From 5a47132313b5280381106bfcac249ea3f8d0e0b0 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 13 Sep 2022 12:32:51 -0400 Subject: [PATCH 001/138] 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 002/138] 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 003/138] 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 004/138] 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 005/138] 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 006/138] 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 007/138] 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 008/138] 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 009/138] 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 010/138] 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 011/138] 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 012/138] 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 013/138] 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 014/138] 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 015/138] 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 016/138] 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 017/138] 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 018/138] 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 019/138] 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 020/138] 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 021/138] 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 022/138] 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 023/138] 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 024/138] 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 025/138] 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 026/138] 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 027/138] 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 028/138] 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 029/138] 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 030/138] 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 031/138] 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 032/138] 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 ( + <> + + {pageTitle(`${template.name} · Collaborators`)} + + + + ) +} + +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 ( - <> - - {pageTitle(`${template.name} · Template`)} - - - - { - 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 ( + <> + + {pageTitle(`${template.name} · Template`)} + + + + ) +} + +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 033/138] 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 ( <> {pageTitle(`${template.name} · Collaborators`)} - + ) } 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 034/138] 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 035/138] 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 036/138] 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> = ( {pageTitle(`${template.name} · Collaborators`)} { 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 037/138] 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( From 5982dd36a4756e72df1ebfac8d497c0914f20670 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 04:13:26 +0000 Subject: [PATCH 038/138] add schema for groups --- coderd/database/dump.sql | 26 +++ .../migrations/000051_template_acl.down.sql | 3 + .../migrations/000051_template_acl.up.sql | 15 ++ coderd/database/models.go | 11 ++ coderd/database/querier.go | 4 + coderd/database/queries.sql.go | 150 ++++++++++++++++++ coderd/database/queries/groups.sql | 52 ++++++ coderd/database/unique_constraint.go | 1 + 8 files changed, 262 insertions(+) create mode 100644 coderd/database/queries/groups.sql diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0e648293926c7..90630a83404aa 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -158,6 +158,17 @@ CREATE TABLE gitsshkeys ( public_key text NOT NULL ); +CREATE TABLE group_users ( + user_id uuid NOT NULL, + group_id uuid NOT NULL +); + +CREATE TABLE groups ( + id uuid NOT NULL, + name text NOT NULL, + organization_id uuid NOT NULL +); + CREATE TABLE licenses ( id integer NOT NULL, uploaded_at timestamp with time zone NOT NULL, @@ -417,6 +428,12 @@ ALTER TABLE ONLY files ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); +ALTER TABLE ONLY groups + ADD CONSTRAINT groups_name_key UNIQUE (name); + +ALTER TABLE ONLY groups + ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); @@ -536,6 +553,15 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); +ALTER TABLE ONLY group_users + ADD CONSTRAINT group_users_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + +ALTER TABLE ONLY group_users + ADD CONSTRAINT group_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY groups + ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000051_template_acl.down.sql b/coderd/database/migrations/000051_template_acl.down.sql index 55019e33d326d..3b89db8e57ce4 100644 --- a/coderd/database/migrations/000051_template_acl.down.sql +++ b/coderd/database/migrations/000051_template_acl.down.sql @@ -4,4 +4,7 @@ ALTER TABLE templates DROP COLUMN user_acl; ALTER TABLE templates DROP COLUMN is_private; DROP TYPE template_role; +DROP TABLE groups; +DROP TABLE group_users; + COMMIT; diff --git a/coderd/database/migrations/000051_template_acl.up.sql b/coderd/database/migrations/000051_template_acl.up.sql index 5f98045f53cbe..cfc18500e53f5 100644 --- a/coderd/database/migrations/000051_template_acl.up.sql +++ b/coderd/database/migrations/000051_template_acl.up.sql @@ -9,4 +9,19 @@ CREATE TYPE template_role AS ENUM ( 'admin' ); +CREATE TABLE groups ( + id uuid NOT NULL, + name text NOT NULL, + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + PRIMARY KEY(id), + UNIQUE(name) +); + +CREATE TABLE group_users ( + user_id uuid NOT NULL, + group_id uuid NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE +); + COMMIT; diff --git a/coderd/database/models.go b/coderd/database/models.go index bcf26bec41488..b8bf04914eb22 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -411,6 +411,17 @@ type GitSSHKey struct { PublicKey string `db:"public_key" json:"public_key"` } +type Group struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +type GroupUser struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` +} + type License struct { ID int32 `db:"id" json:"id"` UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7ceb368e2d11e..272df51addd09 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -37,6 +37,9 @@ type sqlcQuerier interface { GetDeploymentID(ctx context.Context) (string, error) GetFileByHash(ctx context.Context, hash string) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) + GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) + GetGroups(ctx context.Context) ([]Group, error) + GetGroupsByUserID(ctx context.Context, userID uuid.UUID) ([]Group, error) GetLatestAgentStat(ctx context.Context, agentID uuid.UUID) (AgentStat, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) @@ -75,6 +78,7 @@ type sqlcQuerier interface { GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) + GetUsersByGroupID(ctx context.Context, groupID uuid.UUID) ([]GetUsersByGroupIDRow, error) GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams) ([]User, error) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index a132b58443a32..29333f3f9fa9d 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -701,6 +701,156 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar return err } +const getGroupByID = `-- name: GetGroupByID :one +SELECT + id, name, organization_id +FROM + groups +WHERE + id = $1 +LIMIT + 1 +` + +func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) { + row := q.db.QueryRowContext(ctx, getGroupByID, id) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + +const getGroups = `-- name: GetGroups :many +SELECT + id, name, organization_id +FROM + groups +` + +func (q *sqlQuerier) GetGroups(ctx context.Context) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, getGroups) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Group + for rows.Next() { + var i Group + if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGroupsByUserID = `-- name: GetGroupsByUserID :many +SELECT + groups.id, groups.name, groups.organization_id +FROM + groups +JOIN + group_users +ON + groups.id = group_users.group_id +WHERE + group_users.user_id = $1 +` + +func (q *sqlQuerier) GetGroupsByUserID(ctx context.Context, userID uuid.UUID) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, getGroupsByUserID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Group + for rows.Next() { + var i Group + if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUsersByGroupID = `-- name: GetUsersByGroupID :many +SELECT + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, user_id, group_id +FROM + users +JOIN + group_users +ON + users.id = group_users.user_id +WHERE + group_users.group_id = $1 +` + +type GetUsersByGroupIDRow 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"` + Status UserStatus `db:"status" json:"status"` + 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"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` +} + +func (q *sqlQuerier) GetUsersByGroupID(ctx context.Context, groupID uuid.UUID) ([]GetUsersByGroupIDRow, error) { + rows, err := q.db.QueryContext(ctx, getUsersByGroupID, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetUsersByGroupIDRow + for rows.Next() { + var i GetUsersByGroupIDRow + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.RBACRoles, + &i.LoginType, + &i.AvatarURL, + &i.Deleted, + &i.UserID, + &i.GroupID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const deleteLicense = `-- name: DeleteLicense :one DELETE FROM licenses diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql new file mode 100644 index 0000000000000..aacc1ce7f643c --- /dev/null +++ b/coderd/database/queries/groups.sql @@ -0,0 +1,52 @@ +-- name: GetGroupByID :one +SELECT + * +FROM + groups +WHERE + id = $1 +LIMIT + 1; + +-- name: GetGroupByName :one +SELECT + * +FROM + groups +WHERE + name = $1 +LIMIT + 1; + +-- name: GetUserGroups :many +SELECT + groups.* +FROM + groups +JOIN + group_users +ON + groups.id = group_users.group_id +WHERE + group_users.user_id = $1; + + +-- name: GetGroupMembers :many +SELECT + * +FROM + users +JOIN + group_users +ON + users.id = group_users.user_id +WHERE + group_users.group_id = $1; + +-- name: GetGroupsByOrganizationID :many +SELECT + * +FROM + groups +WHERE + organization_id = $1; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index d3b38a8161413..eec999a0e3107 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,6 +6,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( + UniqueGroupsNameKey UniqueConstraint = "groups_name_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_key UNIQUE (name); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); From c759d999ff03ed18d58006b5184ef861ea51f0bd Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 04:13:53 +0000 Subject: [PATCH 039/138] add skeleton for group API routes --- coderd/coderd.go | 12 ++++++ coderd/groups.go | 23 +++++++++++ codersdk/groups.go | 96 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 coderd/groups.go create mode 100644 codersdk/groups.go diff --git a/coderd/coderd.go b/coderd/coderd.go index a526354a4289b..2251eee01eab9 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -268,6 +268,14 @@ func New(options *Options) *API { r.Get("/{hash}", api.fileByHash) r.Post("/", api.postFile) }) + + r.Route("/groups/{group}", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/", api.group) + r.Patch("/", api.patchGroup) + r.Delete("/", api.deleteGroup) + }) + r.Route("/provisionerdaemons", func(r chi.Router) { r.Use( apiKeyMiddleware, @@ -290,6 +298,10 @@ func New(options *Options) *API { r.Get("/", api.templatesByOrganization) r.Get("/{templatename}", api.templateByOrganizationAndName) }) + r.Route("/groups", func(r chi.Router) { + r.Post("/", api.postGroupByOrganization) + r.Get("/", api.groups) + }) r.Post("/workspaces", api.postWorkspacesByOrganization) r.Route("/members", func(r chi.Router) { r.Get("/roles", api.assignableOrgRoles) diff --git a/coderd/groups.go b/coderd/groups.go new file mode 100644 index 0000000000000..6d1afacbe2fbc --- /dev/null +++ b/coderd/groups.go @@ -0,0 +1,23 @@ +package coderd + +import "net/http" + +func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) { + +} + +func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { + +} + +func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { + +} + +func (api *API) group(rw http.ResponseWriter, r *http.Request) { + +} + +func (api *API) groups(rw http.ResponseWriter, r *http.Request) { + +} diff --git a/codersdk/groups.go b/codersdk/groups.go new file mode 100644 index 0000000000000..7577177b7336e --- /dev/null +++ b/codersdk/groups.go @@ -0,0 +1,96 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +type CreateGroupRequest struct { + Name string `json:"name"` +} + +type Group struct { + ID uuid.UUID `json:"uuid"` + Name string `json:"name"` + OrganizationID uuid.UUID `json:"organization_id"` + Members []User `json:"members"` +} + +func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) { + res, err := c.Request(ctx, http.MethodPost, + fmt.Sprintf("/api/v2/organizations/%s/groups", orgID.String()), + req, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +func (c *Client) GroupsByOrganization(ctx context.Context, orgID uuid.UUID) ([]Group, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/groups", orgID.String()), + nil, + ) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return nil, readBodyAsError(res) + } + var resp Group + return nil, json.NewDecoder(res.Body).Decode(&resp) +} + +func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/groups/%s", group.String()), + nil, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +type PatchGroupUsersRequest struct { + AddUsers []string `json:"add_users"` + RemoveUsers []string `json:"remove_users"` + Name string `json:"name"` +} + +func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroupUsersRequest) (Group, error) { + res, err := c.Request(ctx, http.MethodPatch, + fmt.Sprintf("/api/v2/groups/%s", group.String()), + req, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} From 4169569c8ed346205c7130b0aa6e354c43e875bf Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 06:04:31 +0000 Subject: [PATCH 040/138] add create group endpoint --- coderd/database/databasefake/databasefake.go | 64 ++++++++ coderd/database/dump.sql | 12 +- .../migrations/000051_template_acl.up.sql | 4 +- coderd/database/models.go | 2 +- coderd/database/querier.go | 8 +- coderd/database/queries.sql.go | 144 +++++++++++------- coderd/database/queries/groups.sql | 29 ++-- coderd/database/unique_constraint.go | 2 +- coderd/groups.go | 51 ++++++- coderd/groups_test.go | 52 +++++++ coderd/rbac/object.go | 8 + testutil/ctx.go | 12 ++ 12 files changed, 309 insertions(+), 79 deletions(-) create mode 100644 coderd/groups_test.go create mode 100644 testutil/ctx.go diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 38a21e09659a4..4358d1be8187b 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -85,6 +85,7 @@ type data struct { auditLogs []database.AuditLog files []database.File gitSSHKey []database.GitSSHKey + groups []database.Group parameterSchemas []database.ParameterSchema parameterValues []database.ParameterValue provisionerDaemons []database.ProvisionerDaemon @@ -2668,3 +2669,66 @@ func (q *fakeQuerier) UpdateUserLink(_ context.Context, params database.UpdateUs return database.UserLink{}, sql.ErrNoRows } + +func (q *fakeQuerier) GetGroupByID(_ context.Context, id uuid.UUID) (database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, group := range q.groups { + if group.ID == id { + return group, nil + } + } + + return database.Group{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetGroupByOrgAndName(_ context.Context, arg database.GetGroupByOrgAndNameParams) (database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, group := range q.groups { + if group.OrganizationID == arg.OrganizationID && + group.Name == arg.Name { + return group, nil + } + } + + return database.Group{}, sql.ErrNoRows +} + +func (q *fakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupParams) (database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, group := range q.groups { + if group.OrganizationID.String() == arg.OrganizationID.String() && + group.Name == arg.Name { + return database.Group{}, &pq.Error{ + Code: "23505", + } + } + } + + group := database.Group{ + ID: arg.ID, + Name: arg.Name, + OrganizationID: arg.OrganizationID, + } + + q.groups = append(q.groups, group) + + return group, nil +} + +func (q *fakeQuerier) GetUserGroups(_ context.Context, userID uuid.UUID) ([]database.Group, error) { + panic("not implemented") +} + +func (q *fakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]database.User, error) { + panic("not implemented") +} + +func (q *fakeQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.Group, error) { + panic("not implemented") +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 90630a83404aa..19e9cc8c48180 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -158,7 +158,7 @@ CREATE TABLE gitsshkeys ( public_key text NOT NULL ); -CREATE TABLE group_users ( +CREATE TABLE group_members ( user_id uuid NOT NULL, group_id uuid NOT NULL ); @@ -429,7 +429,7 @@ ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); ALTER TABLE ONLY groups - ADD CONSTRAINT groups_name_key UNIQUE (name); + ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); ALTER TABLE ONLY groups ADD CONSTRAINT groups_pkey PRIMARY KEY (id); @@ -553,11 +553,11 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); -ALTER TABLE ONLY group_users - ADD CONSTRAINT group_users_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; +ALTER TABLE ONLY group_members + ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; -ALTER TABLE ONLY group_users - ADD CONSTRAINT group_users_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY group_members + ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; ALTER TABLE ONLY groups ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/migrations/000051_template_acl.up.sql b/coderd/database/migrations/000051_template_acl.up.sql index cfc18500e53f5..bfd555e11e5b0 100644 --- a/coderd/database/migrations/000051_template_acl.up.sql +++ b/coderd/database/migrations/000051_template_acl.up.sql @@ -14,10 +14,10 @@ CREATE TABLE groups ( name text NOT NULL, organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, PRIMARY KEY(id), - UNIQUE(name) + UNIQUE(name, organization_id) ); -CREATE TABLE group_users ( +CREATE TABLE group_members ( user_id uuid NOT NULL, group_id uuid NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, diff --git a/coderd/database/models.go b/coderd/database/models.go index b8bf04914eb22..7e38c4a1570fa 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -417,7 +417,7 @@ type Group struct { OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` } -type GroupUser struct { +type GroupMember struct { UserID uuid.UUID `db:"user_id" json:"user_id"` GroupID uuid.UUID `db:"group_id" json:"group_id"` } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 272df51addd09..d60a1e8e05346 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -38,8 +38,9 @@ type sqlcQuerier interface { GetFileByHash(ctx context.Context, hash string) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) - GetGroups(ctx context.Context) ([]Group, error) - GetGroupsByUserID(ctx context.Context, userID uuid.UUID) ([]Group, error) + GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) + GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) + GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) GetLatestAgentStat(ctx context.Context, agentID uuid.UUID) (AgentStat, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) @@ -75,10 +76,10 @@ type sqlcQuerier interface { GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) + GetUserGroups(ctx context.Context, userID uuid.UUID) ([]Group, error) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) - GetUsersByGroupID(ctx context.Context, groupID uuid.UUID) ([]GetUsersByGroupIDRow, error) GetUsersByIDs(ctx context.Context, arg GetUsersByIDsParams) ([]User, error) GetWorkspaceAgentByAuthToken(ctx context.Context, authToken uuid.UUID) (WorkspaceAgent, error) GetWorkspaceAgentByID(ctx context.Context, id uuid.UUID) (WorkspaceAgent, error) @@ -111,6 +112,7 @@ type sqlcQuerier interface { InsertDeploymentID(ctx context.Context, value string) error InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) + InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 29333f3f9fa9d..ec5ad291f55fd 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -719,23 +719,66 @@ func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, err return i, err } -const getGroups = `-- name: GetGroups :many +const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one SELECT id, name, organization_id FROM groups +WHERE + organization_id = $1 +AND + name = $2 +LIMIT + 1 ` -func (q *sqlQuerier) GetGroups(ctx context.Context) ([]Group, error) { - rows, err := q.db.QueryContext(ctx, getGroups) +type GetGroupByOrgAndNameParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) { + row := q.db.QueryRowContext(ctx, getGroupByOrgAndName, arg.OrganizationID, arg.Name) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + +const getGroupMembers = `-- name: GetGroupMembers :many +SELECT + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted +FROM + users +JOIN + group_members +ON + users.id = group_members.user_id +WHERE + group_members.group_id = $1 +` + +func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) { + rows, err := q.db.QueryContext(ctx, getGroupMembers, groupID) if err != nil { return nil, err } defer rows.Close() - var items []Group + var items []User for rows.Next() { - var i Group - if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.RBACRoles, + &i.LoginType, + &i.AvatarURL, + &i.Deleted, + ); err != nil { return nil, err } items = append(items, i) @@ -749,21 +792,17 @@ func (q *sqlQuerier) GetGroups(ctx context.Context) ([]Group, error) { return items, nil } -const getGroupsByUserID = `-- name: GetGroupsByUserID :many +const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many SELECT - groups.id, groups.name, groups.organization_id + id, name, organization_id FROM groups -JOIN - group_users -ON - groups.id = group_users.group_id WHERE - group_users.user_id = $1 + organization_id = $1 ` -func (q *sqlQuerier) GetGroupsByUserID(ctx context.Context, userID uuid.UUID) ([]Group, error) { - rows, err := q.db.QueryContext(ctx, getGroupsByUserID, userID) +func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, getGroupsByOrganizationID, organizationID) if err != nil { return nil, err } @@ -785,59 +824,29 @@ func (q *sqlQuerier) GetGroupsByUserID(ctx context.Context, userID uuid.UUID) ([ return items, nil } -const getUsersByGroupID = `-- name: GetUsersByGroupID :many +const getUserGroups = `-- name: GetUserGroups :many SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, user_id, group_id + groups.id, groups.name, groups.organization_id FROM - users + groups JOIN - group_users + group_members ON - users.id = group_users.user_id + groups.id = group_members.group_id WHERE - group_users.group_id = $1 + group_members.user_id = $1 ` -type GetUsersByGroupIDRow 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"` - Status UserStatus `db:"status" json:"status"` - 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"` - UserID uuid.UUID `db:"user_id" json:"user_id"` - GroupID uuid.UUID `db:"group_id" json:"group_id"` -} - -func (q *sqlQuerier) GetUsersByGroupID(ctx context.Context, groupID uuid.UUID) ([]GetUsersByGroupIDRow, error) { - rows, err := q.db.QueryContext(ctx, getUsersByGroupID, groupID) +func (q *sqlQuerier) GetUserGroups(ctx context.Context, userID uuid.UUID) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, getUserGroups, userID) if err != nil { return nil, err } defer rows.Close() - var items []GetUsersByGroupIDRow + var items []Group for rows.Next() { - var i GetUsersByGroupIDRow - if err := rows.Scan( - &i.ID, - &i.Email, - &i.Username, - &i.HashedPassword, - &i.CreatedAt, - &i.UpdatedAt, - &i.Status, - &i.RBACRoles, - &i.LoginType, - &i.AvatarURL, - &i.Deleted, - &i.UserID, - &i.GroupID, - ); err != nil { + var i Group + if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { return nil, err } items = append(items, i) @@ -851,6 +860,29 @@ func (q *sqlQuerier) GetUsersByGroupID(ctx context.Context, groupID uuid.UUID) ( return items, nil } +const insertGroup = `-- name: InsertGroup :one +INSERT INTO groups ( + id, + name, + organization_id +) +VALUES + ( $1, $2, $3) RETURNING id, name, organization_id +` + +type InsertGroupParams struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) { + row := q.db.QueryRowContext(ctx, insertGroup, arg.ID, arg.Name, arg.OrganizationID) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + const deleteLicense = `-- name: DeleteLicense :one DELETE FROM licenses diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index aacc1ce7f643c..b1d247a5911a9 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -8,13 +8,15 @@ WHERE LIMIT 1; --- name: GetGroupByName :one +-- name: GetGroupByOrgAndName :one SELECT * FROM groups WHERE - name = $1 + organization_id = $1 +AND + name = $2 LIMIT 1; @@ -24,24 +26,24 @@ SELECT FROM groups JOIN - group_users + group_members ON - groups.id = group_users.group_id + groups.id = group_members.group_id WHERE - group_users.user_id = $1; + group_members.user_id = $1; -- name: GetGroupMembers :many SELECT - * + users.* FROM users JOIN - group_users + group_members ON - users.id = group_users.user_id + users.id = group_members.user_id WHERE - group_users.group_id = $1; + group_members.group_id = $1; -- name: GetGroupsByOrganizationID :many SELECT @@ -50,3 +52,12 @@ FROM groups WHERE organization_id = $1; + +-- name: InsertGroup :one +INSERT INTO groups ( + id, + name, + organization_id +) +VALUES + ( $1, $2, $3) RETURNING *; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index eec999a0e3107..ad82fc37885f2 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,7 +6,7 @@ type UniqueConstraint string // UniqueConstraint enums. const ( - UniqueGroupsNameKey UniqueConstraint = "groups_name_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_key UNIQUE (name); + UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); diff --git a/coderd/groups.go b/coderd/groups.go index 6d1afacbe2fbc..cb57695e7ee88 100644 --- a/coderd/groups.go +++ b/coderd/groups.go @@ -1,9 +1,50 @@ package coderd -import "net/http" +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + org = httpmw.OrganizationParam(r) + ) + + // if api.Authorize(r, rbac.ActionCreate, rbac.ResourceGroup.InOrg(org.ID)) { + // http.NotFound(rw, r) + // return + // } + + var req codersdk.CreateGroupRequest + if !httpapi.Read(rw, r, &req) { + return + } + group, err := api.Database.InsertGroup(ctx, database.InsertGroupParams{ + ID: uuid.New(), + Name: req.Name, + OrganizationID: org.ID, + }) + if database.IsUniqueViolation(err) { + httpapi.Write(rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("Group with name %q already exists.", req.Name), + }) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(rw, http.StatusCreated, group) } func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { @@ -21,3 +62,11 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { func (api *API) groups(rw http.ResponseWriter, r *http.Request) { } + +func convertGroup(g database.Group) codersdk.Group { + return codersdk.Group{ + ID: g.ID, + Name: g.Name, + OrganizationID: g.OrganizationID, + } +} diff --git a/coderd/groups_test.go b/coderd/groups_test.go new file mode 100644 index 0000000000000..95b3d1d5db636 --- /dev/null +++ b/coderd/groups_test.go @@ -0,0 +1,52 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/testutil" +) + +func TestCreateGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + require.Equal(t, "hi", group.Name) + require.Empty(t, group.Members) + }) + + t.Run("Conflict", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, _ := testutil.Context(t) + _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + _, err = client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusConflict, cerr.StatusCode()) + }) +} diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 460bf877c335d..8a3ff250f6d0d 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -54,6 +54,14 @@ var ( Type: "template", } + // ResourceGroup CRUD. Org admins only. + // create/delete = Make or delete a new group. + // update = Update the name or members of a group. + // read = Read groups and their members. + ResourceGroup = Object{ + Type: "group", + } + ResourceTemplatePrivate = Object{ Type: "template_private", } diff --git a/testutil/ctx.go b/testutil/ctx.go new file mode 100644 index 0000000000000..1fe58ca7db088 --- /dev/null +++ b/testutil/ctx.go @@ -0,0 +1,12 @@ +package testutil + +import ( + "context" + "testing" +) + +func Context(t *testing.T) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(context.Background(), WaitLong) + t.Cleanup(cancel) + return ctx, cancel +} From a8943c94ca872b48e5e99faddc8dedb8c4844cd5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 07:11:48 +0000 Subject: [PATCH 041/138] add group httpmw --- coderd/httpmw/groupparam.go | 54 ++++++++++++++++ coderd/httpmw/groupparam_test.go | 103 +++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 coderd/httpmw/groupparam.go create mode 100644 coderd/httpmw/groupparam_test.go diff --git a/coderd/httpmw/groupparam.go b/coderd/httpmw/groupparam.go new file mode 100644 index 0000000000000..2d89b59624f41 --- /dev/null +++ b/coderd/httpmw/groupparam.go @@ -0,0 +1,54 @@ +package httpmw + +import ( + "context" + "database/sql" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" +) + +type groupParamContextKey struct{} + +// GroupParam returns the group extracted via the ExtraGroupParam middleware. +func GroupParam(r *http.Request) database.Group { + group, ok := r.Context().Value(groupParamContextKey{}).(database.Group) + if !ok { + panic("developer error: group param middleware not provided") + } + return group +} + +// ExtraGroupParam grabs a group from the "group" URL parameter. +func ExtractGroupParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + groupID, parsed := parseUUID(rw, r, "group") + if !parsed { + return + } + + group, err := db.GetGroupByID(r.Context(), groupID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching group.", + Detail: err.Error(), + }) + return + } + + ctx := context.WithValue(r.Context(), groupParamContextKey{}, group) + chi.RouteContext(ctx).URLParams.Add("organization", group.OrganizationID.String()) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/groupparam_test.go b/coderd/httpmw/groupparam_test.go new file mode 100644 index 0000000000000..70850de4ce9be --- /dev/null +++ b/coderd/httpmw/groupparam_test.go @@ -0,0 +1,103 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/testutil" +) + +func TestGroupParam(t *testing.T) { + t.Parallel() + + setup := func(t *testing.T) (database.Store, database.Group) { + t.Helper() + + ctx, _ := testutil.Context(t) + db := databasefake.New() + + orgID := uuid.New() + organization, err := db.InsertOrganization(ctx, database.InsertOrganizationParams{ + ID: orgID, + Name: "banana", + Description: "wowie", + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + + group, err := db.InsertGroup(ctx, database.InsertGroupParams{ + ID: uuid.New(), + Name: "yeww", + OrganizationID: organization.ID, + }) + require.NoError(t, err) + + return db, group + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var ( + db, group = setup(t) + r = httptest.NewRequest("GET", "/", nil) + w = httptest.NewRecorder() + ) + + router := chi.NewRouter() + router.Use(httpmw.ExtractGroupParam(db)) + router.Get("/", func(w http.ResponseWriter, r *http.Request) { + g := httpmw.GroupParam(r) + require.Equal(t, group, g) + w.WriteHeader(http.StatusOK) + }) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("group", group.ID.String()) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + + router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + var ( + db, group = setup(t) + r = httptest.NewRequest("GET", "/", nil) + w = httptest.NewRecorder() + ) + + router := chi.NewRouter() + router.Use(httpmw.ExtractGroupParam(db)) + router.Get("/", func(w http.ResponseWriter, r *http.Request) { + g := httpmw.GroupParam(r) + require.Equal(t, group, g) + w.WriteHeader(http.StatusOK) + }) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("group", uuid.NewString()) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + + router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} From 9fbc15fb8a2df276b767c7a67a97cf95fff509e0 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 08:09:48 +0000 Subject: [PATCH 042/138] add patch group endpoint --- coderd/coderd.go | 5 +- coderd/database/querier.go | 3 + coderd/database/queries.sql.go | 52 +++++++++++++ coderd/database/queries/groups.sql | 23 ++++++ coderd/groups.go | 111 +++++++++++++++++++++++++- coderd/groups_test.go | 121 +++++++++++++++++++++++++++++ codersdk/groups.go | 4 +- 7 files changed, 315 insertions(+), 4 deletions(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 2251eee01eab9..25633d560d365 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -270,7 +270,10 @@ func New(options *Options) *API { }) r.Route("/groups/{group}", func(r chi.Router) { - r.Use(apiKeyMiddleware) + r.Use( + apiKeyMiddleware, + httpmw.ExtractGroupParam(api.Database), + ) r.Get("/", api.group) r.Patch("/", api.patchGroup) r.Delete("/", api.deleteGroup) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index d60a1e8e05346..7b5c090cdc064 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -21,6 +21,7 @@ type sqlcQuerier interface { AcquireProvisionerJob(ctx context.Context, arg AcquireProvisionerJobParams) (ProvisionerJob, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error + DeleteGroupMember(ctx context.Context, userID uuid.UUID) error DeleteLicense(ctx context.Context, id int32) (int32, error) DeleteOldAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error @@ -113,6 +114,7 @@ type sqlcQuerier interface { InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) + InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) @@ -135,6 +137,7 @@ type sqlcQuerier interface { ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) error + UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) UpdateProvisionerDaemonByID(ctx context.Context, arg UpdateProvisionerDaemonByIDParams) error UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ec5ad291f55fd..b9f34fbb5d297 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -701,6 +701,18 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar return err } +const deleteGroupMember = `-- name: DeleteGroupMember :exec +DELETE FROM + group_members +WHERE + user_id = $1 +` + +func (q *sqlQuerier) DeleteGroupMember(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteGroupMember, userID) + return err +} + const getGroupByID = `-- name: GetGroupByID :one SELECT id, name, organization_id @@ -883,6 +895,46 @@ func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Gr return i, err } +const insertGroupMember = `-- name: InsertGroupMember :exec +INSERT INTO group_members ( + user_id, + group_id +) +VALUES ( $1, $2) +` + +type InsertGroupMemberParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` +} + +func (q *sqlQuerier) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error { + _, err := q.db.ExecContext(ctx, insertGroupMember, arg.UserID, arg.GroupID) + return err +} + +const updateGroupByID = `-- name: UpdateGroupByID :one +UPDATE + groups +SET + name = $1 +WHERE + id = $2 +RETURNING id, name, organization_id +` + +type UpdateGroupByIDParams struct { + Name string `db:"name" json:"name"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) { + row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, arg.ID) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + const deleteLicense = `-- name: DeleteLicense :one DELETE FROM licenses diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index b1d247a5911a9..6960ccbf18ac6 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -61,3 +61,26 @@ INSERT INTO groups ( ) VALUES ( $1, $2, $3) RETURNING *; + +-- name: UpdateGroupByID :one +UPDATE + groups +SET + name = $1 +WHERE + id = $2 +RETURNING *; + +-- name: InsertGroupMember :exec +INSERT INTO group_members ( + user_id, + group_id +) +VALUES ( $1, $2); + +-- name: DeleteGroupMember :exec +DELETE FROM + group_members +WHERE + user_id = $1; + diff --git a/coderd/groups.go b/coderd/groups.go index cb57695e7ee88..5fbe91d5d4b87 100644 --- a/coderd/groups.go +++ b/coderd/groups.go @@ -1,10 +1,12 @@ package coderd import ( + "database/sql" "fmt" "net/http" "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" @@ -48,7 +50,105 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) } func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + group = httpmw.GroupParam(r) + ) + + // if api.Authorize(r, rbac.ActionUpdate, rbac.ResourceGroup.InOrg(group.OrganizationID)) { + // http.NotFound(rw, r) + // return + // } + + var req codersdk.PatchGroupRequest + if !httpapi.Read(rw, r, &req) { + return + } + + users := make([]string, 0, len(req.AddUsers)+len(req.RemoveUsers)) + users = append(users, req.AddUsers...) + users = append(users, req.RemoveUsers...) + + for _, id := range users { + if _, err := uuid.Parse(id); err != nil { + httpapi.Write(rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("ID %q must be a valid user UUID.", id), + }) + return + } + // TODO: It would be nice to enforce this at the schema level + // but unfortunately our org_members table does not have an ID. + _, err := api.Database.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{ + OrganizationID: group.OrganizationID, + UserID: uuid.MustParse(id), + }) + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: fmt.Sprintf("User %q must be a member of organization %q", id, group.ID), + }) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + } + if req.Name != "" { + _, err := api.Database.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ + OrganizationID: group.OrganizationID, + Name: req.Name, + }) + if err == nil { + httpapi.Write(rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("A group with name %q already exists.", req.Name), + }) + return + } + } + + err := api.Database.InTx(func(tx database.Store) error { + if req.Name != "" { + var err error + group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ + ID: group.ID, + Name: req.Name, + }) + if err != nil { + return xerrors.Errorf("update group by ID: %w", err) + } + } + for _, id := range req.AddUsers { + err := tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{ + GroupID: group.ID, + UserID: uuid.MustParse(id), + }) + if err != nil { + return xerrors.Errorf("insert group member %q: %w", id, err) + } + } + for _, id := range req.RemoveUsers { + err := tx.DeleteGroupMember(ctx, uuid.MustParse(id)) + if err != nil { + return xerrors.Errorf("insert group member %q: %w", id, err) + } + } + return nil + }) + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: "Failed to add or remove non-existent group member", + Detail: err.Error(), + }) + return + } + members, err := api.Database.GetGroupMembers(ctx, group.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(rw, http.StatusOK, convertGroup(group, members)) } func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { @@ -63,10 +163,19 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) { } -func convertGroup(g database.Group) codersdk.Group { +func convertGroup(g database.Group, users []database.User) codersdk.Group { + // It's ridiculous to query all the orgs of a user here + // especially since as of the writing of this comment there + // is only one org. So we pretend everyone is only part of + // the group's organization. + orgs := make(map[uuid.UUID][]uuid.UUID) + for _, user := range users { + orgs[user.ID] = []uuid.UUID{g.OrganizationID} + } return codersdk.Group{ ID: g.ID, Name: g.Name, OrganizationID: g.OrganizationID, + Members: convertUsers(users, orgs), } } diff --git a/coderd/groups_test.go b/coderd/groups_test.go index 95b3d1d5db636..4b951a892ec78 100644 --- a/coderd/groups_test.go +++ b/coderd/groups_test.go @@ -4,6 +4,7 @@ import ( "net/http" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/coder/coder/coderd/coderdtest" @@ -50,3 +51,123 @@ func TestCreateGroup(t *testing.T) { require.Equal(t, http.StatusConflict, cerr.StatusCode()) }) } + +func TestPatchGroup(t *testing.T) { + t.Parallel() + + t.Run("Name", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: "bye", + }) + require.NoError(t, err) + require.Equal(t, "bye", group.Name) + }) + + t.Run("AddUsers", 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) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user2) + require.Contains(t, group.Members, user3) + }) + + t.Run("RemoveUsers", 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) + _, user4 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String(), user4.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user2) + require.Contains(t, group.Members, user3) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + RemoveUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + require.NotContains(t, group.Members, user2) + require.NotContains(t, group.Members, user3) + require.Contains(t, group.Members, user4) + }) + + t.Run("UserNotExist", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{uuid.NewString()}, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusPreconditionFailed, cerr.StatusCode()) + }) + + t.Run("MalformedUUID", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{"yeet"}, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) +} diff --git a/codersdk/groups.go b/codersdk/groups.go index 7577177b7336e..1f7693bd84078 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -72,13 +72,13 @@ func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { return resp, json.NewDecoder(res.Body).Decode(&resp) } -type PatchGroupUsersRequest struct { +type PatchGroupRequest struct { AddUsers []string `json:"add_users"` RemoveUsers []string `json:"remove_users"` Name string `json:"name"` } -func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroupUsersRequest) (Group, error) { +func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroupRequest) (Group, error) { res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/groups/%s", group.String()), req, From baaf44592c7fd9e875fd95183f66953785e12592 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 16:25:24 +0000 Subject: [PATCH 043/138] add test pkg for opening database --- coderd/coderdtest/coderdtest.go | 26 ++------------------- coderd/database/dbtestutil/db.go | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 24 deletions(-) create mode 100644 coderd/database/dbtestutil/db.go diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 52bb2a08a385c..65116ca88208e 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -9,7 +9,6 @@ import ( "crypto/sha256" "crypto/x509" "crypto/x509/pkix" - "database/sql" "encoding/base64" "encoding/json" "encoding/pem" @@ -21,7 +20,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "os" "strconv" "strings" "testing" @@ -49,8 +47,7 @@ import ( "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/databasefake" - "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" @@ -137,26 +134,7 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance }) } - // This can be hotswapped for a live database instance. - db := databasefake.New() - pubsub := database.NewPubsubInMemory() - if os.Getenv("DB") != "" { - connectionURL, closePg, err := postgres.Open() - require.NoError(t, err) - t.Cleanup(closePg) - sqlDB, err := sql.Open("postgres", connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - _ = sqlDB.Close() - }) - db = database.New(sqlDB) - - pubsub, err = database.NewPubsub(context.Background(), sqlDB, connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - _ = pubsub.Close() - }) - } + db, pubsub := dbtestutil.NewDB(t) ctx, cancelFunc := context.WithCancel(context.Background()) lifecycleExecutor := executor.New( diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go new file mode 100644 index 0000000000000..7e5e99f5d09d4 --- /dev/null +++ b/coderd/database/dbtestutil/db.go @@ -0,0 +1,39 @@ +package dbtestutil + +import ( + "context" + "database/sql" + "os" + "testing" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/database/postgres" + "github.com/stretchr/testify/require" +) + +func NewDB(t *testing.T) (database.Store, database.Pubsub) { + t.Helper() + + db := databasefake.New() + pubsub := database.NewPubsubInMemory() + if os.Getenv("DB") != "" { + connectionURL, closePg, err := postgres.Open() + require.NoError(t, err) + t.Cleanup(closePg) + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + db = database.New(sqlDB) + + pubsub, err = database.NewPubsub(context.Background(), sqlDB, connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = pubsub.Close() + }) + } + + return db, pubsub +} From 4f1a30853bcdbc25bf75d98b8c52a2f63849fa4f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Sep 2022 12:37:14 -0400 Subject: [PATCH 044/138] test: Add unit test to exercise roles query with multiple orgs Roles from multiple orgs are not being fetched. Only 1 org is being returned for authorization --- coderd/httpmw/authorize_test.go | 59 ++++++++++++++++++++++++++++----- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index e1ac548092f8b..030737926173f 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -4,23 +4,22 @@ import ( "context" "crypto/sha256" "fmt" + "net" "net/http" "net/http/httptest" "testing" "time" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - - "github.com/google/uuid" - - "github.com/coder/coder/coderd/database" - "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/tabbed/pqtype" - "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" ) func TestExtractUserRoles(t *testing.T) { @@ -71,14 +70,48 @@ func TestExtractUserRoles(t *testing.T) { return user, append(roles, append(orgRoles, rbac.RoleMember(), rbac.RoleOrgMember(org.ID))...), token }, }, + { + Name: "MultipleOrgMember", + AddUser: func(db database.Store) (database.User, []string, string) { + roles := []string{} + user, token := addUser(t, db, roles...) + roles = append(roles, rbac.RoleMember()) + for i := 0; i < 3; i++ { + organization, err := db.InsertOrganization(context.Background(), database.InsertOrganizationParams{ + ID: uuid.New(), + Name: fmt.Sprintf("testorg%d", i), + Description: "test", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + require.NoError(t, err) + + orgRoles := []string{} + if i%2 == 0 { + orgRoles = append(orgRoles, rbac.RoleOrgAdmin(organization.ID)) + } + _, err = db.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ + OrganizationID: organization.ID, + UserID: user.ID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Roles: orgRoles, + }) + roles = append(roles, orgRoles...) + roles = append(roles, rbac.RoleOrgMember(organization.ID)) + } + return user, roles, token + }, + }, } for _, c := range testCases { c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() + var ( - db = databasefake.New() + db, _ = dbtestutil.NewDB(t) user, expRoles, token = c.AddUser(db) rw = httptest.NewRecorder() rtr = chi.NewRouter() @@ -114,6 +147,7 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s Email: "admin@email.com", Username: "admin", RBACRoles: roles, + LoginType: database.LoginTypePassword, }) require.NoError(t, err) @@ -125,6 +159,13 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s ExpiresAt: database.Now().Add(time.Minute), LoginType: database.LoginTypePassword, Scope: database.APIKeyScopeAll, + IPAddress: pqtype.Inet{ + IPNet: net.IPNet{ + IP: net.ParseIP("0.0.0.0"), + Mask: net.IPMask{0, 0, 0, 0}, + }, + Valid: true, + }, }) require.NoError(t, err) From f98c3b77c761edba519fb7f16a1c9608b1bf01b4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Sep 2022 12:38:18 -0400 Subject: [PATCH 045/138] feat: Add group support to rego policy Fix authorization query to return all roles and groups for a given user. --- coderd/database/databasefake/databasefake.go | 15 +++++++ coderd/database/queries/users.sql | 42 +++++++++++++++++--- coderd/rbac/policy.rego | 25 +++++++++--- 3 files changed, 71 insertions(+), 11 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 4358d1be8187b..a893f43ddff6b 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2419,6 +2419,21 @@ func (q *fakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitS return sql.ErrNoRows } +func (q *fakeQuerier) InsertGroupMember(ctx context.Context, arg database.InsertGroupMemberParams) error { + panic("not implemented") + return nil +} + +func (q *fakeQuerier) DeleteGroupMember(ctx context.Context, userID uuid.UUID) error { + panic("not implemented") + return nil +} + +func (q *fakeQuerier) UpdateGroupByID(ctx context.Context, arg database.UpdateGroupByIDParams) (database.Group, error) { + panic("not implemented") + return database.Group{}, nil +} + func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 59332c5a57963..f6628081203ab 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -166,15 +166,45 @@ SELECT -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. id, username, status, + -- Roles. The SQL is 2 nested sub queries because the innermost subquery returns a 2 dimensional array + -- of roles. 'unnest' is used to flatten the array into rows, and then 'array_agg' to convert the rows + -- into a 1 dimensional array. Unfortunately 'array_agg(unnest(...))' cannot be called, so we need to + -- do the inner call as a subquery. array_cat( -- All users are members - array_append(users.rbac_roles, 'member'), - -- All org_members get the org-member role for their orgs - array_append(organization_members.roles, 'organization-member:'||organization_members.organization_id::text)) :: text[] - AS roles + array_append(users.rbac_roles, 'member'), + ( + SELECT + array_agg(org_member_roles.values) + FROM ( + SELECT unnest( + array_agg( + array_append( + organization_members.roles, + -- All org_members get the org-member role for their orgs + 'organization-member:' || organization_members.organization_id::text + ) + ) + ) AS values + FROM + organization_members + WHERE + user_id = users.id + ) AS org_member_roles + ) + ) AS roles, + -- All groups the user is in. + ( + SELECT + array_agg( + group_members.group_id :: text + ) + FROM + group_members + WHERE + user_id = users.id + ) AS groups FROM users -LEFT JOIN organization_members - ON id = user_id WHERE id = @user_id; diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 9b0761edae247..5eee936bb735b 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_user_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 --unknowns input.object.acl_group_list -i input.json # # This policy is specifically constructed to compress to a set of queries if the @@ -157,14 +157,29 @@ allow { user = 1 } -# ACL Allow +# ACL for users allow { # Should you have to be a member of the org too? perms := input.object.acl_user_list[input.subject.id] - input.action in perms + # Either the input action or wildcard + [input.action, "*"][_] in perms } -# ACL wildcard allow +# ACL for groups allow { - "*" in input.object.acl_user_list[input.subject.id] + # If there is no organization owner, the object cannot be owned by an + # org_scoped team. + # TODO: This line and 'org_mem' are similiar and should be combined. + # Currently the simplfied queries return extra queries that are always + # false. If these 2 lines are combined, we reduce the number of queries + # returned by partial execution. + input.object.org_owner != "" + # Only people in the org can use the team access. + org_mem + group := input.subject.groups[input.object.org_owner][_] + perms := input.object.acl_group_list[group] + # Either the input action or wildcard + [input.action, "*"][_] in perms } + + From 930cdf6d73a05fc999ae2278a00ad866fa14cd1a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Sep 2022 12:51:18 -0400 Subject: [PATCH 046/138] Add query to include group fetch --- coderd/database/queries.sql.go | 44 ++++++++++++++++++++++++++----- coderd/database/queries/users.sql | 4 +-- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b9f34fbb5d297..875004082e52f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3215,16 +3215,46 @@ SELECT -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. id, username, status, + -- Roles. The SQL is 2 nested sub queries because the innermost subquery returns a 2 dimensional array + -- of roles. 'unnest' is used to flatten the array into rows, and then 'array_agg' to convert the rows + -- into a 1 dimensional array. Unfortunately 'array_agg(unnest(...))' cannot be called, so we need to + -- do the inner call as a subquery. array_cat( -- All users are members - array_append(users.rbac_roles, 'member'), - -- All org_members get the org-member role for their orgs - array_append(organization_members.roles, 'organization-member:'||organization_members.organization_id::text)) :: text[] - AS roles + array_append(users.rbac_roles, 'member'), + ( + SELECT + array_agg(org_member_roles.values) + FROM ( + SELECT unnest( + array_agg( + array_append( + organization_members.roles, + -- All org_members get the org-member role for their orgs + 'organization-member:' || organization_members.organization_id::text + ) + ) + ) AS values + FROM + organization_members + WHERE + user_id = users.id + ) AS org_member_roles + ) + ) :: text[] AS roles, + -- All groups the user is in. + ( + SELECT + array_agg( + group_members.group_id :: text + ) + FROM + group_members + WHERE + user_id = users.id + ) :: text[] AS groups FROM users -LEFT JOIN organization_members - ON id = user_id WHERE id = $1 ` @@ -3234,6 +3264,7 @@ type GetAuthorizationUserRolesRow struct { Username string `db:"username" json:"username"` Status UserStatus `db:"status" json:"status"` Roles []string `db:"roles" json:"roles"` + Groups []string `db:"groups" json:"groups"` } // This function returns roles for authorization purposes. Implied member roles @@ -3246,6 +3277,7 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid. &i.Username, &i.Status, pq.Array(&i.Roles), + pq.Array(&i.Groups), ) return i, err } diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index f6628081203ab..1c2af66d4c2fe 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -192,7 +192,7 @@ SELECT user_id = users.id ) AS org_member_roles ) - ) AS roles, + ) :: text[] AS roles, -- All groups the user is in. ( SELECT @@ -203,7 +203,7 @@ SELECT group_members WHERE user_id = users.id - ) AS groups + ) :: text[] AS groups FROM users WHERE From b26cd97327007a9322bf9dac385acac2d2da2f4a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Sep 2022 13:11:26 -0400 Subject: [PATCH 047/138] Fix auth query --- coderd/database/queries.sql.go | 24 ++++++++---------------- coderd/database/queries/users.sql | 30 ++++++++++-------------------- 2 files changed, 18 insertions(+), 36 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 875004082e52f..064e0d6acc6ab 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3224,22 +3224,14 @@ SELECT array_append(users.rbac_roles, 'member'), ( SELECT - array_agg(org_member_roles.values) - FROM ( - SELECT unnest( - array_agg( - array_append( - organization_members.roles, - -- All org_members get the org-member role for their orgs - 'organization-member:' || organization_members.organization_id::text - ) - ) - ) AS values - FROM - organization_members - WHERE - user_id = users.id - ) AS org_member_roles + array_agg(org_roles) + FROM + organization_members, + unnest( + array_append(roles, 'organization-member:' || organization_members.organization_id::text) + ) AS org_roles + WHERE + user_id = users.id ) ) :: text[] AS roles, -- All groups the user is in. diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 1c2af66d4c2fe..d43fcf490070e 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -166,31 +166,21 @@ SELECT -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. id, username, status, - -- Roles. The SQL is 2 nested sub queries because the innermost subquery returns a 2 dimensional array - -- of roles. 'unnest' is used to flatten the array into rows, and then 'array_agg' to convert the rows - -- into a 1 dimensional array. Unfortunately 'array_agg(unnest(...))' cannot be called, so we need to - -- do the inner call as a subquery. + -- All user roles, including their org roles. array_cat( -- All users are members array_append(users.rbac_roles, 'member'), ( SELECT - array_agg(org_member_roles.values) - FROM ( - SELECT unnest( - array_agg( - array_append( - organization_members.roles, - -- All org_members get the org-member role for their orgs - 'organization-member:' || organization_members.organization_id::text - ) - ) - ) AS values - FROM - organization_members - WHERE - user_id = users.id - ) AS org_member_roles + array_agg(org_roles) + FROM + organization_members, + -- All org_members get the org-member role for their orgs + unnest( + array_append(roles, 'organization-member:' || organization_members.organization_id::text) + ) AS org_roles + WHERE + user_id = users.id ) ) :: text[] AS roles, -- All groups the user is in. From bf13f376af0ff1ef215f3d31d0c755074395f324 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 17:30:13 +0000 Subject: [PATCH 048/138] add patch group endpoint w/ tests --- coderd/database/databasefake/databasefake.go | 74 ++++++++++++++++---- coderd/database/dbtestutil/db.go | 3 +- coderd/groups.go | 23 +++--- coderd/groups_test.go | 1 + codersdk/groups.go | 2 +- 5 files changed, 78 insertions(+), 25 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index a893f43ddff6b..d4a449cb91262 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -24,12 +24,13 @@ func New() database.Store { return &fakeQuerier{ mutex: &sync.RWMutex{}, data: &data{ - apiKeys: make([]database.APIKey, 0), - agentStats: make([]database.AgentStat, 0), - organizationMembers: make([]database.OrganizationMember, 0), - organizations: make([]database.Organization, 0), - users: make([]database.User, 0), - + apiKeys: make([]database.APIKey, 0), + agentStats: make([]database.AgentStat, 0), + organizationMembers: make([]database.OrganizationMember, 0), + organizations: make([]database.Organization, 0), + users: make([]database.User, 0), + groups: make([]database.Group, 0), + groupMembers: make([]database.GroupMember, 0), auditLogs: make([]database.AuditLog, 0), files: make([]database.File, 0), gitSSHKey: make([]database.GitSSHKey, 0), @@ -86,6 +87,7 @@ type data struct { files []database.File gitSSHKey []database.GitSSHKey groups []database.Group + groupMembers []database.GroupMember parameterSchemas []database.ParameterSchema parameterValues []database.ParameterValue provisionerDaemons []database.ProvisionerDaemon @@ -2419,19 +2421,42 @@ func (q *fakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitS return sql.ErrNoRows } -func (q *fakeQuerier) InsertGroupMember(ctx context.Context, arg database.InsertGroupMemberParams) error { - panic("not implemented") +func (q *fakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGroupMemberParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + q.groupMembers = append(q.groupMembers, database.GroupMember{ + GroupID: arg.GroupID, + UserID: arg.UserID, + }) + return nil } -func (q *fakeQuerier) DeleteGroupMember(ctx context.Context, userID uuid.UUID) error { - panic("not implemented") +func (q *fakeQuerier) DeleteGroupMember(_ context.Context, userID uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, member := range q.groupMembers { + if member.UserID == userID { + q.groupMembers = append(q.groupMembers[:i], q.groupMembers[i+1:]...) + } + } return nil } -func (q *fakeQuerier) UpdateGroupByID(ctx context.Context, arg database.UpdateGroupByIDParams) (database.Group, error) { - panic("not implemented") - return database.Group{}, nil +func (q *fakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGroupByIDParams) (database.Group, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, group := range q.groups { + if group.ID == arg.ID { + group.Name = arg.Name + q.groups[i] = group + return group, nil + } + } + return database.Group{}, sql.ErrNoRows } func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { @@ -2741,7 +2766,28 @@ func (q *fakeQuerier) GetUserGroups(_ context.Context, userID uuid.UUID) ([]data } func (q *fakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]database.User, error) { - panic("not implemented") + q.mutex.RLock() + defer q.mutex.RUnlock() + + var members []database.GroupMember + for _, member := range q.groupMembers { + if member.GroupID == groupID { + members = append(members, member) + } + } + + users := make([]database.User, 0, len(members)) + + for _, member := range members { + for _, user := range q.users { + if user.ID == member.UserID { + users = append(users, user) + break + } + } + } + + return users, nil } func (q *fakeQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.Group, error) { diff --git a/coderd/database/dbtestutil/db.go b/coderd/database/dbtestutil/db.go index 7e5e99f5d09d4..2ca9e95a8af25 100644 --- a/coderd/database/dbtestutil/db.go +++ b/coderd/database/dbtestutil/db.go @@ -6,10 +6,11 @@ import ( "os" "testing" + "github.com/stretchr/testify/require" + "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/database/databasefake" "github.com/coder/coder/coderd/database/postgres" - "github.com/stretchr/testify/require" ) func NewDB(t *testing.T) (database.Store, database.Pubsub) { diff --git a/coderd/groups.go b/coderd/groups.go index 5fbe91d5d4b87..54147596bd2a7 100644 --- a/coderd/groups.go +++ b/coderd/groups.go @@ -11,6 +11,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) @@ -20,10 +21,10 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) org = httpmw.OrganizationParam(r) ) - // if api.Authorize(r, rbac.ActionCreate, rbac.ResourceGroup.InOrg(org.ID)) { - // http.NotFound(rw, r) - // return - // } + if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceGroup.InOrg(org.ID)) { + http.NotFound(rw, r) + return + } var req codersdk.CreateGroupRequest if !httpapi.Read(rw, r, &req) { @@ -46,7 +47,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) return } - httpapi.Write(rw, http.StatusCreated, group) + httpapi.Write(rw, http.StatusCreated, convertGroup(group, nil)) } func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { @@ -55,10 +56,10 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) - // if api.Authorize(r, rbac.ActionUpdate, rbac.ResourceGroup.InOrg(group.OrganizationID)) { - // http.NotFound(rw, r) - // return - // } + if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceGroup.InOrg(group.OrganizationID)) { + http.NotFound(rw, r) + return + } var req codersdk.PatchGroupRequest if !httpapi.Read(rw, r, &req) { @@ -141,6 +142,10 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { }) return } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } members, err := api.Database.GetGroupMembers(ctx, group.ID) if err != nil { diff --git a/coderd/groups_test.go b/coderd/groups_test.go index 4b951a892ec78..85b30e46d5677 100644 --- a/coderd/groups_test.go +++ b/coderd/groups_test.go @@ -28,6 +28,7 @@ func TestCreateGroup(t *testing.T) { require.NoError(t, err) require.Equal(t, "hi", group.Name) require.Empty(t, group.Members) + require.NotEqual(t, uuid.Nil.String(), group.ID.String()) }) t.Run("Conflict", func(t *testing.T) { diff --git a/codersdk/groups.go b/codersdk/groups.go index 1f7693bd84078..803375d21383a 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -88,7 +88,7 @@ func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroup } defer res.Body.Close() - if res.StatusCode != http.StatusCreated { + if res.StatusCode != http.StatusOK { return Group{}, readBodyAsError(res) } var resp Group From eea0aeeb9cb39b3229a6c7fe418f1e12b7ce60f8 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 17:36:18 +0000 Subject: [PATCH 049/138] add get group endpoint w/ tests --- coderd/groups.go | 16 ++++++++++++++ coderd/groups_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++ codersdk/groups.go | 2 +- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/coderd/groups.go b/coderd/groups.go index 54147596bd2a7..f35f77c6088cb 100644 --- a/coderd/groups.go +++ b/coderd/groups.go @@ -161,7 +161,23 @@ func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { } func (api *API) group(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + group = httpmw.GroupParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceTemplate) { + httpapi.ResourceNotFound(rw) + return + } + + users, err := api.Database.GetGroupMembers(ctx, group.ID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + httpapi.Write(rw, http.StatusOK, convertGroup(group, users)) } func (api *API) groups(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/groups_test.go b/coderd/groups_test.go index 85b30e46d5677..1d7fe6d74f443 100644 --- a/coderd/groups_test.go +++ b/coderd/groups_test.go @@ -172,3 +172,52 @@ func TestPatchGroup(t *testing.T) { require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) }) } + +// TODO: test auth. +func TestGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + ggroup, err := client.Group(ctx, group.ID) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) + + t.Run("WithUsers", 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) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user2) + require.Contains(t, group.Members, user3) + + ggroup, err := client.Group(ctx, group.ID) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) +} diff --git a/codersdk/groups.go b/codersdk/groups.go index 803375d21383a..d081c2d9a0c0b 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -65,7 +65,7 @@ func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { } defer res.Body.Close() - if res.StatusCode != http.StatusCreated { + if res.StatusCode != http.StatusOK { return Group{}, readBodyAsError(res) } var resp Group From d70911b1b94ce49505ffdff8abd555280b389651 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 18:18:22 +0000 Subject: [PATCH 050/138] add groups endpoint with tests --- coderd/database/databasefake/databasefake.go | 12 ++++- coderd/database/modelmethods.go | 4 ++ coderd/groups.go | 44 ++++++++++++++-- coderd/groups_test.go | 53 ++++++++++++++++++++ codersdk/groups.go | 7 +-- 5 files changed, 113 insertions(+), 7 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index d4a449cb91262..3d5c392219fbb 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2791,5 +2791,15 @@ func (q *fakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]d } func (q *fakeQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.Group, error) { - panic("not implemented") + q.mutex.RLock() + defer q.mutex.RUnlock() + + var groups []database.Group + for _, group := range q.groups { + if group.OrganizationID == organizationID { + groups = append(groups, group) + } + } + + return groups, nil } diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index b9a66b9d04f1c..3e574ffd69712 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -80,6 +80,10 @@ func (TemplateVersion) RBACObject(template Template) rbac.Object { return template.RBACObject() } +func (g Group) RBACObject() rbac.Object { + return rbac.ResourceGroup.InOrg(g.OrganizationID) +} + func (w Workspace) RBACObject() rbac.Object { return rbac.ResourceWorkspace.InOrg(w.OrganizationID).WithOwner(w.OwnerID.String()) } diff --git a/coderd/groups.go b/coderd/groups.go index f35f77c6088cb..f334b5eda0a30 100644 --- a/coderd/groups.go +++ b/coderd/groups.go @@ -21,7 +21,7 @@ func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) org = httpmw.OrganizationParam(r) ) - if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceGroup.InOrg(org.ID)) { + if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceGroup) { http.NotFound(rw, r) return } @@ -56,7 +56,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) - if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceGroup.InOrg(group.OrganizationID)) { + if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceGroup) { http.NotFound(rw, r) return } @@ -166,7 +166,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) - if !api.Authorize(r, rbac.ActionRead, rbac.ResourceTemplate) { + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceGroup) { httpapi.ResourceNotFound(rw) return } @@ -181,7 +181,45 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { } func (api *API) groups(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + org = httpmw.OrganizationParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceGroup) { + httpapi.ResourceNotFound(rw) + return + } + + groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + // Filter templates based on rbac permissions + // TODO: authorize filters. + // groups, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, groups) + // if err != nil { + // httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + // Message: "Internal error fetching templates.", + // Detail: err.Error(), + // }) + // return + // } + + resp := make([]codersdk.Group, 0, len(groups)) + for _, group := range groups { + members, err := api.Database.GetGroupMembers(ctx, group.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + resp = append(resp, convertGroup(group, members)) + } + httpapi.Write(rw, http.StatusOK, resp) } func convertGroup(g database.Group, users []database.User) codersdk.Group { diff --git a/coderd/groups_test.go b/coderd/groups_test.go index 1d7fe6d74f443..8d66c602993d6 100644 --- a/coderd/groups_test.go +++ b/coderd/groups_test.go @@ -221,3 +221,56 @@ func TestGroup(t *testing.T) { require.Equal(t, group, ggroup) }) } + +// TODO: test auth. +func TestGroups(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) + _, user4 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user5 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group2, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hey", + }) + require.NoError(t, err) + + group1, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + + group2, err = client.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user4.ID.String(), user5.ID.String()}, + }) + require.NoError(t, err) + + groups, err := client.GroupsByOrganization(ctx, user.OrganizationID) + require.NoError(t, err) + require.Contains(t, groups, group1) + require.Contains(t, groups, group2) + }) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + ctx, _ := testutil.Context(t) + groups, err := client.GroupsByOrganization(ctx, user.OrganizationID) + require.NoError(t, err) + require.Len(t, groups, 0) + }) +} diff --git a/codersdk/groups.go b/codersdk/groups.go index d081c2d9a0c0b..de1cc524b1bb7 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -48,11 +48,12 @@ func (c *Client) GroupsByOrganization(ctx context.Context, orgID uuid.UUID) ([]G } defer res.Body.Close() - if res.StatusCode != http.StatusCreated { + if res.StatusCode != http.StatusOK { return nil, readBodyAsError(res) } - var resp Group - return nil, json.NewDecoder(res.Body).Decode(&resp) + + var groups []Group + return groups, json.NewDecoder(res.Body).Decode(&groups) } func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { From ba1953a52097be08c118d795b2f727d04fa62cd5 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Sep 2022 14:19:34 -0400 Subject: [PATCH 051/138] Add groups to rego objects --- coderd/rbac/authz.go | 18 +++++++++------- coderd/rbac/object.go | 47 ++++++++++++++++++++++++++++------------- coderd/rbac/policy.rego | 2 +- 3 files changed, 43 insertions(+), 24 deletions(-) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index c2d8ead280601..ec1494c298340 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -93,20 +93,21 @@ func NewAuthorizer() (*RegoAuthorizer, error) { } type authSubject struct { - ID string `json:"id"` - Roles []Role `json:"roles"` + ID string `json:"id"` + Roles []Role `json:"roles"` + Groups []string `json:"groups"` } // ByRoleName will expand all roleNames into roles before calling Authorize(). // This is the function intended to be used outside this package. // The role is fetched from the builtin map located in memory. -func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error { +func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, groups []string, scope Scope, action Action, object Object) error { roles, err := RolesByNames(roleNames) if err != nil { return err } - err = a.Authorize(ctx, subjectID, roles, action, object) + err = a.Authorize(ctx, subjectID, roles, groups, action, object) if err != nil { return err } @@ -118,7 +119,7 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa return err } - err = a.Authorize(ctx, subjectID, []Role{scopeRole}, action, object) + err = a.Authorize(ctx, subjectID, []Role{scopeRole}, groups, action, object) if err != nil { return err } @@ -129,14 +130,15 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa // Authorize allows passing in custom Roles. // This is really helpful for unit testing, as we can create custom roles to exercise edge cases. -func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, action Action, object Object) error { +func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, groups []string, action Action, object Object) error { ctx, span := tracing.StartSpan(ctx) defer span.End() input := map[string]interface{}{ "subject": authSubject{ - ID: subjectID, - Roles: roles, + ID: subjectID, + Roles: roles, + Groups: groups, }, "object": object, "action": action, diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 8a3ff250f6d0d..a5ff977211b2e 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -160,8 +160,8 @@ type Object struct { // Type is "workspace", "project", "app", etc Type string `json:"type"` - // map[string][]Action - ACLUserList map[string][]Action ` json:"acl_user_list"` + ACLUserList map[string][]Action ` json:"acl_user_list"` + ACLGroupList map[string][]Action ` json:"acl_group_list"` } func (z Object) RBACObject() Object { @@ -171,36 +171,53 @@ func (z Object) RBACObject() Object { // All returns an object matching all resources of the same type. func (z Object) All() Object { return Object{ - Owner: "", - OrgID: "", - Type: z.Type, + Owner: "", + OrgID: "", + Type: z.Type, + ACLUserList: map[string][]Action{}, + ACLGroupList: map[string][]Action{}, } } // InOrg adds an org OwnerID to the resource func (z Object) InOrg(orgID uuid.UUID) Object { return Object{ - Owner: z.Owner, - OrgID: orgID.String(), - Type: z.Type, + Owner: z.Owner, + OrgID: orgID.String(), + Type: z.Type, + ACLUserList: z.ACLUserList, + ACLGroupList: z.ACLGroupList, } } // WithOwner adds an OwnerID to the resource func (z Object) WithOwner(ownerID string) Object { return Object{ - Owner: ownerID, - OrgID: z.OrgID, - Type: z.Type, + Owner: ownerID, + OrgID: z.OrgID, + Type: z.Type, + ACLUserList: z.ACLUserList, + ACLGroupList: z.ACLGroupList, } } // WithACLUserList adds an ACL list to a given object func (z Object) WithACLUserList(acl map[string][]Action) Object { return Object{ - Owner: z.Owner, - OrgID: z.OrgID, - Type: z.Type, - ACLUserList: acl, + Owner: z.Owner, + OrgID: z.OrgID, + Type: z.Type, + ACLUserList: acl, + ACLGroupList: z.ACLGroupList, + } +} + +func (z Object) WithGroups(groups map[string][]Action) Object { + return Object{ + Owner: z.Owner, + OrgID: z.OrgID, + Type: z.Type, + ACLUserList: z.ACLUserList, + ACLGroupList: groups, } } diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 5eee936bb735b..ee2cb63d998f6 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -176,7 +176,7 @@ allow { input.object.org_owner != "" # Only people in the org can use the team access. org_mem - group := input.subject.groups[input.object.org_owner][_] + group := input.subject.groups[_] perms := input.object.acl_group_list[group] # Either the input action or wildcard [input.action, "*"][_] in perms From 7544e373c301c5b2b43977cf57a3710c242e241c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 22 Sep 2022 14:27:02 -0400 Subject: [PATCH 052/138] fix: Group ACL list fixed --- coderd/authorize.go | 4 ++-- coderd/coderdtest/authorize.go | 10 +++++++--- coderd/httpmw/apikey.go | 2 ++ coderd/rbac/authz.go | 16 ++++++++-------- coderd/rbac/authz_internal_test.go | 11 ++++++----- coderd/rbac/builtin_test.go | 6 ++++-- coderd/rbac/partial.go | 13 +++++++------ coderd/roles.go | 2 +- 8 files changed, 37 insertions(+), 27 deletions(-) diff --git a/coderd/authorize.go b/coderd/authorize.go index 6183092c18e8f..b6c76e833216f 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -12,7 +12,7 @@ import ( func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action rbac.Action, objects []O) ([]O, error) { roles := httpmw.UserAuthorization(r) - objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, objects) + objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, roles.Groups, roles.Scope.ToRBAC(), action, objects) if err != nil { // Log the error as Filter should not be erroring. h.Logger.Error(r.Context(), "filter failed", @@ -57,7 +57,7 @@ func (api *API) Authorize(r *http.Request, action rbac.Action, object rbac.Objec // } func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool { roles := httpmw.UserAuthorization(r) - err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, object.RBACObject()) + err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Groups, roles.Scope.ToRBAC(), action, object.RBACObject()) if err != nil { // Log the errors for debugging internalError := new(rbac.UnauthorizedError) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 8b31b8e8eb2b5..6c0db05717087 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -498,6 +498,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck type authCall struct { SubjectID string Roles []string + Groups []string Scope rbac.Scope Action rbac.Action Object rbac.Object @@ -510,10 +511,11 @@ type RecordingAuthorizer struct { var _ rbac.Authorizer = (*RecordingAuthorizer)(nil) -func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scope rbac.Scope, action rbac.Action, object rbac.Object) error { +func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, groups []string, scope rbac.Scope, action rbac.Action, object rbac.Object) error { r.Called = &authCall{ SubjectID: subjectID, Roles: roleNames, + Groups: groups, Scope: scope, Action: action, Object: object, @@ -521,13 +523,14 @@ func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, ro return r.AlwaysReturn } -func (r *RecordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, scope rbac.Scope, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { +func (r *RecordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, groups []string, scope rbac.Scope, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { return &fakePreparedAuthorizer{ Original: r, SubjectID: subjectID, Roles: roles, Scope: scope, Action: action, + Groups: groups, }, nil } @@ -539,10 +542,11 @@ type fakePreparedAuthorizer struct { Original *RecordingAuthorizer SubjectID string Roles []string + Groups []string Scope rbac.Scope Action rbac.Action } func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error { - return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Scope, f.Action, object) + return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Groups, f.Scope, f.Action, object) } diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 3d11ba98493b1..3ef91d6950159 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -41,6 +41,7 @@ type Authorization struct { ID uuid.UUID Username string Roles []string + Groups []string Scope database.APIKeyScope } @@ -336,6 +337,7 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs, redirectToLogin bool Username: roles.Username, Roles: roles.Roles, Scope: key.Scope, + Groups: roles.Groups, }) next.ServeHTTP(rw, r.WithContext(ctx)) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index ec1494c298340..99566aa421ad4 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -13,8 +13,8 @@ import ( ) type Authorizer interface { - ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error - PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) + ByRoleName(ctx context.Context, subjectID string, roleNames []string, groups []string, scope Scope, action Action, object Object) error + PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, groups []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) } type PreparedAuthorized interface { @@ -25,7 +25,7 @@ type PreparedAuthorized interface { // 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, scope Scope, action Action, objects []O) ([]O, error) { +func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, groups []string, scope Scope, action Action, objects []O) ([]O, error) { ctx, span := tracing.StartSpan(ctx, trace.WithAttributes( attribute.String("subject_id", subjID), attribute.StringSlice("subject_roles", subjRoles), @@ -48,7 +48,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub objectAuth, ok := prepared[object.RBACObject().Type] if !ok { var err error - objectAuth, err = auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, action, objectType) + objectAuth, err = auth.PrepareByRoleName(ctx, subjID, subjRoles, groups, scope, action, objectType) if err != nil { return nil, xerrors.Errorf("prepare: %w", err) } @@ -158,11 +158,11 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [ // Prepare will partially execute the rego policy leaving the object fields unknown (except for the type). // This will vastly speed up performance if batch authorization on the same type of objects is needed. -func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) { +func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, groups []string, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() - auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, action, objectType) + auth, err := newPartialAuthorizer(ctx, subjectID, roles, groups, scope, action, objectType) if err != nil { return nil, xerrors.Errorf("new partial authorizer: %w", err) } @@ -170,7 +170,7 @@ func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Rol return auth, nil } -func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) { +func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, groups []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() @@ -179,5 +179,5 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, return nil, err } - return a.Prepare(ctx, subjectID, roles, scope, action, objectType) + return a.Prepare(ctx, subjectID, roles, groups, scope, action, objectType) } diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index c14be7a065d94..0234f46cee739 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -19,7 +19,8 @@ type subject struct { // For the unit test we want to pass in the roles directly, instead of just // by name. This allows us to test custom roles that do not exist in the product, // but test edge cases of the implementation. - Roles []Role `json:"roles"` + Roles []Role `json:"roles"` + Groups []string `json:"groups"` } type fakeObject struct { @@ -162,7 +163,7 @@ func TestFilter(t *testing.T) { var allowedCount int for i, obj := range localObjects { obj.Type = tc.ObjectType - err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, scope, ActionRead, obj.RBACObject()) + err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, []string{}, scope, ActionRead, obj.RBACObject()) obj.Allowed = err == nil if err == nil { allowedCount++ @@ -171,7 +172,7 @@ func TestFilter(t *testing.T) { } // Run by filter - list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, scope, tc.Action, localObjects) + list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, []string{}, scope, tc.Action, localObjects) require.NoError(t, err) require.Equal(t, allowedCount, len(list), "expected number of allowed") for _, obj := range list { @@ -714,7 +715,7 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, a, c.resource) + authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, subject.Groups, a, c.resource) // Logging only if authError != nil { @@ -739,7 +740,7 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes assert.Error(t, authError, "expected unauthorized") } - partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, ScopeAll, a, c.resource.Type) + partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, subject.Groups, ScopeAll, a, c.resource.Type) require.NoError(t, err, "make prepared authorizer") // Also check the rego policy can form a valid partial query result. diff --git a/coderd/rbac/builtin_test.go b/coderd/rbac/builtin_test.go index 2616466c39e1e..6bb0ad2057db0 100644 --- a/coderd/rbac/builtin_test.go +++ b/coderd/rbac/builtin_test.go @@ -32,6 +32,7 @@ func BenchmarkRBACFilter(b *testing.B) { benchCases := []struct { Name string Roles []string + Groups []string UserID uuid.UUID Scope rbac.Scope }{ @@ -90,7 +91,7 @@ func BenchmarkRBACFilter(b *testing.B) { b.Run(c.Name, func(b *testing.B) { objects := benchmarkSetup(orgs, users, b.N) b.ResetTimer() - allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Scope, rbac.ActionRead, objects) + allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Groups, c.Scope, rbac.ActionRead, objects) require.NoError(b, err) var _ = allowed }) @@ -114,6 +115,7 @@ type authSubject struct { Name string UserID string Roles []string + Groups []string } func TestRolePermissions(t *testing.T) { @@ -359,7 +361,7 @@ func TestRolePermissions(t *testing.T) { delete(remainingSubjs, subj.Name) msg := fmt.Sprintf("%s as %q doing %q on %q", c.Name, subj.Name, action, c.Resource.Type) // TODO: scopey - err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAll, action, c.Resource) + err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, subj.Groups, rbac.ScopeAll, action, c.Resource) if result { assert.NoError(t, err, fmt.Sprintf("Should pass: %s", msg)) } else { diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index 01f4ae756b1fe..de3d6877b444e 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -35,11 +35,11 @@ func (pa *PartialAuthorizer) Authorize(ctx context.Context, object Object) error return nil } -func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) { +func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, groups []string, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() - pAuth, err := newSubPartialAuthorizer(ctx, subjectID, roles, action, objectType) + pAuth, err := newSubPartialAuthorizer(ctx, subjectID, roles, groups, action, objectType) if err != nil { return nil, err } @@ -51,7 +51,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, s return nil, xerrors.Errorf("unknown scope %q", scope) } - scopeAuth, err = newSubPartialAuthorizer(ctx, subjectID, []Role{scopeRole}, action, objectType) + scopeAuth, err = newSubPartialAuthorizer(ctx, subjectID, []Role{scopeRole}, groups, action, objectType) if err != nil { return nil, err } @@ -78,14 +78,15 @@ type subPartialAuthorizer struct { alwaysTrue bool } -func newSubPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*subPartialAuthorizer, error) { +func newSubPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, groups []string, action Action, objectType string) (*subPartialAuthorizer, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() input := map[string]interface{}{ "subject": authSubject{ - ID: subjectID, - Roles: roles, + ID: subjectID, + Roles: roles, + Groups: groups, }, "object": map[string]string{ "type": objectType, diff --git a/coderd/roles.go b/coderd/roles.go index cfac554cde0dc..562ebd610429d 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -70,7 +70,7 @@ func (api *API) checkPermissions(rw http.ResponseWriter, r *http.Request) { if v.Object.OwnerID == "me" { v.Object.OwnerID = roles.ID.String() } - err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, apiKey.Scope.ToRBAC(), rbac.Action(v.Action), + err := api.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Groups, apiKey.Scope.ToRBAC(), rbac.Action(v.Action), rbac.Object{ Owner: v.Object.OwnerID, OrgID: v.Object.OrganizationID, From ff9d968929ab6c7626def86d290bcb84d620dbd0 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 22 Sep 2022 18:34:59 +0000 Subject: [PATCH 053/138] add delete group endpoint --- coderd/database/databasefake/databasefake.go | 16 +++++++++++- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 18 +++++++++++--- coderd/database/queries/groups.sql | 7 ++++++ coderd/groups.go | 18 ++++++++++++++ coderd/groups_test.go | 26 ++++++++++++++++++++ codersdk/groups.go | 16 ++++++++++++ 7 files changed, 97 insertions(+), 5 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 3d5c392219fbb..4517cfcb5e7fc 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2790,7 +2790,7 @@ func (q *fakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]d return users, nil } -func (q *fakeQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]database.Group, error) { +func (q *fakeQuerier) GetGroupsByOrganizationID(_ context.Context, organizationID uuid.UUID) ([]database.Group, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -2803,3 +2803,17 @@ func (q *fakeQuerier) GetGroupsByOrganizationID(ctx context.Context, organizatio return groups, nil } + +func (q *fakeQuerier) DeleteGroupByID(_ context.Context, id uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, group := range q.groups { + if group.ID == id { + q.groups = append(q.groups[:i], q.groups[i+1:]...) + return nil + } + } + + return sql.ErrNoRows +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 7b5c090cdc064..0619d2de9256d 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -21,6 +21,7 @@ type sqlcQuerier interface { AcquireProvisionerJob(ctx context.Context, arg AcquireProvisionerJobParams) (ProvisionerJob, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error + DeleteGroupByID(ctx context.Context, id uuid.UUID) error DeleteGroupMember(ctx context.Context, userID uuid.UUID) error DeleteLicense(ctx context.Context, id int32) (int32, error) DeleteOldAgentStats(ctx context.Context) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 064e0d6acc6ab..4f2c30eb025aa 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -701,6 +701,18 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar return err } +const deleteGroupByID = `-- name: DeleteGroupByID :exec +DELETE FROM + groups +WHERE + id = $1 +` + +func (q *sqlQuerier) DeleteGroupByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteGroupByID, id) + return err +} + const deleteGroupMember = `-- name: DeleteGroupMember :exec DELETE FROM group_members @@ -3215,10 +3227,7 @@ SELECT -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. id, username, status, - -- Roles. The SQL is 2 nested sub queries because the innermost subquery returns a 2 dimensional array - -- of roles. 'unnest' is used to flatten the array into rows, and then 'array_agg' to convert the rows - -- into a 1 dimensional array. Unfortunately 'array_agg(unnest(...))' cannot be called, so we need to - -- do the inner call as a subquery. + -- All user roles, including their org roles. array_cat( -- All users are members array_append(users.rbac_roles, 'member'), @@ -3227,6 +3236,7 @@ SELECT array_agg(org_roles) FROM organization_members, + -- All org_members get the org-member role for their orgs unnest( array_append(roles, 'organization-member:' || organization_members.organization_id::text) ) AS org_roles diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 6960ccbf18ac6..45c452a8c6476 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -84,3 +84,10 @@ DELETE FROM WHERE user_id = $1; +-- name: DeleteGroupByID :exec +DELETE FROM + groups +WHERE + id = $1; + + diff --git a/coderd/groups.go b/coderd/groups.go index f334b5eda0a30..dbee04f2563d8 100644 --- a/coderd/groups.go +++ b/coderd/groups.go @@ -157,7 +157,25 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { } func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + group = httpmw.GroupParam(r) + ) + + if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceGroup) { + httpapi.ResourceNotFound(rw) + return + } + err := api.Database.DeleteGroupByID(ctx, group.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(rw, http.StatusOK, codersdk.Response{ + Message: "Successfully deleted group!", + }) } func (api *API) group(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/groups_test.go b/coderd/groups_test.go index 8d66c602993d6..48e0b8061fd88 100644 --- a/coderd/groups_test.go +++ b/coderd/groups_test.go @@ -274,3 +274,29 @@ func TestGroups(t *testing.T) { require.Len(t, groups, 0) }) } + +func TestDeleteGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, _ := testutil.Context(t) + group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + err = client.DeleteGroup(ctx, group1.ID) + require.NoError(t, err) + + _, err = client.Group(ctx, group1.ID) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + }) +} diff --git a/codersdk/groups.go b/codersdk/groups.go index de1cc524b1bb7..361900446ba5f 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -95,3 +95,19 @@ func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroup var resp Group return resp, json.NewDecoder(res.Body).Decode(&resp) } + +func (c *Client) DeleteGroup(ctx context.Context, group uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, + fmt.Sprintf("/api/v2/groups/%s", group.String()), + nil, + ) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return readBodyAsError(res) + } + return nil +} From ea84bc6625fdd8cda3a053402c2e411efd937efa Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 23 Sep 2022 11:54:23 -0400 Subject: [PATCH 054/138] Fix authorize calls for group endpoints --- coderd/coderdtest/authorize.go | 29 ++++++++++++++++++++++++----- coderd/groups.go | 30 ++++++++++++------------------ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 63c0fdad8c665..6437c53b0f1c5 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -27,6 +27,7 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { workspaceRBACObj := rbac.ResourceWorkspace.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) workspaceExecObj := rbac.ResourceWorkspaceExecution.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) applicationConnectObj := rbac.ResourceWorkspaceApplicationConnect.InOrg(a.Organization.ID).WithOwner(a.Workspace.OwnerID.String()) + groupObj := rbac.ResourceGroup.InOrg(a.Organization.ID) // skipRoutes allows skipping routes from being checked. skipRoutes := map[string]string{ @@ -243,16 +244,29 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "GET:/api/v2/users": {StatusCode: http.StatusOK, AssertObject: rbac.ResourceUser}, "GET:/api/v2/applications/auth-redirect": {AssertAction: rbac.ActionCreate, AssertObject: rbac.ResourceAPIKey}, + "DELETE:/api/v2/groups/{group}": { + AssertAction: rbac.ActionDelete, + AssertObject: groupObj, + }, + "PATCH:/api/v2/groups/{group}": { + AssertAction: rbac.ActionUpdate, + AssertObject: groupObj, + }, + "GET:/api/v2/groups/{group}": { + AssertAction: rbac.ActionRead, + AssertObject: groupObj, + }, + "GET:/api/v2/organizations/{organization}/groups/": { + StatusCode: http.StatusOK, + AssertAction: rbac.ActionRead, + AssertObject: groupObj, + }, + // These endpoints need payloads to get to the auth part. Payloads will be required "PUT:/api/v2/users/{user}/roles": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, "PUT:/api/v2/organizations/{organization}/members/{user}/roles": {NoAuthorize: true}, "POST:/api/v2/workspaces/{workspace}/builds": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - - // TODO: @emyrk @jonayers Fix this unit test by using a valid group - "DELETE:/api/v2/groups/{group}": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - "PATCH:/api/v2/groups/{group}": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, - "GET:/api/v2/groups/{group}": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, } // Routes like proxy routes support all HTTP methods. A helper func to expand @@ -360,6 +374,10 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a ParameterValues: []codersdk.CreateParameterRequest{}, }) require.NoError(t, err, "template version dry-run") + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "testgroup", + }) + require.NoError(t, err, "create group") templateParam, err := client.CreateParameter(ctx, codersdk.ParameterTemplate, template.ID, codersdk.CreateParameterRequest{ Name: "test-param", @@ -385,6 +403,7 @@ func NewAuthTester(ctx context.Context, t *testing.T, client *codersdk.Client, a "{jobID}": templateVersionDryRun.ID.String(), "{templatename}": template.Name, "{workspace_and_agent}": workspace.Name + "." + workspaceResources[0].Agents[0].Name, + "{group}": group.ID.String(), // Only checking template scoped params here "parameters/{scope}/{id}": fmt.Sprintf("parameters/%s/%s", string(templateParam.Scope), templateParam.ScopeID.String()), diff --git a/coderd/groups.go b/coderd/groups.go index 1cfa1e4b292b4..de5427b4d5030 100644 --- a/coderd/groups.go +++ b/coderd/groups.go @@ -56,7 +56,7 @@ func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) - if !api.Authorize(r, rbac.ActionUpdate, rbac.ResourceGroup) { + if !api.Authorize(r, rbac.ActionUpdate, group) { http.NotFound(rw, r) return } @@ -162,7 +162,7 @@ func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) - if !api.Authorize(r, rbac.ActionDelete, rbac.ResourceGroup) { + if !api.Authorize(r, rbac.ActionDelete, group) { httpapi.ResourceNotFound(rw) return } @@ -184,7 +184,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { group = httpmw.GroupParam(r) ) - if !api.Authorize(r, rbac.ActionRead, rbac.ResourceGroup) { + if !api.Authorize(r, rbac.ActionRead, group) { httpapi.ResourceNotFound(rw) return } @@ -204,27 +204,21 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) { org = httpmw.OrganizationParam(r) ) - if !api.Authorize(r, rbac.ActionRead, rbac.ResourceGroup) { - httpapi.ResourceNotFound(rw) - return - } - groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return } - // Filter templates based on rbac permissions - // TODO: authorize filters. - // groups, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, groups) - // if err != nil { - // httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - // Message: "Internal error fetching templates.", - // Detail: err.Error(), - // }) - // return - // } + // Filter groups based on rbac permissions + groups, err = AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, groups) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching groups.", + Detail: err.Error(), + }) + return + } resp := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { From 759bddf303431c4b24c306d20b185c35cb7648aa Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 26 Sep 2022 17:09:58 +0000 Subject: [PATCH 055/138] Fix FE errors --- site/src/AppRouter.tsx | 74 ++++++------ .../TemplateLayout/TemplateLayout.tsx | 8 +- site/src/pages/TemplatePage/TemplatePage.tsx | 112 ------------------ .../TemplatePermissionsPage.tsx} | 2 +- .../TemplatePermissionsPageView.tsx} | 0 5 files changed, 40 insertions(+), 156 deletions(-) delete mode 100644 site/src/pages/TemplatePage/TemplatePage.tsx rename site/src/pages/TemplatePage/{TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx => TemplatePermissionsPage/TemplatePermissionsPage.tsx} (96%) rename site/src/pages/TemplatePage/{TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx => TemplatePermissionsPage/TemplatePermissionsPageView.tsx} (100%) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 9634a7dce8851..6d32ddbf7bf4a 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -2,12 +2,12 @@ import { useSelector } from "@xstate/react" import { FeatureNames } from "api/types" import { FullScreenLoader } from "components/Loader/FullScreenLoader" import { RequirePermission } from "components/RequirePermission/RequirePermission" +import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import IndexPage from "pages" import AuditPage from "pages/AuditPage/AuditPage" import LoginPage from "pages/LoginPage/LoginPage" -import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import { SetupPage } from "pages/SetupPage/SetupPage" -import TemplateCollaboratorsPage from "pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage" +import TemplatePermissionsPage from "pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage" import TemplateSummaryPage from "pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage" import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" import TemplatesPage from "pages/TemplatesPage/TemplatesPage" @@ -93,43 +93,41 @@ export const AppRouter: FC = () => { } /> - - - - - } - > - } /> - } /> - - - - - } - /> - - - - } - /> - - - - } - /> + + + + } + > + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index 0b5ce9e83c775..3ef707c6321e8 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -12,7 +12,6 @@ import { Loader } from "components/Loader/Loader" import { PageHeader, PageHeaderSubtitle, PageHeaderTitle } from "components/PageHeader/PageHeader" import { useOrganizationId } from "hooks/useOrganizationId" import { FC, useContext } from "react" -import { useTranslation } from "react-i18next" import { Link as RouterLink, Navigate, NavLink, Outlet, useParams } from "react-router-dom" import { combineClasses } from "util/combineClasses" import { firstLetter } from "util/firstLetter" @@ -42,7 +41,6 @@ export const TemplateLayout: FC = () => { const styles = useStyles() const organizationId = useOrganizationId() const templateName = useTemplateName() - const { t } = useTranslation("templatePage") const [templateState, templateSend] = useMachine(templateMachine, { context: { templateName, @@ -143,7 +141,7 @@ export const TemplateLayout: FC = () => { Summary combineClasses([styles.tabItem, isActive ? styles.tabItemActive : undefined]) } @@ -161,14 +159,14 @@ export const TemplateLayout: FC = () => { { templateSend("CONFIRM_DELETE") }} onCancel={() => { templateSend("CANCEL_DELETE") }} + entity="template" + name={template.name} /> ) diff --git a/site/src/pages/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatePage/TemplatePage.tsx deleted file mode 100644 index b2e17ee66a50d..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePage.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles" -import { useMachine, useSelector } from "@xstate/react" -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" -import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" -import { Margins } from "components/Margins/Margins" -import { FC, useContext } from "react" -import { Helmet } from "react-helmet-async" -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 styles = useStyles() - const organizationId = useOrganizationId() - const templateName = useTemplateName() - const [templateState, templateSend] = useMachine(templateMachine, { - context: { - templateName, - organizationId, - }, - }) - - const { - template, - activeTemplateVersion, - templateResources, - templateVersions, - deleteTemplateError, - templateDAUs, - getTemplateError, - } = templateState.context - const xServices = useContext(XServiceContext) - const permissions = useSelector(xServices.authXService, selectPermissions) - const isLoading = - !template || !activeTemplateVersion || !templateResources || !permissions || !templateDAUs - - const handleDeleteTemplate = () => { - templateSend("DELETE") - } - - if (templateState.matches("error") && Boolean(getTemplateError)) { - return ( - -
- -
-
- ) - } - - if (isLoading) { - return - } - - if (templateState.matches("deleted")) { - return - } - - return ( - <> - - {pageTitle(`${template.name} · Template`)} - - - - { - templateSend("CONFIRM_DELETE") - }} - onCancel={() => { - templateSend("CANCEL_DELETE") - }} - /> - - ) -} - -const useStyles = makeStyles((theme) => ({ - errorBox: { - padding: theme.spacing(3), - }, -})) - -export default TemplatePage diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx similarity index 96% rename from site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx rename to site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 94fbd73622200..c92b7a4771ad4 100644 --- a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -7,7 +7,7 @@ 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" +import { TemplateCollaboratorsPageView } from "./TemplatePermissionsPageView" export const TemplateCollaboratorsPage: FC> = () => { const { templateContext, permissions } = useOutletContext<{ diff --git a/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx similarity index 100% rename from site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx rename to site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx From 0e2cb2251497270d1e47590acdfe9361d896dea9 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 26 Sep 2022 17:12:33 +0000 Subject: [PATCH 056/138] Fix migration name --- ...{000051_template_acl.down.sql => 000054_template_acl.down.sql} | 0 .../{000051_template_acl.up.sql => 000054_template_acl.up.sql} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename coderd/database/migrations/{000051_template_acl.down.sql => 000054_template_acl.down.sql} (100%) rename coderd/database/migrations/{000051_template_acl.up.sql => 000054_template_acl.up.sql} (100%) diff --git a/coderd/database/migrations/000051_template_acl.down.sql b/coderd/database/migrations/000054_template_acl.down.sql similarity index 100% rename from coderd/database/migrations/000051_template_acl.down.sql rename to coderd/database/migrations/000054_template_acl.down.sql diff --git a/coderd/database/migrations/000051_template_acl.up.sql b/coderd/database/migrations/000054_template_acl.up.sql similarity index 100% rename from coderd/database/migrations/000051_template_acl.up.sql rename to coderd/database/migrations/000054_template_acl.up.sql From 41b79b637a0bc55f88b1f9424799d1433ca95a04 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 26 Sep 2022 13:28:57 -0400 Subject: [PATCH 057/138] Scopes broke ACL. Fixing unit tests. TODO: Fix ACL list --- coderd/rbac/authz_internal_test.go | 66 +++++++++++++++--------------- 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index b45b855b87a49..ecd09f2b71545 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -201,43 +201,44 @@ func TestAuthorizeDomain(t *testing.T) { user := subject{ UserID: "me", + Scope: must(ScopeRole(ScopeAll)), Roles: []Role{ must(RoleByName(RoleMember())), must(RoleByName(RoleOrgMember(defOrg))), }, } - testAuthorize(t, "ACLList", user, []authTestCase{ - { - 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[string][]Action{ - user.UserID: {WildcardSymbol}, - }), - actions: allActions(), - allow: true, - }, - { - 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[string][]Action{ - user.UserID: {ActionUpdate}, - }), - actions: []Action{ActionRead, ActionUpdate}, - allow: true, - }, - }) + //testAuthorize(t, "ACLList", user, []authTestCase{ + // { + // 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[string][]Action{ + // user.UserID: {WildcardSymbol}, + // }), + // actions: allActions(), + // allow: true, + // }, + // { + // 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[string][]Action{ + // user.UserID: {ActionUpdate}, + // }), + // actions: []Action{ActionRead, ActionUpdate}, + // allow: true, + // }, + //}) testAuthorize(t, "Member", user, []authTestCase{ // Org + me @@ -780,9 +781,6 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes for _, cases := range sets { for i, c := range cases { c := c - if c.resource.Type != "application_connect" { - continue - } caseName := fmt.Sprintf("%s/%d", name, i) t.Run(caseName, func(t *testing.T) { t.Parallel() From 7297c3cead95fdcff72c0d7e82feba2a411e8283 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 26 Sep 2022 13:43:03 -0400 Subject: [PATCH 058/138] fix: Fix acl list rego policy --- coderd/rbac/authz.go | 35 ++--------------- coderd/rbac/authz_internal_test.go | 62 +++++++++++++++--------------- coderd/rbac/partial.go | 2 +- coderd/rbac/policy.rego | 23 +++++++---- 4 files changed, 52 insertions(+), 70 deletions(-) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 852114d2330ed..53499124c8bff 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -3,7 +3,6 @@ package rbac import ( "context" _ "embed" - "fmt" "sync" "github.com/open-policy-agent/opa/rego" @@ -92,12 +91,7 @@ func NewAuthorizer() *RegoAuthorizer { queryOnce.Do(func() { var err error query, err = rego.New( - // Bind the results to 2 variables for easy checking later. - rego.Query( - fmt.Sprintf("%s := data.authz.role_allow "+ - "%s := data.authz.scope_allow", - rolesOkCheck, scopeOkCheck), - ), + rego.Query("data.authz.allow"), rego.Module("policy.rego", policy), ).PrepareForEval(context.Background()) if err != nil { @@ -158,31 +152,10 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [ return ForbiddenWithInternal(xerrors.Errorf("eval rego: %w", err), input, results) } - // We expect only the 2 bindings for scopes and roles checks. - if len(results) == 1 && len(results[0].Bindings) == 2 { - roleCheck, ok := results[0].Bindings[rolesOkCheck].(bool) - if !ok || !roleCheck { - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) - } - - scopeCheck, ok := results[0].Bindings[scopeOkCheck].(bool) - if !ok || !scopeCheck { - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) - } - - // This is purely defensive programming. The two above checks already - // check for 'true' expressions. This is just a sanity check to make - // sure we don't add non-boolean expressions to our query. - // This is super cheap to do, and just adds in some extra safety for - // programmer error. - for _, exp := range results[0].Expressions { - if b, ok := exp.Value.(bool); !ok || !b { - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) - } - } - return nil + if !results.Allowed() { + return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) } - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) + return nil } // Prepare will partially execute the rego policy leaving the object fields unknown (except for the type). diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index ecd09f2b71545..54d079d989d6c 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -208,37 +208,37 @@ func TestAuthorizeDomain(t *testing.T) { }, } - //testAuthorize(t, "ACLList", user, []authTestCase{ - // { - // 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[string][]Action{ - // user.UserID: {WildcardSymbol}, - // }), - // actions: allActions(), - // allow: true, - // }, - // { - // 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[string][]Action{ - // user.UserID: {ActionUpdate}, - // }), - // actions: []Action{ActionRead, ActionUpdate}, - // allow: true, - // }, - //}) + testAuthorize(t, "ACLList", user, []authTestCase{ + { + 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[string][]Action{ + user.UserID: {WildcardSymbol}, + }), + actions: allActions(), + allow: true, + }, + { + 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[string][]Action{ + user.UserID: {ActionUpdate}, + }), + actions: []Action{ActionRead, ActionUpdate}, + allow: true, + }, + }) testAuthorize(t, "Member", user, []authTestCase{ // Org + me diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index f73dc72c55e74..e6590623106d5 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -111,7 +111,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, s // Run the rego policy with a few unknown fields. This should simplify our // policy to a set of queries. partialQueries, err := rego.New( - rego.Query("data.authz.role_allow = true data.authz.scope_allow = true"), + rego.Query("data.authz.allow = true"), rego.Module("policy.rego", policy), rego.Unknowns([]string{ "input.object.owner", diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 080fa9b91d479..6dbe0956e1e70 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -2,8 +2,8 @@ package authz import future.keywords # A great playground: https://play.openpolicyagent.org/ # Helpful cli commands to debug. -# opa eval --format=pretty 'data.authz.role_allow data.authz.scope_allow' -d policy.rego -i input.json -# opa eval --partial --format=pretty 'data.authz.role_allow = true data.authz.scope_allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json +# opa eval --format=pretty 'data.authz.allow' -d policy.rego -i input.json +# opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json # # This policy is specifically constructed to compress to a set of queries if the @@ -156,7 +156,6 @@ user_allow(roles) := num { # Allow query: # data.authz.role_allow = true data.authz.scope_allow = true -default role_allow = false role_allow { site = 1 } @@ -175,8 +174,6 @@ role_allow { user = 1 } - -default scope_allow = false scope_allow { scope_site = 1 } @@ -196,7 +193,7 @@ scope_allow { } # ACL for users -allow { +acl_allow { # Should you have to be a member of the org too? perms := input.object.acl_user_list[input.subject.id] # Either the input action or wildcard @@ -204,7 +201,7 @@ allow { } # ACL for groups -allow { +acl_allow { # If there is no organization owner, the object cannot be owned by an # org_scoped team. # TODO: This line and 'org_mem' are similiar and should be combined. @@ -220,3 +217,15 @@ allow { [input.action, "*"][_] in perms } +allow { + role_allow + scope_allow +} + +# ACL list must also have the scope_allow to pass +allow { + acl_allow + scope_allow +} + + From dc65257f47f5f6e74663959bf8494c8490119f28 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 26 Sep 2022 14:02:45 -0400 Subject: [PATCH 059/138] Remove need to be in the org for the group to work in the rego --- coderd/rbac/partial.go | 3 ++- coderd/rbac/policy.rego | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index e6590623106d5..35acf050fa7d3 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -80,7 +80,7 @@ EachQueryLoop: // inspect this any further. But just in case, we will verify each expression // did resolve to 'true'. This is purely defensive programming. for _, exp := range results[0].Expressions { - if exp.String() != "true" { + if v, ok := exp.Value.(bool); !ok || !v { continue EachQueryLoop } } @@ -117,6 +117,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, s "input.object.owner", "input.object.org_owner", "input.object.acl_user_list", + "input.object.acl_group_list", }), rego.Input(input), ).Partial(ctx) diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 6dbe0956e1e70..01b9ed5311b42 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -208,9 +208,9 @@ acl_allow { # Currently the simplfied queries return extra queries that are always # false. If these 2 lines are combined, we reduce the number of queries # returned by partial execution. - input.object.org_owner != "" +# input.object.org_owner != "" # Only people in the org can use the team access. - org_mem +# org_mem group := input.subject.groups[_] perms := input.object.acl_group_list[group] # Either the input action or wildcard From d50a0c599e52f2c9bc0ab5eef8cbdaa083d6283b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 26 Sep 2022 14:10:12 -0400 Subject: [PATCH 060/138] Add group ACL unit test --- coderd/rbac/authz_internal_test.go | 54 +++++++++++++++++++++++------- coderd/rbac/policy.rego | 7 ++-- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 54d079d989d6c..672a743708012 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -198,17 +198,19 @@ func TestAuthorizeDomain(t *testing.T) { t.Parallel() defOrg := uuid.New() unuseID := uuid.New() + allUsersGroup := "all_users" user := subject{ UserID: "me", Scope: must(ScopeRole(ScopeAll)), + Groups: []string{allUsersGroup}, Roles: []Role{ must(RoleByName(RoleMember())), must(RoleByName(RoleOrgMember(defOrg))), }, } - testAuthorize(t, "ACLList", user, []authTestCase{ + testAuthorize(t, "UserACLList", user, []authTestCase{ { resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{ user.UserID: allActions(), @@ -240,6 +242,38 @@ func TestAuthorizeDomain(t *testing.T) { }, }) + testAuthorize(t, "GroupACLList", user, []authTestCase{ + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithGroups(map[string][]Action{ + allUsersGroup: allActions(), + }), + actions: allActions(), + allow: true, + }, + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithGroups(map[string][]Action{ + allUsersGroup: {WildcardSymbol}, + }), + actions: allActions(), + allow: true, + }, + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithGroups(map[string][]Action{ + allUsersGroup: {ActionRead, ActionUpdate}, + }), + actions: []Action{ActionCreate, ActionDelete}, + allow: false, + }, + { + // By default users cannot update templates + resource: ResourceTemplate.InOrg(defOrg).WithGroups(map[string][]Action{ + allUsersGroup: {ActionUpdate}, + }), + actions: []Action{ActionRead, ActionUpdate}, + allow: true, + }, + }) + testAuthorize(t, "Member", user, []authTestCase{ // Org + me {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true}, @@ -790,21 +824,19 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, subject.Scope, subject.Groups, a, c.resource) + d, _ := json.Marshal(map[string]interface{}{ + "subject": subject, + "object": c.resource, + "action": a, + }) + // Logging only + t.Logf("input: %s", string(d)) if authError != nil { var uerr *UnauthorizedError xerrors.As(authError, &uerr) - d, _ := json.Marshal(uerr.Input()) - t.Logf("input: %s", string(d)) t.Logf("internal error: %+v", uerr.Internal().Error()) t.Logf("output: %+v", uerr.Output()) - } else { - d, _ := json.Marshal(map[string]interface{}{ - "subject": subject, - "object": c.resource, - "action": a, - }) - t.Log(string(d)) } if c.allow { @@ -819,8 +851,6 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes // Also check the rego policy can form a valid partial query result. // This ensures we can convert the queries into SQL WHERE clauses in the future. // If this function returns 'Support' sections, then we cannot convert the query into SQL. - d, _ := json.Marshal(partialAuthz.input) - t.Logf("input: %s", string(d)) for _, q := range partialAuthz.partialQueries.Queries { t.Logf("query: %+v", q.String()) } diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 01b9ed5311b42..74b8c0faba595 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -217,6 +217,11 @@ acl_allow { [input.action, "*"][_] in perms } +############### +# Final Allow +# The role or the ACL must allow the action. Scopes can be used to limit, +# so scope_allow must always be true. + allow { role_allow scope_allow @@ -227,5 +232,3 @@ allow { acl_allow scope_allow } - - From 7375484c576d4b66a32f6c58bf7d1cd20b80bb0f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 26 Sep 2022 18:19:33 +0000 Subject: [PATCH 061/138] update uuid -> id --- codersdk/groups.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codersdk/groups.go b/codersdk/groups.go index 361900446ba5f..b4b9759a0295d 100644 --- a/codersdk/groups.go +++ b/codersdk/groups.go @@ -15,7 +15,7 @@ type CreateGroupRequest struct { } type Group struct { - ID uuid.UUID `json:"uuid"` + ID uuid.UUID `json:"id"` Name string `json:"name"` OrganizationID uuid.UUID `json:"organization_id"` Members []User `json:"members"` From d70664dce1c455970aa61abc10ddebe4b0a401c2 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 26 Sep 2022 18:20:55 +0000 Subject: [PATCH 062/138] make gen --- coderd/database/queries.sql.go | 5 +++-- site/src/api/typesGenerated.ts | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 97e6223f7721d..e50c9ea0438d1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -837,7 +837,7 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg const getGroupMembers = `-- name: GetGroupMembers :many SELECT - users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at FROM users JOIN @@ -869,6 +869,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([] &i.LoginType, &i.AvatarURL, &i.Deleted, + &i.LastSeenAt, ); err != nil { return nil, err } @@ -3715,7 +3716,7 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f0f9160543c21..c55a1e8f4a552 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -152,6 +152,11 @@ export interface CreateFirstUserResponse { readonly organization_id: string } +// From codersdk/groups.go +export interface CreateGroupRequest { + readonly name: string +} + // From codersdk/users.go export interface CreateOrganizationRequest { readonly name: string @@ -273,6 +278,14 @@ export interface GitSSHKey { readonly public_key: string } +// From codersdk/groups.go +export interface Group { + readonly id: string + readonly name: string + readonly organization_id: string + readonly members: User[] +} + // From codersdk/workspaceapps.go export interface Healthcheck { readonly url: string @@ -356,6 +369,13 @@ export interface ParameterSchema { readonly validation_contains?: string[] } +// From codersdk/groups.go +export interface PatchGroupRequest { + readonly add_users: string[] + readonly remove_users: string[] + readonly name: string +} + // From codersdk/provisionerdaemons.go export interface ProvisionerDaemon { readonly id: string From 3dac95a31530d545b03724639a6bd71534172182 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 26 Sep 2022 18:22:32 +0000 Subject: [PATCH 063/138] Add index page for groups --- coderd/database/queries.sql.go | 5 +- site/src/AppRouter.tsx | 12 ++ site/src/api/api.ts | 5 + site/src/api/typesGenerated.ts | 20 +++ site/src/components/NavbarView/NavbarView.tsx | 5 + site/src/pages/GroupsPage/GroupsPage.tsx | 146 ++++++++++++++++++ .../TemplatePermissionsPage.tsx | 6 +- .../TemplatePermissionsPageView.tsx | 6 +- site/src/testHelpers/entities.ts | 7 + site/src/testHelpers/handlers.ts | 6 + site/src/xServices/groups/groupsXService.tsx | 54 +++++++ 11 files changed, 264 insertions(+), 8 deletions(-) create mode 100644 site/src/pages/GroupsPage/GroupsPage.tsx create mode 100644 site/src/xServices/groups/groupsXService.tsx diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 97e6223f7721d..e50c9ea0438d1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -837,7 +837,7 @@ func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrg const getGroupMembers = `-- name: GetGroupMembers :many SELECT - users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at FROM users JOIN @@ -869,6 +869,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([] &i.LoginType, &i.AvatarURL, &i.Deleted, + &i.LastSeenAt, ); err != nil { return nil, err } @@ -3715,7 +3716,7 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 6d32ddbf7bf4a..9812fab4de059 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -5,6 +5,7 @@ import { RequirePermission } from "components/RequirePermission/RequirePermissio import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import IndexPage from "pages" import AuditPage from "pages/AuditPage/AuditPage" +import GroupsPage from "pages/GroupsPage/GroupsPage" import LoginPage from "pages/LoginPage/LoginPage" import { SetupPage } from "pages/SetupPage/SetupPage" import TemplatePermissionsPage from "pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage" @@ -149,6 +150,17 @@ export const AppRouter: FC = () => { />
+ + + + + } + /> + + => { + const response = await axios.get(`/api/v2/organizations/${organizationId}/groups`) + return response.data +} diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f0f9160543c21..1bf3ace1e5fd7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -152,6 +152,11 @@ export interface CreateFirstUserResponse { readonly organization_id: string } +// From codersdk/groups.go +export interface CreateGroupRequest { + readonly name: string +} + // From codersdk/users.go export interface CreateOrganizationRequest { readonly name: string @@ -273,6 +278,14 @@ export interface GitSSHKey { readonly public_key: string } +// From codersdk/groups.go +export interface Group { + readonly uuid: string + readonly name: string + readonly organization_id: string + readonly members: User[] +} + // From codersdk/workspaceapps.go export interface Healthcheck { readonly url: string @@ -356,6 +369,13 @@ export interface ParameterSchema { readonly validation_contains?: string[] } +// From codersdk/groups.go +export interface PatchGroupRequest { + readonly add_users: string[] + readonly remove_users: string[] + readonly name: string +} + // From codersdk/provisionerdaemons.go export interface ProvisionerDaemon { readonly id: string diff --git a/site/src/components/NavbarView/NavbarView.tsx b/site/src/components/NavbarView/NavbarView.tsx index 12eab8ca37c6c..3f2fed0758b76 100644 --- a/site/src/components/NavbarView/NavbarView.tsx +++ b/site/src/components/NavbarView/NavbarView.tsx @@ -55,6 +55,11 @@ const NavItems: React.FC< {Language.users} + + + Groups + + {canViewAuditLog && ( diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx new file mode 100644 index 0000000000000..5cb03938c78f8 --- /dev/null +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -0,0 +1,146 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +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 AddCircleOutline from "@material-ui/icons/AddCircleOutline" +import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import { useMachine } from "@xstate/react" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { EmptyState } from "components/EmptyState/EmptyState" +import { Margins } from "components/Margins/Margins" +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" +import { TableCellLink } from "components/TableCellLink/TableCellLink" +import { TableLoader } from "components/TableLoader/TableLoader" +import { useOrganizationId } from "hooks/useOrganizationId" +import React from "react" +import { Helmet } from "react-helmet-async" +import { Link as RouterLink, useNavigate } from "react-router-dom" +import { pageTitle } from "util/page" +import { groupsMachine } from "xServices/groups/groupsXService" + +const CreateGroupButton: React.FC = () => { + return ( + + + + ) +} + +export const GroupsPage: React.FC = () => { + const organizationId = useOrganizationId() + const [state] = useMachine(groupsMachine, { + context: { + organizationId, + }, + }) + const { groups } = state.context + const isLoading = Boolean(groups === undefined) + const isEmpty = Boolean(groups && groups.length === 0) + const navigate = useNavigate() + const styles = useStyles() + + return ( + <> + + {pageTitle("Groups")} + + + }> + Groups + + +
+ + + Name + Users + + + + + + + + + + + + + } + /> + + + + + + {groups?.map((group) => { + const groupPageLink = `/groups/${group.uuid}` + + return ( + { + if (event.key === "Enter") { + navigate(groupPageLink) + } + }} + className={styles.clickableTableRow} + > + {group.name} + + Users + + +
+ +
+
+
+ ) + })} +
+
+
+
+
+ + + ) +} + +const useStyles = makeStyles((theme) => ({ + clickableTableRow: { + "&:hover td": { + backgroundColor: theme.palette.action.hover, + }, + + "&:focus": { + outline: `1px solid ${theme.palette.secondary.dark}`, + }, + + "& .MuiTableCell-root:last-child": { + paddingRight: theme.spacing(2), + }, + }, + arrowRight: { + color: theme.palette.text.secondary, + width: 20, + height: 20, + }, + arrowCell: { + display: "flex", + }, +})) + +export default GroupsPage diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index c92b7a4771ad4..6fbd2818de016 100644 --- a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -7,7 +7,7 @@ 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 "./TemplatePermissionsPageView" +import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView" export const TemplateCollaboratorsPage: FC> = () => { const { templateContext, permissions } = useOutletContext<{ @@ -32,9 +32,9 @@ export const TemplateCollaboratorsPage: FC> = ( return ( <> - {pageTitle(`${template.name} · Collaborators`)} + {pageTitle(`${template.name} · Permissions`)} - void) => void @@ -157,8 +157,8 @@ export interface TemplateCollaboratorsPageViewProps { onRemoveUser: (user: User) => void } -export const TemplateCollaboratorsPageView: FC< - React.PropsWithChildren +export const TemplatePermissionsPageView: FC< + React.PropsWithChildren > = ({ deleteTemplateError, templateUsers, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 8e16853e9f325..26c91d4c17971 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -845,3 +845,10 @@ export const MockAuditLog2: TypesGen.AuditLog = { }, }, } + +export const MockGroup: TypesGen.Group = { + name: "Coder Group", + uuid: "53bded77-7b9d-4e82-8771-991a34d75930", + organization_id: MockOrganization.id, + members: [], +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index eaeef28c3a902..4783040ab749f 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -3,6 +3,7 @@ import { WorkspaceBuildTransition } from "../api/types" import { CreateWorkspaceBuildRequest } from "../api/typesGenerated" import { permissionsToCheck } from "../xServices/auth/authXService" import * as M from "./entities" +import { MockGroup } from "./entities" export const handlers = [ rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => { @@ -173,4 +174,9 @@ export const handlers = [ rest.get("/api/v2/applications/host", (req, res, ctx) => { return res(ctx.status(200), ctx.json({ host: "dev.coder.com" })) }), + + // Groups + rest.get("/api/v2/organizations/:organizationId/groups", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([MockGroup])) + }), ] diff --git a/site/src/xServices/groups/groupsXService.tsx b/site/src/xServices/groups/groupsXService.tsx new file mode 100644 index 0000000000000..45224713c560d --- /dev/null +++ b/site/src/xServices/groups/groupsXService.tsx @@ -0,0 +1,54 @@ +import { getGroups } from "api/api" +import { getErrorMessage } from "api/errors" +import { Group } from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" +import { assign, createMachine } from "xstate" + +export const groupsMachine = createMachine( + { + id: "groupsMachine", + schema: { + context: {} as { + organizationId: string + groups?: Group[] + }, + services: {} as { + loadGroups: { + data: Group[] + } + }, + }, + tsTypes: {} as import("./groupsXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + invoke: { + src: "loadGroups", + onDone: { + actions: ["assignGroups"], + target: "idle", + }, + onError: { + target: "idle", + actions: ["displayLoadingGroupsError"], + }, + }, + }, + idle: {}, + }, + }, + { + services: { + loadGroups: ({ organizationId }) => getGroups(organizationId), + }, + actions: { + assignGroups: assign({ + groups: (_, { data }) => data, + }), + displayLoadingGroupsError: (_, { data }) => { + const message = getErrorMessage(data, "Error on loading groups.") + displayError(message) + }, + }, + }, +) From 2c4fd8d745dc092ce068d67c6e3b133baf626c69 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 26 Sep 2022 19:10:29 +0000 Subject: [PATCH 064/138] Add create group page --- site/src/AppRouter.tsx | 9 +++ site/src/api/api.ts | 8 ++ site/src/pages/GroupsPage/CreateGroupPage.tsx | 74 +++++++++++++++++ site/src/pages/GroupsPage/GroupsPage.tsx | 2 +- .../xServices/groups/createGroupXService.tsx | 79 +++++++++++++++++++ 5 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 site/src/pages/GroupsPage/CreateGroupPage.tsx create mode 100644 site/src/xServices/groups/createGroupXService.tsx diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 9812fab4de059..ae2dd91e04ff6 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -5,6 +5,7 @@ import { RequirePermission } from "components/RequirePermission/RequirePermissio import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import IndexPage from "pages" import AuditPage from "pages/AuditPage/AuditPage" +import CreateGroupPage from "pages/GroupsPage/CreateGroupPage" import GroupsPage from "pages/GroupsPage/GroupsPage" import LoginPage from "pages/LoginPage/LoginPage" import { SetupPage } from "pages/SetupPage/SetupPage" @@ -159,6 +160,14 @@ export const AppRouter: FC = () => { } /> + + + + } + /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c9399321fbf26..e1a42389383bd 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -512,3 +512,11 @@ export const getGroups = async (organizationId: string): Promise => { + const response = await axios.post(`/api/v2/organizations/${organizationId}/groups`, data) + return response.data +} diff --git a/site/src/pages/GroupsPage/CreateGroupPage.tsx b/site/src/pages/GroupsPage/CreateGroupPage.tsx new file mode 100644 index 0000000000000..c6c66fec11f81 --- /dev/null +++ b/site/src/pages/GroupsPage/CreateGroupPage.tsx @@ -0,0 +1,74 @@ +import TextField from "@material-ui/core/TextField" +import { useMachine } from "@xstate/react" +import { CreateGroupRequest } from "api/typesGenerated" +import { FormFooter } from "components/FormFooter/FormFooter" +import { FullPageForm } from "components/FullPageForm/FullPageForm" +import { Margins } from "components/Margins/Margins" +import { useFormik } from "formik" +import { useOrganizationId } from "hooks/useOrganizationId" +import React from "react" +import { Helmet } from "react-helmet-async" +import { useNavigate } from "react-router-dom" +import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils" +import { pageTitle } from "util/page" +import { createGroupMachine } from "xServices/groups/createGroupXService" +import * as Yup from "yup" + +const validationSchema = Yup.object({ + name: nameValidator("Name"), +}) + +export const CreateGroupPage: React.FC = () => { + const navigate = useNavigate() + const organizationId = useOrganizationId() + const [createState, sendCreateEvent] = useMachine(createGroupMachine, { + context: { + organizationId, + }, + actions: { + onCreate: (_, { data }) => { + navigate(`/groups/${data.id}`) + }, + }, + }) + const { createGroupFormErrors } = createState.context + const form = useFormik({ + initialValues: { + name: "", + }, + validationSchema, + onSubmit: (data) => { + sendCreateEvent({ + type: "CREATE", + data, + }) + }, + }) + const getFieldHelpers = getFormHelpers(form, createGroupFormErrors) + const onCancel = () => navigate("/groups") + + return ( + <> + + {pageTitle("Create Group")} + + + +
+ + + +
+
+ + ) +} +export default CreateGroupPage diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 06a1f43fc6c08..6277af33cfaec 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -25,7 +25,7 @@ import { groupsMachine } from "xServices/groups/groupsXService" const CreateGroupButton: React.FC = () => { return ( - + ) diff --git a/site/src/xServices/groups/createGroupXService.tsx b/site/src/xServices/groups/createGroupXService.tsx new file mode 100644 index 0000000000000..0ef8d3767de28 --- /dev/null +++ b/site/src/xServices/groups/createGroupXService.tsx @@ -0,0 +1,79 @@ +import { createGroup } from "api/api" +import { + ApiError, + getErrorMessage, + hasApiFieldErrors, + isApiError, + mapApiErrorToFieldErrors, +} from "api/errors" +import { CreateGroupRequest, Group } from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" +import { createMachine } from "xstate" + +export const createGroupMachine = createMachine( + { + id: "createGroupMachine", + schema: { + context: {} as { + organizationId: string + createGroupFormErrors?: unknown + }, + services: {} as { + createGroup: { + data: Group + } + }, + events: {} as { + type: "CREATE" + data: CreateGroupRequest + }, + }, + tsTypes: {} as import("./createGroupXService.typegen").Typegen0, + initial: "idle", + states: { + idle: { + on: { + CREATE: { + target: "creatingGroup", + }, + }, + }, + creatingGroup: { + invoke: { + src: "createGroup", + onDone: { + target: "idle", + actions: ["onCreate"], + }, + onError: [ + { + target: "idle", + cond: "hasFieldErrors", + actions: ["assignCreateGroupFormErrors"], + }, + { + target: "idle", + actions: ["displayCreateGroupError"], + }, + ], + }, + }, + }, + }, + { + guards: { + hasFieldErrors: (_, event) => isApiError(event.data) && hasApiFieldErrors(event.data), + }, + services: { + createGroup: ({ organizationId }, { data }) => createGroup(organizationId, data), + }, + actions: { + displayCreateGroupError: (_, { data }) => { + const message = getErrorMessage(data, "Error on creating the group.") + displayError(message) + }, + assignCreateGroupFormErrors: (_, event) => + mapApiErrorToFieldErrors((event.data as ApiError).response.data), + }, + }, +) From cb1464f72f814540d6d5d5769e04b93e06e46799 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 26 Sep 2022 14:15:58 -0400 Subject: [PATCH 065/138] Remove filter's ability to filter multiple object types --- coderd/rbac/authz.go | 31 ++++++++++++------------------ coderd/rbac/authz_internal_test.go | 16 +++++++-------- 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 53499124c8bff..cce18b496e6ad 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -23,9 +23,8 @@ 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. This function slows -// down if the list contains objects of multiple types. Attempt to only -// filter objects of the same type for faster performance. +// the elements the subject does not have permission for. All objects must be +// of the same type. func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, groups []string, action Action, objects []O) ([]O, error) { ctx, span := tracing.StartSpan(ctx, trace.WithAttributes( attribute.String("subject_id", subjID), @@ -38,26 +37,20 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub // Nothing to filter return objects, nil } + objectType := objects[0].RBACObject().Type filtered := make([]O, 0) - prepared := make(map[string]PreparedAuthorized) - - for i := range objects { - object := objects[i] - 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, scope, groups, action, objectType) - if err != nil { - return nil, xerrors.Errorf("prepare: %w", err) - } - prepared[objectType] = objectAuth - } + prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, groups, action, objectType) + if err != nil { + return nil, xerrors.Errorf("prepare: %w", err) + } + for _, object := range objects { rbacObj := object.RBACObject() - err := objectAuth.Authorize(ctx, rbacObj) + if rbacObj.Type != objectType { + return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, object.RBACObject().Type) + } + err := prepared.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 672a743708012..36eb064711e95 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -40,15 +40,15 @@ func (w fakeObject) RBACObject() Object { } // TODO: @emyrk Bring back this test when private/public templates are removed -// in favor of groups. -//func TestFilterError(t *testing.T) { -// t.Parallel() -// auth, err := NewAuthorizer() -// require.NoError(t, err) // -// _, err = Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace}) -// require.ErrorContains(t, err, "object types must be uniform") -//} +// in favor of groups. +func TestFilterError(t *testing.T) { + t.Parallel() + auth := NewAuthorizer() + + _, err := Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, []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 From afe328bafbdacd393a670db31fdb206076b0283f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 26 Sep 2022 19:21:53 +0000 Subject: [PATCH 066/138] groups changes --- coderd/database/databasefake/databasefake.go | 2 -- coderd/database/dump.sql | 2 +- .../migrations/000054_template_acl.up.sql | 2 +- coderd/database/modelmethods.go | 8 +++-- coderd/database/models.go | 2 +- coderd/database/queries.sql.go | 34 ++++++++----------- coderd/database/queries/templates.sql | 8 ++--- coderd/database/sqlc.yaml | 1 + coderd/rbac/builtin.go | 3 +- coderd/rbac/object.go | 4 --- coderd/templates.go | 10 +----- 11 files changed, 28 insertions(+), 48 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 340f739ec3b23..084575d85326a 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1001,7 +1001,6 @@ 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 } @@ -1766,7 +1765,6 @@ 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 02bb5955ee556..a74c29bd71eef 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -313,7 +313,7 @@ CREATE TABLE templates ( created_by uuid NOT NULL, icon character varying(256) DEFAULT ''::character varying NOT NULL, user_acl jsonb DEFAULT '{}'::jsonb NOT NULL, - is_private boolean DEFAULT false NOT NULL + group_acl jsonb DEFAULT '{}'::jsonb NOT NULL ); CREATE TABLE user_links ( diff --git a/coderd/database/migrations/000054_template_acl.up.sql b/coderd/database/migrations/000054_template_acl.up.sql index bfd555e11e5b0..8cb2868f0c4ad 100644 --- a/coderd/database/migrations/000054_template_acl.up.sql +++ b/coderd/database/migrations/000054_template_acl.up.sql @@ -1,7 +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'; +ALTER TABLE templates ADD COLUMN group_acl jsonb NOT NULL default '{}'; CREATE TYPE template_role AS ENUM ( 'read', diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index 3e574ffd69712..d65099968430f 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -10,6 +10,9 @@ import ( // UserACL is a map of user_ids to permissions. type UserACL map[string]TemplateRole +// Group is a map of user_ids to permissions. +type GroupACL map[string]TemplateRole + func (u UserACL) Actions() map[string][]rbac.Action { aclRBAC := make(map[string][]rbac.Action, len(u)) for k, v := range u { @@ -33,6 +36,8 @@ func (t Template) UserACL() UserACL { return acl } +func (t Template) GroupACL() Gr + func (t Template) SetUserACL(acl UserACL) Template { raw, err := json.Marshal(acl) if err != nil { @@ -69,9 +74,6 @@ func (s APIKeyScope) ToRBAC() rbac.Scope { func (t Template) RBACObject() rbac.Object { obj := rbac.ResourceTemplate - if t.IsPrivate { - obj = rbac.ResourceTemplatePrivate - } return obj.InOrg(t.OrganizationID).WithACLUserList(t.UserACL().Actions()) } diff --git a/coderd/database/models.go b/coderd/database/models.go index 83eca385338f6..577c582ee2901 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -556,7 +556,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"` + GroupAcl json.RawMessage `db:"group_acl" json:"group_acl"` } type TemplateVersion struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e50c9ea0438d1..d2db8287db272 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2437,7 +2437,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, is_private + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl FROM templates WHERE @@ -2464,14 +2464,14 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.CreatedBy, &i.Icon, &i.userACL, - &i.IsPrivate, + &i.GroupAcl, ) 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, is_private + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl FROM templates WHERE @@ -2506,13 +2506,13 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.CreatedBy, &i.Icon, &i.userACL, - &i.IsPrivate, + &i.GroupAcl, ) 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, is_private 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, group_acl FROM templates ORDER BY (name, id) ASC ` @@ -2540,7 +2540,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.CreatedBy, &i.Icon, &i.userACL, - &i.IsPrivate, + &i.GroupAcl, ); err != nil { return nil, err } @@ -2557,7 +2557,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, is_private + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl FROM templates WHERE @@ -2620,7 +2620,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.CreatedBy, &i.Icon, &i.userACL, - &i.IsPrivate, + &i.GroupAcl, ); err != nil { return nil, err } @@ -2649,11 +2649,10 @@ INSERT INTO max_ttl, min_autostart_interval, created_by, - icon, - is_private + icon ) 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, is_private + ($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, user_acl, group_acl ` type InsertTemplateParams struct { @@ -2669,7 +2668,6 @@ 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"` - IsPrivate bool `db:"is_private" json:"is_private"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { @@ -2686,7 +2684,6 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.MinAutostartInterval, arg.CreatedBy, arg.Icon, - arg.IsPrivate, ) var i Template err := row.Scan( @@ -2704,7 +2701,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.CreatedBy, &i.Icon, &i.userACL, - &i.IsPrivate, + &i.GroupAcl, ) return i, err } @@ -2760,12 +2757,11 @@ SET max_ttl = $4, min_autostart_interval = $5, name = $6, - icon = $7, - is_private = $8 + icon = $7 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, is_private + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl ` type UpdateTemplateMetaByIDParams struct { @@ -2776,7 +2772,6 @@ 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) { @@ -2788,7 +2783,6 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.MinAutostartInterval, arg.Name, arg.Icon, - arg.IsPrivate, ) var i Template err := row.Scan( @@ -2806,7 +2800,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.CreatedBy, &i.Icon, &i.userACL, - &i.IsPrivate, + &i.GroupAcl, ) return i, err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 720f5f49890df..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, - is_private + 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 @@ -101,8 +100,7 @@ SET max_ttl = $4, min_autostart_interval = $5, name = $6, - icon = $7, - is_private = $8 + icon = $7 WHERE id = $1 RETURNING diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index ed60336346049..148a3bbc05a8a 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -40,3 +40,4 @@ rename: ids: IDs jwt: JWT user_acl: userACL + group_acl: userACL diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 1cc2fbb93dd97..2a4fb3fd2c670 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -108,8 +108,7 @@ var ( Name: templateAdmin, DisplayName: "Template Admin", Site: permissions(map[string][]Action{ - ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceTemplatePrivate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceTemplate.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}, diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index a5ff977211b2e..e8084dbc884e7 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -62,10 +62,6 @@ var ( Type: "group", } - ResourceTemplatePrivate = Object{ - Type: "template_private", - } - ResourceFile = Object{ Type: "file", } diff --git a/coderd/templates.go b/coderd/templates.go index f3f9d0b76790e..b9cf31d9e9054 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -262,7 +262,6 @@ 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) @@ -533,8 +532,7 @@ 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 && - (req.IsPrivate == nil || req.IsPrivate != nil && *req.IsPrivate == template.IsPrivate) { + len(req.UserPerms) == 0 { return nil } @@ -544,7 +542,6 @@ 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 @@ -555,9 +552,6 @@ 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() @@ -585,7 +579,6 @@ 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 @@ -876,7 +869,6 @@ func (api *API) convertTemplate( CreatedByID: template.CreatedBy, CreatedByName: createdByName, UserRoles: convertTemplateACL(template.UserACL()), - IsPrivate: template.IsPrivate, } } From e0ea8ece3a681677bcf14058f045c5933ea382fe Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 26 Sep 2022 19:26:08 +0000 Subject: [PATCH 067/138] Add user auto complete component --- .../UserAutocomplete/UserAutocomplete.tsx | 105 ++++++++++++++++++ .../TemplatePermissionsPageView.tsx | 82 +------------- 2 files changed, 109 insertions(+), 78 deletions(-) create mode 100644 site/src/components/UserAutocomplete/UserAutocomplete.tsx diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx new file mode 100644 index 0000000000000..bc714c72abff5 --- /dev/null +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -0,0 +1,105 @@ +import CircularProgress from "@material-ui/core/CircularProgress" +import { makeStyles } from "@material-ui/core/styles" +import TextField from "@material-ui/core/TextField" +import Autocomplete from "@material-ui/lab/Autocomplete" +import { useMachine } from "@xstate/react" +import { User } from "api/typesGenerated" +import { AvatarData } from "components/AvatarData/AvatarData" +import debounce from "just-debounce-it" +import { ChangeEvent, useState } from "react" +import { searchUserMachine } from "xServices/users/searchUserXService" + +export type UserAutocompleteProps = { + value: User | null + onChange: (user: User | null) => void +} + +export const UserAutocomplete: React.FC = ({ value, onChange }) => { + const styles = useStyles() + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) + const [searchState, sendSearch] = useMachine(searchUserMachine) + const { searchResults } = searchState.context + + const handleFilterChange = debounce((event: ChangeEvent) => { + sendSearch("SEARCH", { query: event.target.value }) + }, 1000) + + return ( + { + setIsAutocompleteOpen(true) + }} + onClose={() => { + setIsAutocompleteOpen(false) + }} + onChange={(event, newValue) => { + onChange(newValue) + }} + 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) => ( + + {searchState.matches("searching") ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ) +} +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`, + }, + }, + + avatar: { + width: theme.spacing(4.5), + height: theme.spacing(4.5), + borderRadius: "100%", + }, + } +}) diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index 3ab0b138b8ad0..f79cb83bf109e 100644 --- a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -1,4 +1,3 @@ -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" @@ -8,10 +7,7 @@ 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 { useMachine } from "@xstate/react" import { TemplateRole, TemplateUser, User } from "api/typesGenerated" import { AvatarData } from "components/AvatarData/AvatarData" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" @@ -21,25 +17,17 @@ 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" +import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete" +import { FC, useState } from "react" 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 [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") @@ -56,60 +44,11 @@ const AddTemplateUser: React.FC<{ }} > - { - setIsAutocompleteOpen(true) - }} - onClose={() => { - setIsAutocompleteOpen(false) - }} - onChange={(event, newValue) => { + onChange={(newValue) => { setSelectedUser(newValue) }} - 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) => ( - - {searchState.matches("searching") ? : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} /> - - Read - - - Write + + View Admin @@ -72,13 +82,13 @@ const AddTemplateUser: React.FC<{ } loading={isLoading} > - Add user + Add member @@ -86,63 +96,129 @@ const AddTemplateUser: React.FC<{ } export interface TemplatePermissionsPageViewProps { - deleteTemplateError: Error | unknown - templateUsers: TemplateUser[] | undefined - onAddUser: (user: User, role: TemplateRole, reset: () => void) => void + templateACL: TemplateACL | undefined + // User + onAddUser: (user: TemplateUser, role: TemplateRole, reset: () => void) => void isAddingUser: boolean canUpdateUsers: boolean - onUpdateUser: (user: User, role: TemplateRole) => void + onUpdateUser: (user: TemplateUser, role: TemplateRole) => void updatingUser: TemplateUser | undefined - onRemoveUser: (user: User) => void + onRemoveUser: (user: TemplateUser) => void + // Group + onAddGroup: (group: TemplateGroup, role: TemplateRole, reset: () => void) => void + isAddingGroup: boolean + onUpdateGroup: (group: TemplateGroup, role: TemplateRole) => void + updatingGroup: TemplateGroup | undefined + onRemoveGroup: (group: Group) => void } export const TemplatePermissionsPageView: FC< React.PropsWithChildren > = ({ - deleteTemplateError, - templateUsers, + templateACL, + canUpdateUsers, + // User onAddUser, isAddingUser, updatingUser, onUpdateUser, - canUpdateUsers, onRemoveUser, + // Group + onAddGroup, + isAddingGroup, + updatingGroup, + onUpdateGroup, + onRemoveGroup, }) => { const styles = useStyles() - const deleteError = deleteTemplateError ? ( - - ) : null + const isEmpty = Boolean( + templateACL && templateACL.users.length === 0 && templateACL.group.length === 0, + ) return ( - {deleteError} - + + "members" in value + ? onAddGroup(value, role, resetAutocomplete) + : onAddUser(value, role, resetAutocomplete) + } + /> - User + Member Role - + - + - 0)}> - {templateUsers?.map((user) => ( + + {templateACL?.group.map((group) => ( + + + + + + {canUpdateUsers ? ( + + ) : ( + group.role + )} + + + {canUpdateUsers && ( + + onRemoveGroup(group), + }, + ]} + /> + + )} + + ))} + + {templateACL?.users.map((user) => ( - - Read - - - Write + + View Admin diff --git a/site/src/xServices/template/searchUsersAndGroupsXService.ts b/site/src/xServices/template/searchUsersAndGroupsXService.ts new file mode 100644 index 0000000000000..34b4977cd6824 --- /dev/null +++ b/site/src/xServices/template/searchUsersAndGroupsXService.ts @@ -0,0 +1,74 @@ +import { getGroups, getUsers } from "api/api" +import { Group, User } from "api/typesGenerated" +import { queryToFilter } from "util/filters" +import { assign, createMachine } from "xstate" + +export type SearchUsersAndGroupsEvent = + | { type: "SEARCH"; query: string } + | { type: "CLEAR_RESULTS" } + +export const searchUsersAndGroupsMachine = createMachine( + { + id: "searchUsersAndGroups", + schema: { + context: {} as { + organizationId: string + userResults: User[] + groupResults: Group[] + }, + events: {} as SearchUsersAndGroupsEvent, + services: {} as { + search: { + data: { + users: User[] + groups: Group[] + } + } + }, + }, + tsTypes: {} as import("./searchUsersAndGroupsXService.typegen").Typegen0, + initial: "idle", + states: { + idle: { + on: { + SEARCH: "searching", + CLEAR_RESULTS: { + actions: ["clearResults"], + target: "idle", + }, + }, + }, + searching: { + invoke: { + src: "search", + onDone: { + target: "idle", + actions: ["assignSearchResults"], + }, + }, + }, + }, + }, + { + services: { + search: async ({ organizationId }, { query }) => { + const [users, groups] = await Promise.all([ + getUsers(queryToFilter(query)), + getGroups(organizationId), + ]) + + return { users, groups } + }, + }, + actions: { + assignSearchResults: assign({ + userResults: (_, { data }) => data.users, + groupResults: (_, { data }) => data.groups, + }), + clearResults: assign({ + userResults: (_) => [], + groupResults: (_) => [], + }), + }, + }, +) diff --git a/site/src/xServices/template/templateACLXService.ts b/site/src/xServices/template/templateACLXService.ts new file mode 100644 index 0000000000000..8af975cb77fa1 --- /dev/null +++ b/site/src/xServices/template/templateACLXService.ts @@ -0,0 +1,344 @@ +import { getTemplateACL, updateTemplateACL } from "api/api" +import { TemplateACL, TemplateGroup, TemplateRole, TemplateUser } from "api/typesGenerated" +import { displaySuccess } from "components/GlobalSnackbar/utils" +import { assign, createMachine } from "xstate" + +export const templateACLMachine = createMachine( + { + schema: { + context: {} as { + templateId: string + templateACL?: TemplateACL + // User + userToBeAdded?: TemplateUser + userToBeUpdated?: TemplateUser + addUserCallback?: () => void + // Group + groupToBeAdded?: TemplateGroup + groupToBeUpdated?: TemplateGroup + addGroupCallback?: () => void + }, + services: {} as { + loadTemplateACL: { + data: TemplateACL + } + // User + addUser: { + data: unknown + } + updateUser: { + data: unknown + } + // Group + addGroup: { + data: unknown + } + updateGroup: { + data: unknown + } + }, + events: {} as // User + | { + type: "ADD_USER" + user: TemplateUser + role: TemplateRole + onDone: () => void + } + | { + type: "UPDATE_USER_ROLE" + user: TemplateUser + role: TemplateRole + } + | { + type: "REMOVE_USER" + user: TemplateUser + } + // Group + | { + type: "ADD_GROUP" + group: TemplateGroup + role: TemplateRole + onDone: () => void + } + | { + type: "UPDATE_GROUP_ROLE" + group: TemplateGroup + role: TemplateRole + } + | { + type: "REMOVE_GROUP" + group: TemplateGroup + }, + }, + tsTypes: {} as import("./templateACLXService.typegen").Typegen0, + id: "templateUserRoles", + initial: "loading", + states: { + loading: { + invoke: { + src: "loadTemplateACL", + onDone: { + actions: ["assignTemplateACL"], + target: "idle", + }, + }, + }, + idle: { + on: { + // User + ADD_USER: { target: "addingUser", actions: ["assignUserToBeAdded"] }, + UPDATE_USER_ROLE: { target: "updatingUser", actions: ["assignUserToBeUpdated"] }, + REMOVE_USER: { target: "removingUser", actions: ["removeUserFromTemplateACL"] }, + // Group + ADD_GROUP: { target: "addingGroup", actions: ["assignGroupToBeAdded"] }, + UPDATE_GROUP_ROLE: { target: "updatingGroup", actions: ["assignGroupToBeUpdated"] }, + REMOVE_GROUP: { target: "removingGroup", actions: ["removeGroupFromTemplateACL"] }, + }, + }, + // User + addingUser: { + invoke: { + src: "addUser", + onDone: { + target: "idle", + actions: ["addUserToTemplateACL", "runAddUserCallback"], + }, + }, + }, + updatingUser: { + invoke: { + src: "updateUser", + onDone: { + target: "idle", + actions: [ + "updateUserOnTemplateACL", + "clearUserToBeUpdated", + "displayUpdateUserSuccessMessage", + ], + }, + }, + }, + removingUser: { + invoke: { + src: "removeUser", + onDone: { + target: "idle", + actions: ["displayRemoveUserSuccessMessage"], + }, + }, + }, + // Group + addingGroup: { + invoke: { + src: "addGroup", + onDone: { + target: "idle", + actions: ["addGroupToTemplateACL", "runAddGroupCallback"], + }, + }, + }, + updatingGroup: { + invoke: { + src: "updateGroup", + onDone: { + target: "idle", + actions: [ + "updateGroupOnTemplateACL", + "clearGroupToBeUpdated", + "displayUpdateGroupSuccessMessage", + ], + }, + }, + }, + removingGroup: { + invoke: { + src: "removeGroup", + onDone: { + target: "idle", + actions: ["displayRemoveGroupSuccessMessage"], + }, + }, + }, + }, + }, + { + services: { + loadTemplateACL: ({ templateId }) => getTemplateACL(templateId), + // User + addUser: ({ templateId }, { user, role }) => + updateTemplateACL(templateId, { + user_perms: { + [user.id]: role, + }, + }), + updateUser: ({ templateId }, { user, role }) => + updateTemplateACL(templateId, { + user_perms: { + [user.id]: role, + }, + }), + removeUser: ({ templateId }, { user }) => + updateTemplateACL(templateId, { + user_perms: { + [user.id]: "", + }, + }), + // Group + addGroup: ({ templateId }, { group, role }) => + updateTemplateACL(templateId, { + group_perms: { + [group.id]: role, + }, + }), + updateGroup: ({ templateId }, { group, role }) => + updateTemplateACL(templateId, { + group_perms: { + [group.id]: role, + }, + }), + removeGroup: ({ templateId }, { group }) => + updateTemplateACL(templateId, { + group_perms: { + [group.id]: "", + }, + }), + }, + actions: { + assignTemplateACL: assign({ + templateACL: (_, { data }) => data, + }), + // User + assignUserToBeAdded: assign({ + userToBeAdded: (_, { user, role }) => ({ ...user, role }), + addUserCallback: (_, { onDone }) => onDone, + }), + addUserToTemplateACL: assign({ + templateACL: ({ templateACL, userToBeAdded }) => { + if (!userToBeAdded) { + throw new Error("No user to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + users: [...templateACL.users, userToBeAdded], + } + }, + }), + runAddUserCallback: ({ addUserCallback }) => { + if (addUserCallback) { + addUserCallback() + } + }, + assignUserToBeUpdated: assign({ + userToBeUpdated: (_, { user, role }) => ({ ...user, role }), + }), + updateUserOnTemplateACL: assign({ + templateACL: ({ templateACL, userToBeUpdated }) => { + if (!userToBeUpdated) { + throw new Error("No user to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + users: templateACL.users.map((oldTemplateUser) => { + return oldTemplateUser.id === userToBeUpdated.id ? userToBeUpdated : oldTemplateUser + }), + } + }, + }), + clearUserToBeUpdated: assign({ + userToBeUpdated: (_) => undefined, + }), + displayUpdateUserSuccessMessage: () => { + displaySuccess("User role update successfully!") + }, + removeUserFromTemplateACL: assign({ + templateACL: ({ templateACL }, { user }) => { + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + users: templateACL.users.filter((oldTemplateUser) => { + return oldTemplateUser.id !== user.id + }), + } + }, + }), + displayRemoveUserSuccessMessage: () => { + displaySuccess("User removed successfully!") + }, + // Group + assignGroupToBeAdded: assign({ + groupToBeAdded: (_, { group, role }) => ({ ...group, role }), + addGroupCallback: (_, { onDone }) => onDone, + }), + addGroupToTemplateACL: assign({ + templateACL: ({ templateACL, groupToBeAdded }) => { + if (!groupToBeAdded) { + throw new Error("No group to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + group: [...templateACL.group, groupToBeAdded], + } + }, + }), + runAddGroupCallback: ({ addGroupCallback }) => { + if (addGroupCallback) { + addGroupCallback() + } + }, + assignGroupToBeUpdated: assign({ + groupToBeUpdated: (_, { group, role }) => ({ ...group, role }), + }), + updateGroupOnTemplateACL: assign({ + templateACL: ({ templateACL, groupToBeUpdated }) => { + if (!groupToBeUpdated) { + throw new Error("No group to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + group: templateACL.group.map((oldTemplateGroup) => { + return oldTemplateGroup.id === groupToBeUpdated.id + ? groupToBeUpdated + : oldTemplateGroup + }), + } + }, + }), + clearGroupToBeUpdated: assign({ + groupToBeUpdated: (_) => undefined, + }), + displayUpdateGroupSuccessMessage: () => { + displaySuccess("Group role update successfully!") + }, + removeGroupFromTemplateACL: assign({ + templateACL: ({ templateACL }, { group }) => { + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + group: templateACL.group.filter((oldTemplateGroup) => { + return oldTemplateGroup.id !== group.id + }), + } + }, + }), + displayRemoveGroupSuccessMessage: () => { + displaySuccess("Group removed successfully!") + }, + }, + }, +) diff --git a/site/src/xServices/template/templateUsersXService.ts b/site/src/xServices/template/templateUsersXService.ts deleted file mode 100644 index c0a20c9592bdc..0000000000000 --- a/site/src/xServices/template/templateUsersXService.ts +++ /dev/null @@ -1,174 +0,0 @@ -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( - { - schema: { - context: {} as { - templateId: string - templateUsers?: TemplateUser[] - userToBeAdded?: TemplateUser - userToBeUpdated?: TemplateUser - addUserCallback?: () => void - }, - services: {} as { - loadTemplateUsers: { - data: TemplateUser[] - } - addUser: { - data: unknown - } - updateUser: { - data: unknown - } - }, - 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", - initial: "loading", - states: { - loading: { - invoke: { - src: "loadTemplateUsers", - onDone: { - actions: ["assignTemplateUsers"], - target: "idle", - }, - }, - }, - idle: { - on: { - ADD_USER: { target: "addingUser", actions: ["assignUserToBeAdded"] }, - UPDATE_USER_ROLE: { target: "updatingUser", actions: ["assignUserToBeUpdated"] }, - REMOVE_USER: { target: "removingUser", actions: ["removeUserFromTemplateUsers"] }, - }, - }, - addingUser: { - invoke: { - src: "addUser", - onDone: { - target: "idle", - actions: ["addUserToTemplateUsers", "runAddCallback"], - }, - }, - }, - updatingUser: { - invoke: { - src: "updateUser", - onDone: { - target: "idle", - actions: [ - "updateUserOnTemplateUsers", - "clearUserToBeUpdated", - "displayUpdateSuccessMessage", - ], - }, - }, - }, - removingUser: { - invoke: { - src: "removeUser", - onDone: { - target: "idle", - actions: ["displayRemoveSuccessMessage"], - }, - }, - }, - }, - }, - { - services: { - loadTemplateUsers: ({ templateId }) => getTemplateUserRoles(templateId), - addUser: ({ templateId }, { user, role }) => - updateTemplateMeta(templateId, { - user_perms: { - [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({ - 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] - }, - }), - 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 08805b3512eb9c563d64617b39f6e2cb6ebbddfa Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Fri, 30 Sep 2022 12:20:17 -0500 Subject: [PATCH 095/138] allow org members to read all groups (#4277) --- coderd/rbac/builtin.go | 4 ++++ enterprise/coderd/groups_test.go | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 71f8e7d2214c4..0d4f2154846d1 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -172,6 +172,10 @@ var ( ResourceType: ResourceOrgRoleAssignment.Type, Action: ActionRead, }, + { + ResourceType: ResourceGroup.Type, + Action: ActionRead, + }, }, }, } diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index fb2e8d0c6b177..72fd434232e38 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -282,6 +282,25 @@ func TestGroup(t *testing.T) { require.NoError(t, err) require.Equal(t, group, ggroup) }) + + t.Run("RegularUserReadGroup", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + ggroup, err := client1.Group(ctx, group.ID) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) } // TODO: test auth. From 845d81f92f7659842c724e3b5acbcbba5aa884ed Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Fri, 30 Sep 2022 13:28:09 -0500 Subject: [PATCH 096/138] populate template acl group with members (#4279) --- coderd/database/queries/groups.sql | 1 - enterprise/coderd/templates.go | 39 +++++++++++++++++------------ enterprise/coderd/templates_test.go | 5 +++- 3 files changed, 27 insertions(+), 18 deletions(-) diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index 3e337bdb29c8e..da4565ed0f3ce 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -32,7 +32,6 @@ ON WHERE group_members.user_id = $1; - -- name: GetGroupMembers :many SELECT users.* diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index b3ba1ee8a5111..d7a582eadf308 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -40,13 +40,13 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { return } - groups, err := api.Database.GetTemplateGroupRoles(ctx, template.ID) + dbGroups, err := api.Database.GetTemplateGroupRoles(ctx, template.ID) if err != nil { httpapi.InternalServerError(rw, err) return } - groups, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, rbac.ActionRead, groups) + dbGroups, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, rbac.ActionRead, dbGroups) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching users.", @@ -71,9 +71,29 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs } + groups := make([]codersdk.TemplateGroup, 0, len(dbGroups)) + for _, group := range dbGroups { + var members []database.User + + if group.Name == database.AllUsersGroup { + members, err = api.Database.GetAllOrganizationMembers(ctx, group.OrganizationID) + } else { + members, err = api.Database.GetGroupMembers(ctx, group.ID) + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + groups = append(groups, codersdk.TemplateGroup{ + Group: convertGroup(group.Group, members), + Role: convertToTemplateRole(group.Actions), + }) + } + httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateACL{ Users: convertTemplateUsers(users, organizationIDsByUserID), - Groups: convertTemplateGroups(groups), + Groups: groups, }) } @@ -203,19 +223,6 @@ func convertTemplateUsers(tus []database.TemplateUser, orgIDsByUserIDs map[uuid. return users } -func convertTemplateGroups(tgs []database.TemplateGroup) []codersdk.TemplateGroup { - groups := make([]codersdk.TemplateGroup, 0, len(tgs)) - - for _, tg := range tgs { - groups = append(groups, codersdk.TemplateGroup{ - Group: convertGroup(tg.Group, nil), - Role: convertToTemplateRole(tg.Actions), - }) - } - - return groups -} - func validateTemplateRole(role codersdk.TemplateRole) error { actions := convertSDKTemplateRole(role) if actions == nil && role != codersdk.TemplateRoleDeleted { diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index f52a9e12e8004..6e0b151208c05 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -58,6 +58,8 @@ func TestTemplateACL(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -68,6 +70,8 @@ func TestTemplateACL(t *testing.T) { require.NoError(t, err) require.Len(t, acl.Groups, 1) + require.Len(t, acl.Groups[0].Members, 2) + require.Contains(t, acl.Groups[0].Members, user1) require.Len(t, acl.Users, 0) }) @@ -228,7 +232,6 @@ func TestTemplateACL(t *testing.T) { Role: codersdk.TemplateRoleView, }) }) - } func TestUpdateTemplateACL(t *testing.T) { From 564928eec2b0afdf314a3d663afc269085d30387 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 30 Sep 2022 14:30:49 -0400 Subject: [PATCH 097/138] chore: Minor rego optimization by removing excessive queries (#4275) * test: Add ACL lists to benchmark * chore: Minor optimization in the rego * Linting --- coderd/rbac/authz_internal_test.go | 6 +++--- coderd/rbac/builtin_test.go | 8 +++++++- coderd/rbac/policy.rego | 19 ++++++++----------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 1b747d604a9f9..d4c3b0a0c605d 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -241,21 +241,21 @@ func TestAuthorizeDomain(t *testing.T) { testAuthorize(t, "GroupACLList", user, []authTestCase{ { - resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithGroupACL(map[string][]Action{ + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ allUsersGroup: allActions(), }), actions: allActions(), allow: true, }, { - resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithGroupACL(map[string][]Action{ + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ allUsersGroup: {WildcardSymbol}, }), actions: allActions(), allow: true, }, { - resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithGroupACL(map[string][]Action{ + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ allUsersGroup: {ActionRead, ActionUpdate}, }), actions: []Action{ActionCreate, ActionDelete}, diff --git a/coderd/rbac/builtin_test.go b/coderd/rbac/builtin_test.go index d4ac000ab3a9a..8ea4323f8264a 100644 --- a/coderd/rbac/builtin_test.go +++ b/coderd/rbac/builtin_test.go @@ -97,11 +97,17 @@ func BenchmarkRBACFilter(b *testing.B) { func benchmarkSetup(orgs []uuid.UUID, users []uuid.UUID, size int) []rbac.Object { // Create a "random" but deterministic set of objects. + aclList := map[string][]rbac.Action{ + uuid.NewString(): {rbac.ActionRead, rbac.ActionUpdate}, + uuid.NewString(): {rbac.ActionCreate}, + } objectList := make([]rbac.Object, size) for i := range objectList { objectList[i] = rbac.ResourceWorkspace. InOrg(orgs[i%len(orgs)]). - WithOwner(users[i%len(users)].String()) + WithOwner(users[i%len(users)].String()). + WithACLUserList(aclList). + WithGroupACL(aclList) } return objectList diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 1d16e9bf73f1b..095f1844bd78d 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -119,9 +119,13 @@ org_mem := true { input.object.org_owner in org_members } +org_ok { + org_mem +} + # If the object has no organization, then the user is also considered part of # the non-existent org. -org_mem := true { +org_ok { input.object.org_owner == "" } @@ -170,7 +174,7 @@ role_allow { not org = -1 # If we are not a member of an org, and the object has an org, then we are # not authorized. This is an "implied -1" for not being in the org. - org_mem + org_ok user = 1 } @@ -188,7 +192,7 @@ scope_allow { not scope_org = -1 # If we are not a member of an org, and the object has an org, then we are # not authorized. This is an "implied -1" for not being in the org. - org_mem + org_ok scope_user = 1 } @@ -204,13 +208,7 @@ acl_allow { acl_allow { # If there is no organization owner, the object cannot be owned by an # org_scoped team. - # TODO: This line and 'org_mem' are similar and should be combined. - # Currently the simplfied queries return extra queries that are always - # false. If these 2 lines are combined, we reduce the number of queries - # returned by partial execution. -# input.object.org_owner != "" - # Only people in the org can use the team access. -# org_mem + org_mem group := input.subject.groups[_] perms := input.object.acl_group_list[group] # Either the input action or wildcard @@ -220,7 +218,6 @@ acl_allow { # ACL for 'all_users' special group acl_allow { org_mem - input.object.org_owner != "" perms := input.object.acl_group_list[input.object.org_owner] [input.action, "*"][_] in perms } From 38cce76e930950b5f86f946367c8a4c605e4311b Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 30 Sep 2022 14:31:07 -0400 Subject: [PATCH 098/138] feat: Add resource_id option to authcheck (#4278) * feat: Add resource_id option to authcheck * Reorg imports * Move unit test to coderen * Fix unit tests, add resource ids types * Reformat into var block --- coderd/authorize.go | 82 ++++++++++++++++++--- coderd/authorize_test.go | 51 ++++++++----- coderd/workspaceapps_test.go | 1 - enterprise/coderd/authorize_test.go | 106 ++++++++++++++++++++++++++++ 4 files changed, 214 insertions(+), 26 deletions(-) create mode 100644 enterprise/coderd/authorize_test.go diff --git a/coderd/authorize.go b/coderd/authorize.go index bf6de7208cddc..1f7c8d0747b7a 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -2,6 +2,7 @@ package coderd import ( "fmt" + "github.com/google/uuid" "net/http" "golang.org/x/xerrors" @@ -104,6 +105,28 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { ) response := make(codersdk.AuthorizationResponse) + // Prevent using too many resources by ID. This prevents database abuse + // from this endpoint. This also prevents misuse of this endpoint, as + // resource_id should be used for single objects, not for a list of them. + var ( + idFetch int + maxFetch = 10 + ) + for _, v := range params.Checks { + if v.Object.ResourceID != "" { + idFetch++ + } + } + if idFetch > maxFetch { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf( + "Endpoint only supports using \"resource_id\" field %d times, found %d usages. Remove %d objects with this field set.", + maxFetch, idFetch, idFetch-maxFetch, + ), + }) + return + } + for k, v := range params.Checks { if v.Object.ResourceType == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -112,15 +135,58 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { return } - if v.Object.OwnerID == "me" { - v.Object.OwnerID = auth.ID.String() + obj := rbac.Object{ + Owner: v.Object.OwnerID, + OrgID: v.Object.OrganizationID, + Type: v.Object.ResourceType, } - err := api.Authorizer.ByRoleName(r.Context(), auth.ID.String(), auth.Roles, auth.Scope.ToRBAC(), auth.Groups, rbac.Action(v.Action), - rbac.Object{ - Owner: v.Object.OwnerID, - OrgID: v.Object.OrganizationID, - Type: v.Object.ResourceType, - }) + if obj.Owner == "me" { + obj.Owner = auth.ID.String() + } + + // If a resource ID is specified, fetch that specific resource. + if v.Object.ResourceID != "" { + id, err := uuid.Parse(v.Object.ResourceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Object %q id is not a valid uuid.", v.Object.ResourceID), + Validations: []codersdk.ValidationError{{Field: "resource_id", Detail: err.Error()}}, + }) + return + } + + var dbObj rbac.Objecter + var dbErr error + // Only support referencing some resources by ID. + switch v.Object.ResourceType { + case rbac.ResourceWorkspaceExecution.Type: + wrkSpace, err := api.Database.GetWorkspaceByID(ctx, id) + if err == nil { + dbObj = wrkSpace.ExecutionRBAC() + } + dbErr = err + case rbac.ResourceWorkspace.Type: + dbObj, dbErr = api.Database.GetWorkspaceByID(ctx, id) + case rbac.ResourceTemplate.Type: + dbObj, dbErr = api.Database.GetTemplateByID(ctx, id) + case rbac.ResourceUser.Type: + dbObj, dbErr = api.Database.GetUserByID(ctx, id) + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Object type %q does not support \"resource_id\" field.", v.Object.ResourceType), + Validations: []codersdk.ValidationError{{Field: "resource_type", Detail: err.Error()}}, + }) + return + } + if dbErr != nil { + // 404 or unauthorized is false + response[k] = false + continue + } + obj = dbObj.RBACObject() + } + + err := api.Authorizer.ByRoleName(r.Context(), auth.ID.String(), auth.Roles, auth.Scope.ToRBAC(), auth.Groups, rbac.Action(v.Action), obj) response[k] = err == nil } diff --git a/coderd/authorize_test.go b/coderd/authorize_test.go index 49c0704f3bf63..b495bda4d0529 100644 --- a/coderd/authorize_test.go +++ b/coderd/authorize_test.go @@ -19,7 +19,9 @@ func TestCheckPermissions(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) t.Cleanup(cancel) - adminClient := coderdtest.New(t, nil) + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) // Create adminClient, member, and org adminClient adminUser := coderdtest.CreateFirstUser(t, adminClient) memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) @@ -29,12 +31,17 @@ func TestCheckPermissions(t *testing.T) { orgAdminUser, err := orgAdminClient.User(ctx, codersdk.Me) require.NoError(t, err) + version := coderdtest.CreateTemplateVersion(t, adminClient, adminUser.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, adminClient, version.ID) + template := coderdtest.CreateTemplate(t, adminClient, adminUser.OrganizationID, version.ID) + // With admin, member, and org admin const ( - readAllUsers = "read-all-users" - readOrgWorkspaces = "read-org-workspaces" - readMyself = "read-myself" - readOwnWorkspaces = "read-own-workspaces" + readAllUsers = "read-all-users" + readOrgWorkspaces = "read-org-workspaces" + readMyself = "read-myself" + readOwnWorkspaces = "read-own-workspaces" + updateSpecificTemplate = "update-specific-template" ) params := map[string]codersdk.AuthorizationCheck{ readAllUsers: { @@ -64,6 +71,13 @@ func TestCheckPermissions(t *testing.T) { }, Action: "read", }, + updateSpecificTemplate: { + Object: codersdk.AuthorizationObject{ + ResourceType: rbac.ResourceTemplate.Type, + ResourceID: template.ID.String(), + }, + Action: "update", + }, } testCases := []struct { @@ -77,10 +91,11 @@ func TestCheckPermissions(t *testing.T) { Client: adminClient, UserID: adminUser.UserID, Check: map[string]bool{ - readAllUsers: true, - readMyself: true, - readOwnWorkspaces: true, - readOrgWorkspaces: true, + readAllUsers: true, + readMyself: true, + readOwnWorkspaces: true, + readOrgWorkspaces: true, + updateSpecificTemplate: true, }, }, { @@ -88,10 +103,11 @@ func TestCheckPermissions(t *testing.T) { Client: orgAdminClient, UserID: orgAdminUser.ID, Check: map[string]bool{ - readAllUsers: false, - readMyself: true, - readOwnWorkspaces: true, - readOrgWorkspaces: true, + readAllUsers: false, + readMyself: true, + readOwnWorkspaces: true, + readOrgWorkspaces: true, + updateSpecificTemplate: true, }, }, { @@ -99,10 +115,11 @@ func TestCheckPermissions(t *testing.T) { Client: memberClient, UserID: memberUser.ID, Check: map[string]bool{ - readAllUsers: false, - readMyself: true, - readOwnWorkspaces: true, - readOrgWorkspaces: false, + readAllUsers: false, + readMyself: true, + readOwnWorkspaces: true, + readOrgWorkspaces: false, + updateSpecificTemplate: false, }, }, } diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index e111863f578df..b79fbd83d55e0 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -362,7 +362,6 @@ func TestWorkspaceApplicationAuth(t *testing.T) { ResourceType: "application_connect", OwnerID: "me", OrganizationID: firstUser.OrganizationID.String(), - ResourceID: uuid.NewString(), }, Action: "create", }, diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go new file mode 100644 index 0000000000000..09d1f66b1a8a7 --- /dev/null +++ b/enterprise/coderd/authorize_test.go @@ -0,0 +1,106 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestCheckACLPermissions(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + adminClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + // Create adminClient, member, and org adminClient + adminUser := coderdtest.CreateFirstUser(t, adminClient) + memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + memberUser, err := memberClient.User(ctx, codersdk.Me) + require.NoError(t, err) + orgAdminClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID, rbac.RoleOrgAdmin(adminUser.OrganizationID)) + orgAdminUser, err := orgAdminClient.User(ctx, codersdk.Me) + require.NoError(t, err) + + version := coderdtest.CreateTemplateVersion(t, adminClient, adminUser.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, adminClient, version.ID) + template := coderdtest.CreateTemplate(t, adminClient, adminUser.OrganizationID, version.ID) + + err = adminClient.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + memberUser.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + const ( + updateSpecificTemplate = "read-specific-template" + ) + params := map[string]codersdk.AuthorizationCheck{ + updateSpecificTemplate: { + Object: codersdk.AuthorizationObject{ + ResourceType: rbac.ResourceTemplate.Type, + ResourceID: template.ID.String(), + }, + Action: "write", + }, + } + + testCases := []struct { + Name string + Client *codersdk.Client + UserID uuid.UUID + Check codersdk.AuthorizationResponse + }{ + { + Name: "Admin", + Client: adminClient, + UserID: adminUser.UserID, + Check: map[string]bool{ + updateSpecificTemplate: true, + }, + }, + { + Name: "OrgAdmin", + Client: orgAdminClient, + UserID: orgAdminUser.ID, + Check: map[string]bool{ + updateSpecificTemplate: true, + }, + }, + { + Name: "Member", + Client: memberClient, + UserID: memberUser.ID, + Check: map[string]bool{ + updateSpecificTemplate: true, + }, + }, + } + + for _, c := range testCases { + c := c + + t.Run("CheckAuthorization/"+c.Name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + resp, err := c.Client.CheckAuthorization(ctx, codersdk.AuthorizationRequest{Checks: params}) + require.NoError(t, err, "check perms") + require.Equal(t, c.Check, resp) + }) + } +} From a59138a49a9affa2566b71b030cc31f9fcf17474 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 3 Oct 2022 11:10:16 -0400 Subject: [PATCH 099/138] Add group for authcheck --- coderd/authorize.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/authorize.go b/coderd/authorize.go index 1f7c8d0747b7a..ca4aa01926e99 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -171,6 +171,8 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { dbObj, dbErr = api.Database.GetTemplateByID(ctx, id) case rbac.ResourceUser.Type: dbObj, dbErr = api.Database.GetUserByID(ctx, id) + case rbac.ResourceGroup.Type: + dbObj, dbErr = api.Database.GetGroupByID(ctx, id) default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Object type %q does not support \"resource_id\" field.", v.Object.ResourceType), From a50af85e5fe15ce218a808da9ff50a957aaf518b Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 3 Oct 2022 13:23:07 -0300 Subject: [PATCH 100/138] chore: Update permissions (#4337) * Check if user can create a group * wip: template permission * Check group permissions * Update template permissions --- .../TemplateLayout/TemplateLayout.tsx | 48 ++++--- .../components/UsersLayout/UsersLayout.tsx | 12 +- site/src/pages/GroupsPage/GroupPage.tsx | 46 ++++--- site/src/pages/GroupsPage/GroupsPage.tsx | 16 ++- .../TemplatePermissionsPage.tsx | 11 +- .../TemplatePermissionsPageView.tsx | 129 ++++++++++-------- site/src/xServices/auth/authXService.ts | 7 + site/src/xServices/groups/groupXService.ts | 60 ++++++-- .../xServices/template/templateXService.ts | 46 ++++++- 9 files changed, 251 insertions(+), 124 deletions(-) diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx index 85df4b7082209..4a89c09bc8aef 100644 --- a/site/src/components/TemplateLayout/TemplateLayout.tsx +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -5,6 +5,7 @@ import { makeStyles } from "@material-ui/core/styles" import AddCircleOutline from "@material-ui/icons/AddCircleOutline" import SettingsOutlined from "@material-ui/icons/SettingsOutlined" import { useMachine, useSelector } from "@xstate/react" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" import { DeleteButton } from "components/DropdownButton/ActionCtas" import { DropdownButton } from "components/DropdownButton/DropdownButton" @@ -47,11 +48,22 @@ export const TemplateLayout: FC = () => { organizationId, }, }) - const { template, activeTemplateVersion, templateResources, templateDAUs } = templateState.context + const { + template, + activeTemplateVersion, + templateResources, + templateDAUs, + permissions: templatePermissions, + } = templateState.context const xServices = useContext(XServiceContext) const permissions = useSelector(xServices.authXService, selectPermissions) const isLoading = - !template || !activeTemplateVersion || !templateResources || !permissions || !templateDAUs + !template || + !activeTemplateVersion || + !templateResources || + !permissions || + !templateDAUs || + !templatePermissions if (isLoading) { return @@ -80,18 +92,18 @@ export const TemplateLayout: FC = () => { - - - - - {permissions.deleteTemplates ? ( + + + + + + { ]} canCancel={false} /> - ) : ( - createWorkspaceButton() - )} - + + + {createWorkspaceButton()} + } > diff --git a/site/src/components/UsersLayout/UsersLayout.tsx b/site/src/components/UsersLayout/UsersLayout.tsx index 7efbce89310f7..10fd7b8e2f144 100644 --- a/site/src/components/UsersLayout/UsersLayout.tsx +++ b/site/src/components/UsersLayout/UsersLayout.tsx @@ -13,7 +13,7 @@ import { Stack } from "../../components/Stack/Stack" export const UsersLayout: FC = ({ children }) => { const styles = useStyles() - const { createUser: canCreateUser } = usePermissions() + const { createUser: canCreateUser, createGroup: canCreateGroup } = usePermissions() const navigate = useNavigate() return ( @@ -21,8 +21,8 @@ export const UsersLayout: FC = ({ children }) => { + <> + {canCreateUser && ( + )} + {canCreateGroup && ( - - ) : undefined + )} + } > Users diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index c9e83c1da2d98..9dfebf2eab9ef 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -27,6 +27,7 @@ import { Helmet } from "react-helmet-async" import { Link as RouterLink, useNavigate, useParams } from "react-router-dom" import { pageTitle } from "util/page" import { groupMachine } from "xServices/groups/groupXService" +import { Maybe } from "components/Conditionals/Maybe" const AddGroupMember: React.FC<{ isLoading: boolean @@ -87,8 +88,9 @@ export const GroupPage: React.FC = () => { }, }, }) - const { group } = state.context - const isLoading = group === undefined + const { group, permissions } = state.context + const isLoading = group === undefined || permissions === undefined + const canUpdateGroup = permissions ? permissions.canUpdateGroup : false return ( <> @@ -104,7 +106,7 @@ export const GroupPage: React.FC = () => { + @@ -116,7 +118,7 @@ export const GroupPage: React.FC = () => { > Delete - + } > {group?.name} @@ -124,12 +126,14 @@ export const GroupPage: React.FC = () => { - { - send({ type: "ADD_MEMBER", userId: user.id, callback: reset }) - }} - /> + + { + send({ type: "ADD_MEMBER", userId: user.id, callback: reset }) + }} + /> +
@@ -163,17 +167,19 @@ export const GroupPage: React.FC = () => { /> - { - send({ type: "REMOVE_MEMBER", userId: member.id }) + + { + send({ type: "REMOVE_MEMBER", userId: member.id }) + }, }, - }, - ]} - /> + ]} + /> + ))} diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 88a3c87c4d725..7bad0976d6a2b 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -17,6 +17,7 @@ import { EmptyState } from "components/EmptyState/EmptyState" import { TableLoader } from "components/TableLoader/TableLoader" import { UserAvatar } from "components/UserAvatar/UserAvatar" import { useOrganizationId } from "hooks/useOrganizationId" +import { usePermissions } from "hooks/usePermissions" import React from "react" import { Helmet } from "react-helmet-async" import { Link as RouterLink, useNavigate } from "react-router-dom" @@ -35,6 +36,7 @@ export const GroupsPage: React.FC = () => { const isEmpty = Boolean(groups && groups.length === 0) const navigate = useNavigate() const styles = useStyles() + const { createGroup: canCreateGroup } = usePermissions() return ( <> @@ -61,11 +63,17 @@ export const GroupsPage: React.FC = () => { - - + canCreateGroup && ( + + + + ) } /> diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index 37ae11b25954c..e9cbf980cd4de 100644 --- a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -13,17 +13,14 @@ export const TemplatePermissionsPage: FC> = () templateContext: TemplateContext permissions: Permissions }>() - const { template } = templateContext + const { template, permissions } = templateContext - if (!template) { - throw new Error( - "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.", - ) + if (!template || !permissions) { + throw new Error("This page should not be displayed until template or permissions being loaded.") } const [state, send] = useMachine(templateACLMachine, { context: { templateId: template.id } }) const { templateACL, userToBeUpdated, groupToBeUpdated } = state.context - const canUpdatesUsers = true return ( <> @@ -32,7 +29,7 @@ export const TemplatePermissionsPage: FC> = () { send("ADD_USER", { user, role, onDone: reset }) }} diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index 085d327ec09aa..ba73043b105e7 100644 --- a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -21,6 +21,7 @@ import { UserOrGroupAutocompleteValue, } from "components/UserOrGroupAutocomplete/UserOrGroupAutocomplete" import { FC, useState } from "react" +import { Maybe } from "components/Conditionals/Maybe" const AddTemplateUserOrGroup: React.FC<{ isLoading: boolean @@ -100,7 +101,7 @@ export interface TemplatePermissionsPageViewProps { // User onAddUser: (user: TemplateUser, role: TemplateRole, reset: () => void) => void isAddingUser: boolean - canUpdateUsers: boolean + canUpdatePermissions: boolean onUpdateUser: (user: TemplateUser, role: TemplateRole) => void updatingUser: TemplateUser | undefined onRemoveUser: (user: TemplateUser) => void @@ -116,7 +117,7 @@ export const TemplatePermissionsPageView: FC< React.PropsWithChildren > = ({ templateACL, - canUpdateUsers, + canUpdatePermissions, // User onAddUser, isAddingUser, @@ -137,14 +138,16 @@ export const TemplatePermissionsPageView: FC< return ( - - "members" in value - ? onAddGroup(value, role, resetAutocomplete) - : onAddUser(value, role, resetAutocomplete) - } - /> + + + "members" in value + ? onAddGroup(value, role, resetAutocomplete) + : onAddUser(value, role, resetAutocomplete) + } + /> +
@@ -180,30 +183,33 @@ export const TemplatePermissionsPageView: FC< /> - {canUpdateUsers ? ( - - ) : ( - group.role - )} + + + + + +
{group.role}
+
+
- {canUpdateUsers && ( - + + - - )} + + ))} @@ -237,30 +243,33 @@ export const TemplatePermissionsPageView: FC< /> - {canUpdateUsers ? ( - - ) : ( - user.role - )} + + + + + +
{user.role}
+
+
- {canUpdateUsers && ( - + + - - )} + + ))} @@ -309,5 +318,9 @@ export const useStyles = makeStyles((theme) => { paddingBottom: theme.spacing(1.5), }, }, + + role: { + textTransform: "capitalize", + }, } }) diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 16f77e0d75bd8..fc73f855380fe 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -16,6 +16,7 @@ export const checks = { createTemplates: "createTemplates", deleteTemplates: "deleteTemplates", viewAuditLog: "viewAuditLog", + createGroup: "createGroup", } as const export const permissionsToCheck = { @@ -55,6 +56,12 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.createGroup]: { + object: { + resource_type: "group", + }, + action: "create", + }, } as const export type Permissions = Record diff --git a/site/src/xServices/groups/groupXService.ts b/site/src/xServices/groups/groupXService.ts index de7006c6bf7dd..4a5cadb1a33e5 100644 --- a/site/src/xServices/groups/groupXService.ts +++ b/site/src/xServices/groups/groupXService.ts @@ -1,6 +1,6 @@ -import { deleteGroup, getGroup, patchGroup } from "api/api" +import { deleteGroup, getGroup, patchGroup, checkAuthorization } from "api/api" import { getErrorMessage } from "api/errors" -import { Group } from "api/typesGenerated" +import { AuthorizationResponse, Group } from "api/typesGenerated" import { displayError, displaySuccess } from "components/GlobalSnackbar/utils" import { assign, createMachine } from "xstate" @@ -11,6 +11,7 @@ export const groupMachine = createMachine( context: {} as { groupId: string group?: Group + permissions?: AuthorizationResponse addMemberCallback?: () => void removingMember?: string }, @@ -18,6 +19,9 @@ export const groupMachine = createMachine( loadGroup: { data: Group } + loadPermissions: { + data: AuthorizationResponse + } addMember: { data: Group } @@ -52,17 +56,32 @@ export const groupMachine = createMachine( initial: "loading", states: { loading: { - invoke: { - src: "loadGroup", - onDone: { - actions: ["assignGroup"], - target: "idle", + type: "parallel", + states: { + data: { + invoke: { + src: "loadGroup", + onDone: { + actions: ["assignGroup"], + }, + onError: { + actions: ["displayLoadGroupError"], + }, + }, }, - onError: { - actions: ["displayLoadGroupError"], - target: "idle", + permissions: { + invoke: { + src: "loadPermissions", + onDone: { + actions: ["assignPermissions"], + }, + onError: { + actions: ["displayLoadPermissionsError"], + }, + }, }, }, + onDone: "idle", }, idle: { on: { @@ -127,6 +146,18 @@ export const groupMachine = createMachine( { services: { loadGroup: ({ groupId }) => getGroup(groupId), + loadPermissions: ({ groupId }) => + checkAuthorization({ + checks: { + canUpdateGroup: { + object: { + resource_type: "group", + resource_id: groupId, + }, + action: "update", + }, + }, + }), addMember: ({ group }, { userId }) => { if (!group) { throw new Error("Group not defined.") @@ -157,7 +188,7 @@ export const groupMachine = createMachine( addMemberCallback: (_, { callback }) => callback, }), displayLoadGroupError: (_, { data }) => { - const message = getErrorMessage(data, "Failed to the group.") + const message = getErrorMessage(data, "Failed to load the group.") displayError(message) }, displayAddMemberError: (_, { data }) => { @@ -193,6 +224,13 @@ export const groupMachine = createMachine( const message = getErrorMessage(data, "Failed to delete group.") displayError(message) }, + assignPermissions: assign({ + permissions: (_, { data }) => data, + }), + displayLoadPermissionsError: (_, { data }) => { + const message = getErrorMessage(data, "Failed to load the permissions.") + displayError(message) + }, }, }, ) diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts index df66f2ba1c308..86835c45454f8 100644 --- a/site/src/xServices/template/templateXService.ts +++ b/site/src/xServices/template/templateXService.ts @@ -2,6 +2,7 @@ import { displaySuccess } from "components/GlobalSnackbar/utils" import { t } from "i18next" import { assign, createMachine } from "xstate" import { + checkAuthorization, deleteTemplate, getTemplateByName, getTemplateDAUs, @@ -10,6 +11,7 @@ import { getTemplateVersions, } from "../../api/api" import { + AuthorizationResponse, Template, TemplateDAUsResponse, TemplateVersion, @@ -23,13 +25,24 @@ export interface TemplateContext { activeTemplateVersion?: TemplateVersion templateResources?: WorkspaceResource[] templateVersions?: TemplateVersion[] - templateDAUs: TemplateDAUsResponse + templateDAUs?: TemplateDAUsResponse + permissions?: AuthorizationResponse deleteTemplateError?: Error | unknown getTemplateError?: Error | unknown } type TemplateEvent = { type: "DELETE" } | { type: "CONFIRM_DELETE" } | { type: "CANCEL_DELETE" } +const getPermissionsToCheck = (templateId: string) => ({ + canUpdateTemplate: { + object: { + resource_type: "template", + resource_id: templateId, + }, + action: "update", + }, +}) + export const templateMachine = /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOhgBdyCoAVMVABwBt1ywBiCAe0JIIDcuAazAk0WPIVIUq+WvWaswCAV0ytcPANoAGALqJQDLrFxUehkAA9EAJgDsOkgGZbO2wE5bANg8BGABZnHR0AgBoQAE9EPx0-Eg9EpIAOe28-D28ggF9siPEcAmI+fDNcdCYASXwAMy4SLCp+MDpGFjYANTAAJ1MeMjBKagBBTCaWhXawLt7NfE4eUVURMQxCqRKyiuq6hrHcZtbFTp6+-AGhuVHxo6mZs5V8QXVzfF0DJBBjU1fLGwQAgF4t4AKzeWzOTIhEEhZIRaIIZI6EEkEIhZyg2xuEF+XL5NaSYoELZVWr1NhtJQAJTgXAArt1MHALrJ5JS2DTYPTGXAFrxlqICoTSMSqNsySQKccwJzuUzYCzqLdqbSGfLHs8NNp9JZvmULJ9-kDkiRks4QR50SDkh5nH5nPCYjpXKi0SDnObbeaAniQEKiiLSmLSbspXdTnMFTIlZMlPdI3ylk9hIKCQHNsGduTYydZjwo4NWcrc2dYBq1Fq3jrPnrfobEAFQqbQt5kiCcf4go6ECD7Ca0XFMskAmksr7-RtReUQ1xEyRYOQlKsJOmp+K6rqTPr8H9ELaTclkg5wQEzRidB5u-Y0q6QgFbAEsuCMuO0xsmFx0BBIOwACIAUQAGX-Gh-03H45ksBFrycGE7zcW1bD8e0In+Pw3G8EggT8M0-Fbc8PGSV8Vw2TAeBqXBulQahfzAJhBg4ABhAB5AA5AAxSoqQAWQAfQA4DQPA7ddwQZCMVRUF7Bw3svXsEFuz8MFMNtC8bRtHQzWHYj1mKMjako6i5Fo+i2HYRjhlYxigP4oCQLAmstzrUA0OcaSSHsZwbSUjwAl83zwiiGJGw8LCwTtU8vGQnI8j9N9im-UzqDnAUSEShjizAYTnOsRB7BHEglLwoqskCWxFJ8ewPJ0bx8tsC1IQhZwdOFNK6MGZKem6LhuhIY46iotrTImdkssciCDRcvcbVRJrwr8fLXHsRTYRIOCau8WqYRxIjfXwLhv3gT4J2KaM5Ey7LIPrAFyqChBLVvEJPGk2w21PFrVyDacsz2G4c2mCN+jOqBrgOEbpXjSavicq6prEhar1tR7IXSaSfVik7AxJH7GjBzLIfOWA6UweUjqMGGof+TzbEKsEMiRdwYUhbtkhw5HnHvXx0g8D7Jy+9d6lxw5-oJy7Kb3B07vsJDkekjwcSyWxeaJfmZ0lf7ZTVZlgcyzWeTJ6GJp3a7kOWu7YjcR6wQCEFAQfbxlaxzMJTDFUuS1hUiZJuADdrWHcoQVtMJxdtWfvaL0hWm2rfcRXHxBR2M2+l2NdVfWxeNuHbW7Xz+xCWJkm8ZE3LbRO1zV12S0jRVzpFwH8F9inM4D03u2Ux6sWvQj2wTjH4qd5PQzrvMG-nYnSYz0TQRNG3gnUyE+zNNv-EevDrUya9mr7kiVexlPRoJxujdE7O7r8gIO+dXtUkyMvVazSfrt8bt7xpgcFttC1nXsROPy-SBH5w1iO6MKwRghAkbH2BSUtHBrTRPeC8rhxKJ30hRKiNF2psEAS3ZCNMkR4R8MOWqfloEIntCvDmnoRzyUIvLRO6VWTYP+F4TCNt0g22REhV6pCYgEJIPVDENsPBpA9GkehmCAHjREtdVmmFPCKy2u6RwcJzaQicMOb0wQ3ALVxNvXSRAmExBUWQvOA5baqXlrbXIuQgA */ createMachine( @@ -59,6 +72,9 @@ export const templateMachine = getTemplateDAUs: { data: TemplateDAUsResponse } + getTemplatePermissions: { + data: AuthorizationResponse + } }, }, initial: "gettingTemplate", @@ -159,6 +175,23 @@ export const templateMachine = }, }, }, + templatePermissions: { + initial: "gettingTemplatePermissions", + states: { + gettingTemplatePermissions: { + invoke: { + src: "getTemplatePermissions", + onDone: { + target: "success", + actions: "assignPermissions", + }, + }, + }, + success: { + type: "final", + }, + }, + }, }, onDone: { target: "loaded", @@ -259,6 +292,14 @@ export const templateMachine = } return getTemplateDAUs(ctx.template.id) }, + getTemplatePermissions: (ctx) => { + if (!ctx.template) { + throw new Error("Template not loaded") + } + return checkAuthorization({ + checks: getPermissionsToCheck(ctx.template.id), + }) + }, }, actions: { assignTemplate: assign({ @@ -279,6 +320,9 @@ export const templateMachine = assignTemplateDAUs: assign({ templateDAUs: (_, event) => event.data, }), + assignPermissions: assign({ + permissions: (_, event) => event.data, + }), assignDeleteTemplateError: assign({ deleteTemplateError: (_, event) => event.data, }), From 993ee32e1bd0b777ea4de2339f5c47c7653f4ee5 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 3 Oct 2022 14:24:44 -0500 Subject: [PATCH 101/138] filter deleted/suspended users for groups (#4343) --- coderd/database/queries.sql.go | 4 ++ coderd/database/queries/groups.sql | 6 ++- enterprise/coderd/groups_test.go | 63 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 67263af867eb4..b8cf9dc3aba86 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -895,6 +895,10 @@ ON users.id = group_members.user_id WHERE group_members.group_id = $1 +AND + users.status = 'active' +AND + users.deleted = 'false' ` func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) { diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index da4565ed0f3ce..c57631279a0ec 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -42,7 +42,11 @@ JOIN ON users.id = group_members.user_id WHERE - group_members.group_id = $1; + group_members.group_id = $1 +AND + users.status = 'active' +AND + users.deleted = 'false'; -- name: GetAllOrganizationMembers :many SELECT diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 72fd434232e38..8e4229076608f 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -301,6 +301,69 @@ func TestGroup(t *testing.T) { require.NoError(t, err) require.Equal(t, group, ggroup) }) + + t.Run("FilterDeletedUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String(), user2.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user1) + require.Contains(t, group.Members, user2) + + err = client.DeleteUser(ctx, user1.ID) + require.NoError(t, err) + + group, err = client.Group(ctx, group.ID) + require.NoError(t, err) + require.NotContains(t, group.Members, user1) + }) + + t.Run("FilterSuspendedUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String(), user2.ID.String()}, + }) + require.NoError(t, err) + require.Len(t, group.Members, 2) + require.Contains(t, group.Members, user1) + require.Contains(t, group.Members, user2) + + user1, err = client.UpdateUserStatus(ctx, user1.ID.String(), codersdk.UserStatusSuspended) + require.NoError(t, err) + + group, err = client.Group(ctx, group.ID) + require.NoError(t, err) + require.Len(t, group.Members, 1) + require.NotContains(t, group.Members, user1) + require.Contains(t, group.Members, user2) + }) } // TODO: test auth. From a52203ddec9a2d94929aab802f264ede1bf17a45 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 3 Oct 2022 14:25:10 -0500 Subject: [PATCH 102/138] rm extraneous filter (#4272) --- enterprise/coderd/templates.go | 9 --------- 1 file changed, 9 deletions(-) diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go index d7a582eadf308..4470d0c52296b 100644 --- a/enterprise/coderd/templates.go +++ b/enterprise/coderd/templates.go @@ -31,15 +31,6 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { return } - users, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, rbac.ActionRead, users) - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching users.", - Detail: err.Error(), - }) - return - } - dbGroups, err := api.Database.GetTemplateGroupRoles(ctx, template.ID) if err != nil { httpapi.InternalServerError(rw, err) From bfa35e3eb403405b65165ae7ee3e58e7c3684ec2 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 3 Oct 2022 16:13:24 -0500 Subject: [PATCH 103/138] merge main into groups (#4349) --- .github/workflows/coder.yaml | 34 +- .github/workflows/dogfood.yaml | 2 +- .github/workflows/stale.yaml | 2 +- cli/portforward.go | 123 ++++++-- cli/portforward_internal_test.go | 90 ++++++ coderd/audit.go | 29 +- coderd/audit_test.go | 15 + coderd/database/databasefake/databasefake.go | 3 +- coderd/database/queries.sql.go | 10 +- coderd/database/queries/provisionerjobs.sql | 3 +- coderd/httpmw/apikey.go | 8 - coderd/rbac/authz.go | 19 +- coderd/rbac/builtin.go | 2 +- coderd/rbac/builtin_test.go | 14 +- coderd/templateversions.go | 10 + coderd/templateversions_test.go | 25 +- coderd/workspacebuilds.go | 47 ++- coderd/workspacebuilds_test.go | 47 +++ codersdk/client.go | 7 - codersdk/workspaceagents.go | 12 +- codersdk/workspacebuilds.go | 16 + docs/networking/port-forwarding.md | 24 +- docs/quickstart.md | 2 +- enterprise/cli/licenses.go | 3 + examples/templates/docker-code-server/main.tf | 2 +- .../templates/docker-image-builds/main.tf | 2 +- .../templates/docker-with-dotfiles/main.tf | 2 +- examples/templates/docker/main.tf | 2 +- go.mod | 24 +- go.sum | 52 ++-- helm/templates/coder.yaml | 1 + helm/values.yaml | 4 + site/package.json | 16 +- site/src/api/typesGenerated.ts | 14 + .../Tooltips/UserRoleHelpTooltip.tsx | 2 +- .../WorkspaceScheduleButton.stories.tsx | 16 + .../WorkspaceScheduleButton.tsx | 89 ++++-- .../WorkspaceScheduleForm.tsx | 1 + .../src/pages/WorkspacePage/WorkspacePage.tsx | 149 ++------- .../WorkspacePage/WorkspaceReadyPage.tsx | 112 +++++++ site/src/testHelpers/entities.ts | 2 + site/src/util/schedule.test.ts | 10 +- site/src/util/schedule.ts | 25 +- .../xServices/workspace/workspaceXService.ts | 24 ++ .../workspaceScheduleBannerXService.ts | 140 ++++++++- site/static/error.html | 135 ++++++++ site/yarn.lock | 294 +++++++----------- 47 files changed, 1121 insertions(+), 544 deletions(-) create mode 100644 cli/portforward_internal_test.go create mode 100644 site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx create mode 100644 site/static/error.html diff --git a/.github/workflows/coder.yaml b/.github/workflows/coder.yaml index 9c4fdb19957c5..f0ba64972a911 100644 --- a/.github/workflows/coder.yaml +++ b/.github/workflows/coder.yaml @@ -64,12 +64,10 @@ jobs: - '**' docs: - 'docs/**' + # For testing: + # - '.github/**' sh: - "**.sh" - go: - - "**.go" - tf: - - "**.tf" ts: - 'site/**' k8s: @@ -94,8 +92,6 @@ jobs: name: style/lint/golangci timeout-minutes: 5 runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.go == 'true' steps: - uses: actions/checkout@v3 - uses: actions/setup-go@v3 @@ -119,8 +115,6 @@ jobs: name: style/lint/shellcheck timeout-minutes: 5 runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.sh == 'true' steps: - uses: actions/checkout@v3 - name: Run ShellCheck @@ -134,8 +128,6 @@ jobs: name: "style/lint/typescript" timeout-minutes: 5 runs-on: ubuntu-latest - needs: changes - if: needs.changes.outputs.ts == 'true' steps: - name: Checkout uses: actions/checkout@v3 @@ -255,8 +247,6 @@ jobs: name: "style/fmt" runs-on: ubuntu-latest timeout-minutes: 5 - needs: changes - if: needs.changes.outputs.sh == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.tf == 'true' steps: - name: Checkout uses: actions/checkout@v3 @@ -290,7 +280,6 @@ jobs: name: "test/go" runs-on: ${{ matrix.os }} timeout-minutes: 20 - needs: changes strategy: matrix: os: @@ -299,36 +288,30 @@ jobs: - windows-2022 steps: - uses: actions/checkout@v3 - if: needs.changes.outputs.go == 'true' - uses: actions/setup-go@v3 - if: needs.changes.outputs.go == 'true' with: go-version: "~1.19" - name: Echo Go Cache Paths - if: needs.changes.outputs.go == 'true' id: go-cache-paths run: | echo "::set-output name=go-build::$(go env GOCACHE)" echo "::set-output name=go-mod::$(go env GOMODCACHE)" - name: Go Build Cache - if: needs.changes.outputs.go == 'true' uses: actions/cache@v3 with: path: ${{ steps.go-cache-paths.outputs.go-build }} key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.**', '**.go') }} - name: Go Mod Cache - if: needs.changes.outputs.go == 'true' uses: actions/cache@v3 with: path: ${{ steps.go-cache-paths.outputs.go-mod }} key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }} - name: Install gotestsum - if: needs.changes.outputs.go == 'true' uses: jaxxstorm/action-install-gh-release@v1.7.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -337,13 +320,11 @@ jobs: tag: v1.7.0 - uses: hashicorp/setup-terraform@v2 - if: needs.changes.outputs.go == 'true' with: terraform_version: 1.1.9 terraform_wrapper: false - name: Test with Mock Database - if: needs.changes.outputs.go == 'true' id: test shell: bash run: | @@ -369,7 +350,7 @@ jobs: # that is no guarantee, see: # https://github.com/codecov/codecov-action/issues/788 continue-on-error: true - if: steps.test.outputs.cover && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork && needs.changes.outputs.go == 'true' + if: steps.test.outputs.cover && github.actor != 'dependabot[bot]' && !github.event.pull_request.head.repo.fork with: token: ${{ secrets.CODECOV_TOKEN }} files: ./gotests.coverage @@ -383,8 +364,6 @@ jobs: # goroutines. Setting this to the timeout +5m should work quite well # even if some of the preceding steps are slow. timeout-minutes: 25 - needs: changes - if: needs.changes.outputs.go == 'true' steps: - uses: actions/checkout@v3 @@ -539,8 +518,6 @@ jobs: name: "test/js" runs-on: ubuntu-latest timeout-minutes: 20 - needs: changes - if: needs.changes.outputs.ts == 'true' steps: - uses: actions/checkout@v3 @@ -579,8 +556,9 @@ jobs: test-e2e: name: "test/e2e/${{ matrix.os }}" - needs: changes - if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.tf == 'true' + needs: + - changes + if: needs.changes.outputs.docs-only == 'false' runs-on: ${{ matrix.os }} timeout-minutes: 20 strategy: diff --git a/.github/workflows/dogfood.yaml b/.github/workflows/dogfood.yaml index 28fe7f6fa9e2e..a5dd0748cd8d3 100644 --- a/.github/workflows/dogfood.yaml +++ b/.github/workflows/dogfood.yaml @@ -19,7 +19,7 @@ jobs: steps: - name: Get branch name id: branch-name - uses: tj-actions/branch-names@v5.4 + uses: tj-actions/branch-names@v6.1 - name: "Branch name to Docker tag name" id: docker-tag-name diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml index ca517f6864593..6819cc3a10b78 100644 --- a/.github/workflows/stale.yaml +++ b/.github/workflows/stale.yaml @@ -13,7 +13,7 @@ jobs: steps: # v5.1.0 has a weird bug that makes stalebot add then remove its own label # https://github.com/actions/stale/pull/775 - - uses: actions/stale@v5.0.0 + - uses: actions/stale@v6.0.0 with: stale-issue-label: stale stale-pr-label: stale diff --git a/cli/portforward.go b/cli/portforward.go index 2511375922979..476809d601558 100644 --- a/cli/portforward.go +++ b/cli/portforward.go @@ -18,6 +18,7 @@ import ( "cdr.dev/slog" "github.com/coder/coder/agent" + "github.com/coder/coder/cli/cliflag" "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) @@ -45,6 +46,10 @@ func portForward() *cobra.Command { Description: "Port forward multiple TCP ports and a UDP port", Command: "coder port-forward --tcp 8080:8080 --tcp 9000:3000 --udp 5353:53", }, + example{ + Description: "Port forward multiple ports (TCP or UDP) in condensed syntax", + Command: "coder port-forward --tcp 8080,9000:3000,9090-9092,10000-10002:10010-10012", + }, ), RunE: func(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(cmd.Context()) @@ -164,8 +169,8 @@ func portForward() *cobra.Command { }, } - cmd.Flags().StringArrayVarP(&tcpForwards, "tcp", "p", []string{}, "Forward a TCP port from the workspace to the local machine") - cmd.Flags().StringArrayVar(&udpForwards, "udp", []string{}, "Forward a UDP port from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols") + cliflag.StringArrayVarP(cmd.Flags(), &tcpForwards, "tcp", "p", "CODER_PORT_FORWARD_TCP", nil, "Forward TCP port(s) from the workspace to the local machine") + cliflag.StringArrayVarP(cmd.Flags(), &udpForwards, "udp", "", "CODER_PORT_FORWARD_UDP", nil, "Forward UDP port(s) from the workspace to the local machine. The UDP connection has TCP-like semantics to support stateful UDP protocols") return cmd } @@ -242,32 +247,40 @@ type portForwardSpec struct { func parsePortForwards(tcpSpecs, udpSpecs []string) ([]portForwardSpec, error) { specs := []portForwardSpec{} - for _, spec := range tcpSpecs { - local, remote, err := parsePortPort(spec) - if err != nil { - return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err) - } + for _, specEntry := range tcpSpecs { + for _, spec := range strings.Split(specEntry, ",") { + ports, err := parseSrcDestPorts(spec) + if err != nil { + return nil, xerrors.Errorf("failed to parse TCP port-forward specification %q: %w", spec, err) + } - specs = append(specs, portForwardSpec{ - listenNetwork: "tcp", - listenAddress: fmt.Sprintf("127.0.0.1:%v", local), - dialNetwork: "tcp", - dialAddress: fmt.Sprintf("127.0.0.1:%v", remote), - }) + for _, port := range ports { + specs = append(specs, portForwardSpec{ + listenNetwork: "tcp", + listenAddress: fmt.Sprintf("127.0.0.1:%v", port.local), + dialNetwork: "tcp", + dialAddress: fmt.Sprintf("127.0.0.1:%v", port.remote), + }) + } + } } - for _, spec := range udpSpecs { - local, remote, err := parsePortPort(spec) - if err != nil { - return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err) - } + for _, specEntry := range udpSpecs { + for _, spec := range strings.Split(specEntry, ",") { + ports, err := parseSrcDestPorts(spec) + if err != nil { + return nil, xerrors.Errorf("failed to parse UDP port-forward specification %q: %w", spec, err) + } - specs = append(specs, portForwardSpec{ - listenNetwork: "udp", - listenAddress: fmt.Sprintf("127.0.0.1:%v", local), - dialNetwork: "udp", - dialAddress: fmt.Sprintf("127.0.0.1:%v", remote), - }) + for _, port := range ports { + specs = append(specs, portForwardSpec{ + listenNetwork: "udp", + listenAddress: fmt.Sprintf("127.0.0.1:%v", port.local), + dialNetwork: "udp", + dialAddress: fmt.Sprintf("127.0.0.1:%v", port.remote), + }) + } + } } // Check for duplicate entries. @@ -295,24 +308,72 @@ func parsePort(in string) (uint16, error) { return uint16(port), nil } -func parsePortPort(in string) (local uint16, remote uint16, err error) { +type parsedSrcDestPort struct { + local, remote uint16 +} + +func parseSrcDestPorts(in string) ([]parsedSrcDestPort, error) { parts := strings.Split(in, ":") if len(parts) > 2 { - return 0, 0, xerrors.Errorf("invalid port specification %q", in) + return nil, xerrors.Errorf("invalid port specification %q", in) } if len(parts) == 1 { // Duplicate the single part parts = append(parts, parts[0]) } + if !strings.Contains(parts[0], "-") { + local, err := parsePort(parts[0]) + if err != nil { + return nil, xerrors.Errorf("parse local port from %q: %w", in, err) + } + remote, err := parsePort(parts[1]) + if err != nil { + return nil, xerrors.Errorf("parse remote port from %q: %w", in, err) + } - local, err = parsePort(parts[0]) + return []parsedSrcDestPort{{local: local, remote: remote}}, nil + } + + local, err := parsePortRange(parts[0]) if err != nil { - return 0, 0, xerrors.Errorf("parse local port from %q: %w", in, err) + return nil, xerrors.Errorf("parse local port range from %q: %w", in, err) } - remote, err = parsePort(parts[1]) + remote, err := parsePortRange(parts[1]) if err != nil { - return 0, 0, xerrors.Errorf("parse remote port from %q: %w", in, err) + return nil, xerrors.Errorf("parse remote port range from %q: %w", in, err) + } + if len(local) != len(remote) { + return nil, xerrors.Errorf("port ranges must be the same length, got %d ports forwarded to %d ports", len(local), len(remote)) + } + var out []parsedSrcDestPort + for i := range local { + out = append(out, parsedSrcDestPort{ + local: local[i], + remote: remote[i], + }) } + return out, nil +} - return local, remote, nil +func parsePortRange(in string) ([]uint16, error) { + parts := strings.Split(in, "-") + if len(parts) != 2 { + return nil, xerrors.Errorf("invalid port range specification %q", in) + } + start, err := parsePort(parts[0]) + if err != nil { + return nil, xerrors.Errorf("parse range start port from %q: %w", in, err) + } + end, err := parsePort(parts[1]) + if err != nil { + return nil, xerrors.Errorf("parse range end port from %q: %w", in, err) + } + if end < start { + return nil, xerrors.Errorf("range end port %v is less than start port %v", end, start) + } + var ports []uint16 + for i := start; i <= end; i++ { + ports = append(ports, i) + } + return ports, nil } diff --git a/cli/portforward_internal_test.go b/cli/portforward_internal_test.go new file mode 100644 index 0000000000000..ad083b8cf0705 --- /dev/null +++ b/cli/portforward_internal_test.go @@ -0,0 +1,90 @@ +package cli + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_parsePortForwards(t *testing.T) { + t.Parallel() + + portForwardSpecToString := func(v []portForwardSpec) (out []string) { + for _, p := range v { + require.Equal(t, p.listenNetwork, p.dialNetwork) + out = append(out, fmt.Sprintf("%s:%s", strings.Replace(p.listenAddress, "127.0.0.1:", "", 1), strings.Replace(p.dialAddress, "127.0.0.1:", "", 1))) + } + return out + } + type args struct { + tcpSpecs []string + udpSpecs []string + } + tests := []struct { + name string + args args + want []string + wantErr bool + }{ + { + name: "TCP mixed ports and ranges", + args: args{ + tcpSpecs: []string{ + "8000,8080:8081,9000-9002,9003-9004:9005-9006", + "10000", + }, + }, + want: []string{ + "8000:8000", + "8080:8081", + "9000:9000", + "9001:9001", + "9002:9002", + "9003:9005", + "9004:9006", + "10000:10000", + }, + }, + { + name: "UDP with port range", + args: args{ + udpSpecs: []string{"8000,8080-8081"}, + }, + want: []string{ + "8000:8000", + "8080:8080", + "8081:8081", + }, + }, + { + name: "Bad port range", + args: args{ + tcpSpecs: []string{"8000-7000"}, + }, + wantErr: true, + }, + { + name: "Bad dest port range", + args: args{ + tcpSpecs: []string{"8080-8081:9080-9082"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := parsePortForwards(tt.args.tcpSpecs, tt.args.udpSpecs) + if (err != nil) != tt.wantErr { + t.Fatalf("parsePortForwards() error = %v, wantErr %v", err, tt.wantErr) + return + } + gotStrings := portForwardSpecToString(got) + require.Equal(t, tt.want, gotStrings) + }) + } +} diff --git a/coderd/audit.go b/coderd/audit.go index c9fbb3a9a8fea..00f1228466a4a 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -259,12 +259,37 @@ func auditSearchQuery(query string) (database.GetAuditLogsOffsetParams, []coders // other parsing. parser := httpapi.NewQueryParamParser() filter := database.GetAuditLogsOffsetParams{ - ResourceType: parser.String(searchParams, "", "resource_type"), + ResourceType: resourceTypeFromString(parser.String(searchParams, "", "resource_type")), ResourceID: parser.UUID(searchParams, uuid.Nil, "resource_id"), - Action: parser.String(searchParams, "", "action"), + Action: actionFromString(parser.String(searchParams, "", "action")), Username: parser.String(searchParams, "", "username"), Email: parser.String(searchParams, "", "email"), } return filter, parser.Errors } + +func resourceTypeFromString(resourceTypeString string) string { + switch codersdk.ResourceType(resourceTypeString) { + case codersdk.ResourceTypeOrganization: + case codersdk.ResourceTypeTemplate: + case codersdk.ResourceTypeTemplateVersion: + case codersdk.ResourceTypeUser: + case codersdk.ResourceTypeWorkspace: + case codersdk.ResourceTypeGitSSHKey: + case codersdk.ResourceTypeAPIKey: + return resourceTypeString + } + return "" +} + +func actionFromString(actionString string) string { + switch codersdk.AuditAction(actionString) { + case codersdk.AuditActionCreate: + case codersdk.AuditActionWrite: + case codersdk.AuditActionDelete: + return actionString + default: + } + return "" +} diff --git a/coderd/audit_test.go b/coderd/audit_test.go index 9368746a88f46..be50503c72719 100644 --- a/coderd/audit_test.go +++ b/coderd/audit_test.go @@ -112,6 +112,21 @@ func TestAuditLogsFilter(t *testing.T) { SearchQuery: "resource_id:" + userResourceID.String(), ExpectedResult: 2, }, + { + Name: "FilterInvalidSingleValue", + SearchQuery: "invalid", + ExpectedResult: 3, + }, + { + Name: "FilterWithInvalidResourceType", + SearchQuery: "resource_type:invalid", + ExpectedResult: 3, + }, + { + Name: "FilterWithInvalidAction", + SearchQuery: "action:invalid", + ExpectedResult: 3, + }, } for _, testCase := range testCases { diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 798a8fc59d2fb..d2e348f310b5a 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2369,6 +2369,7 @@ func (q *fakeQuerier) UpdateProvisionerJobWithCancelByID(_ context.Context, arg continue } job.CanceledAt = arg.CanceledAt + job.CompletedAt = arg.CompletedAt q.provisionerJobs[index] = job return nil } @@ -2944,7 +2945,7 @@ func (q *fakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]d for _, member := range members { for _, user := range q.users { - if user.ID == member.UserID { + if user.ID == member.UserID && user.Status == database.UserStatusActive && !user.Deleted { users = append(users, user) break } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b8cf9dc3aba86..09043f5cf9dbf 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2445,18 +2445,20 @@ const updateProvisionerJobWithCancelByID = `-- name: UpdateProvisionerJobWithCan UPDATE provisioner_jobs SET - canceled_at = $2 + canceled_at = $2, + completed_at = $3 WHERE id = $1 ` type UpdateProvisionerJobWithCancelByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - CanceledAt sql.NullTime `db:"canceled_at" json:"canceled_at"` + ID uuid.UUID `db:"id" json:"id"` + CanceledAt sql.NullTime `db:"canceled_at" json:"canceled_at"` + CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"` } func (q *sqlQuerier) UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error { - _, err := q.db.ExecContext(ctx, updateProvisionerJobWithCancelByID, arg.ID, arg.CanceledAt) + _, err := q.db.ExecContext(ctx, updateProvisionerJobWithCancelByID, arg.ID, arg.CanceledAt, arg.CompletedAt) return err } diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index da4fa6d1824d0..4775d574e2713 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -78,7 +78,8 @@ WHERE UPDATE provisioner_jobs SET - canceled_at = $2 + canceled_at = $2, + completed_at = $3 WHERE id = $1; diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index a6e3bbded712a..da80337f76bf8 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -382,14 +382,6 @@ func apiTokenFromRequest(r *http.Request) string { return cookie.Value } - // TODO: @emyrk in October 2022, remove this oldCookie check. - // This is just to support the old cli for 1 release. Then everyone - // must update. - oldCookie, err := r.Cookie("session_token") - if err == nil && oldCookie.Value != "" { - return oldCookie.Value - } - urlValue := r.URL.Query().Get(codersdk.SessionTokenKey) if urlValue != "" { return urlValue diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 35a12626aa235..d35f00fba82a7 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -38,8 +38,25 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub return objects, nil } objectType := objects[0].RBACObject().Type - filtered := make([]O, 0) + // Running benchmarks on this function, it is **always** faster to call + // auth.ByRoleName on <10 objects. This is because the overhead of + // 'PrepareByRoleName'. Once we cross 10 objects, then it starts to become + // faster + if len(objects) < 10 { + for _, o := range objects { + rbacObj := o.RBACObject() + if rbacObj.Type != objectType { + return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, rbacObj) + } + err := auth.ByRoleName(ctx, subjID, subjRoles, scope, groups, action, o.RBACObject()) + if err == nil { + filtered = append(filtered, o) + } + } + return filtered, nil + } + prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, groups, action, objectType) if err != nil { return nil, xerrors.Errorf("prepare: %w", err) diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 0d4f2154846d1..2fb4e6f251982 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -111,7 +111,7 @@ var ( ResourceTemplate.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}, + ResourceWorkspace.Type: {ActionRead}, // CRUD to provisioner daemons for now. ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, }), diff --git a/coderd/rbac/builtin_test.go b/coderd/rbac/builtin_test.go index 8ea4323f8264a..bf5a54e1a0f9f 100644 --- a/coderd/rbac/builtin_test.go +++ b/coderd/rbac/builtin_test.go @@ -182,15 +182,25 @@ func TestRolePermissions(t *testing.T) { }, }, { - Name: "MyWorkspaceInOrg", + Name: "ReadMyWorkspaceInOrg", // When creating the WithID won't be set, but it does not change the result. - Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}, + Actions: []rbac.Action{rbac.ActionRead}, Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(currentUser.String()), AuthorizeMap: map[bool][]authSubject{ true: {owner, orgMemberMe, orgAdmin, templateAdmin}, false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin}, }, }, + { + Name: "C_RDMyWorkspaceInOrg", + // When creating the WithID won't be set, but it does not change the result. + Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete}, + Resource: rbac.ResourceWorkspace.InOrg(orgID).WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]authSubject{ + true: {owner, orgMemberMe, orgAdmin}, + false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin, templateAdmin}, + }, + }, { Name: "MyWorkspaceInOrgExecution", // When creating the WithID won't be set, but it does not change the result. diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 2116663a913b3..4b2f90fb7ec83 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -92,6 +92,11 @@ func (api *API) patchCancelTemplateVersion(rw http.ResponseWriter, r *http.Reque Time: database.Now(), Valid: true, }, + CompletedAt: sql.NullTime{ + Time: database.Now(), + // If the job is running, don't mark it completed! + Valid: !job.WorkerID.Valid, + }, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -356,6 +361,11 @@ func (api *API) patchTemplateVersionDryRunCancel(rw http.ResponseWriter, r *http Time: database.Now(), Valid: true, }, + CompletedAt: sql.NullTime{ + Time: database.Now(), + // If the job is running, don't mark it completed! + Valid: !job.WorkerID.Valid, + }, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index f51d7c2c46925..3f51c49a2a5dc 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -715,29 +715,12 @@ func TestTemplateVersionDryRun(t *testing.T) { ParameterValues: []codersdk.CreateParameterRequest{}, }) require.NoError(t, err) - - require.Eventually(t, func() bool { - job, err := client.TemplateVersionDryRun(ctx, version.ID, job.ID) - if !assert.NoError(t, err) { - return false - } - - t.Logf("Status: %s", job.Status) - return job.Status == codersdk.ProvisionerJobPending - }, testutil.WaitShort, testutil.IntervalFast) - + require.Equal(t, codersdk.ProvisionerJobPending, job.Status) err = client.CancelTemplateVersionDryRun(ctx, version.ID, job.ID) require.NoError(t, err) - - require.Eventually(t, func() bool { - job, err := client.TemplateVersionDryRun(ctx, version.ID, job.ID) - if !assert.NoError(t, err) { - return false - } - - t.Logf("Status: %s", job.Status) - return job.Status == codersdk.ProvisionerJobCanceling - }, testutil.WaitShort, testutil.IntervalFast) + job, err = client.TemplateVersionDryRun(ctx, version.ID, job.ID) + require.NoError(t, err) + require.Equal(t, codersdk.ProvisionerJobCanceled, job.Status) }) t.Run("AlreadyCompleted", func(t *testing.T) { diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 362fb388c0b57..6ece8d379b153 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -552,6 +552,11 @@ func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Reques Time: database.Now(), Valid: true, }, + CompletedAt: sql.NullTime{ + Time: database.Now(), + // If the job is running, don't mark it completed! + Valid: !job.WorkerID.Valid, + }, }) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -835,7 +840,8 @@ func (api *API) convertWorkspaceBuild( metadata := append(make([]database.WorkspaceResourceMetadatum, 0), metadataByResourceID[resource.ID]...) apiResources = append(apiResources, convertWorkspaceResource(resource, apiAgents, metadata)) } - + apiJob := convertProvisionerJob(job) + transition := codersdk.WorkspaceTransition(build.Transition) return codersdk.WorkspaceBuild{ ID: build.ID, CreatedAt: build.CreatedAt, @@ -846,13 +852,14 @@ func (api *API) convertWorkspaceBuild( WorkspaceName: workspace.Name, TemplateVersionID: build.TemplateVersionID, BuildNumber: build.BuildNumber, - Transition: codersdk.WorkspaceTransition(build.Transition), + Transition: transition, InitiatorID: build.InitiatorID, InitiatorUsername: initiator.Username, - Job: convertProvisionerJob(job), + Job: apiJob, Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()), Reason: codersdk.BuildReason(build.Reason), Resources: apiResources, + Status: convertWorkspaceStatus(apiJob.Status, transition), }, nil } @@ -898,3 +905,37 @@ func convertWorkspaceResource(resource database.WorkspaceResource, agents []code Metadata: convertedMetadata, } } + +func convertWorkspaceStatus(jobStatus codersdk.ProvisionerJobStatus, transition codersdk.WorkspaceTransition) codersdk.WorkspaceStatus { + switch jobStatus { + case codersdk.ProvisionerJobPending: + return codersdk.WorkspaceStatusPending + case codersdk.ProvisionerJobRunning: + switch transition { + case codersdk.WorkspaceTransitionStart: + return codersdk.WorkspaceStatusStarting + case codersdk.WorkspaceTransitionStop: + return codersdk.WorkspaceStatusStopping + case codersdk.WorkspaceTransitionDelete: + return codersdk.WorkspaceStatusDeleting + } + case codersdk.ProvisionerJobSucceeded: + switch transition { + case codersdk.WorkspaceTransitionStart: + return codersdk.WorkspaceStatusRunning + case codersdk.WorkspaceTransitionStop: + return codersdk.WorkspaceStatusStopped + case codersdk.WorkspaceTransitionDelete: + return codersdk.WorkspaceStatusDeleted + } + case codersdk.ProvisionerJobCanceling: + return codersdk.WorkspaceStatusCanceling + case codersdk.ProvisionerJobCanceled: + return codersdk.WorkspaceStatusCanceled + case codersdk.ProvisionerJobFailed: + return codersdk.WorkspaceStatusFailed + } + + // return error status since we should never get here + return codersdk.WorkspaceStatusFailed +} diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index e710962a9ca96..61ed5f984062a 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -485,3 +485,50 @@ func TestWorkspaceBuildState(t *testing.T) { require.NoError(t, err) require.Equal(t, wantState, gotState) } + +func TestWorkspaceBuildStatus(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client, closeDaemon, api := coderdtest.NewWithAPI(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + closeDaemon.Close() + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + + // initial returned state is "pending" + require.EqualValues(t, codersdk.WorkspaceStatusPending, workspace.LatestBuild.Status) + + closeDaemon = coderdtest.NewProvisionerDaemon(t, api) + // after successful build is "running" + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + workspace, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) + + // after successful stop is "stopped" + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceStatusStopped, workspace.LatestBuild.Status) + + _ = closeDaemon.Close() + // after successful cancel is "canceled" + build = coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStart) + err = client.CancelWorkspaceBuild(ctx, build.ID) + require.NoError(t, err) + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceStatusCanceled, workspace.LatestBuild.Status) + + _ = coderdtest.NewProvisionerDaemon(t, api) + // after successful delete is "deleted" + build = coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionDelete) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, build.ID) + workspace, err = client.DeletedWorkspace(ctx, workspace.ID) + require.NoError(t, err) + require.EqualValues(t, codersdk.WorkspaceStatusDeleted, workspace.LatestBuild.Status) +} diff --git a/codersdk/client.go b/codersdk/client.go index 3c0e6a7b0d3ce..90216dcdd8e26 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -83,13 +83,6 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac } req.Header.Set(SessionCustomHeader, c.SessionToken) - // Delete this custom cookie set in November 2022. This is just to remain - // backwards compatible with older versions of Coder. - req.AddCookie(&http.Cookie{ - Name: "session_token", - Value: c.SessionToken, - }) - if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 48c5743b7f894..6979aaf46bd71 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -331,11 +331,8 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg // Need to disable compression to avoid a data-race. CompressionMode: websocket.CompressionDisabled, }) - if errors.Is(err, context.Canceled) { - return - } if isFirst { - if res.StatusCode == http.StatusConflict { + if err != nil && res.StatusCode == http.StatusConflict { first <- readBodyAsError(res) return } @@ -343,13 +340,12 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg close(first) } if err != nil { + if errors.Is(err, context.Canceled) { + return + } logger.Debug(ctx, "failed to dial", slog.Error(err)) continue } - if isFirst { - isFirst = false - close(first) - } sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error { return conn.UpdateNodes(node) }) diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index cc8cdbf082f74..725f73a5053cd 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -19,6 +19,21 @@ const ( WorkspaceTransitionDelete WorkspaceTransition = "delete" ) +type WorkspaceStatus string + +const ( + WorkspaceStatusPending WorkspaceStatus = "pending" + WorkspaceStatusStarting WorkspaceStatus = "starting" + WorkspaceStatusRunning WorkspaceStatus = "running" + WorkspaceStatusStopping WorkspaceStatus = "stopping" + WorkspaceStatusStopped WorkspaceStatus = "stopped" + WorkspaceStatusFailed WorkspaceStatus = "failed" + WorkspaceStatusCanceling WorkspaceStatus = "canceling" + WorkspaceStatusCanceled WorkspaceStatus = "canceled" + WorkspaceStatusDeleting WorkspaceStatus = "deleting" + WorkspaceStatusDeleted WorkspaceStatus = "deleted" +) + type BuildReason string const ( @@ -52,6 +67,7 @@ type WorkspaceBuild struct { Reason BuildReason `db:"reason" json:"reason"` Resources []WorkspaceResource `json:"resources"` Deadline NullTime `json:"deadline,omitempty"` + Status WorkspaceStatus `json:"status"` } // WorkspaceBuild returns a single workspace build for a workspace. diff --git a/docs/networking/port-forwarding.md b/docs/networking/port-forwarding.md index c6147501d7001..7d9d11e17c808 100644 --- a/docs/networking/port-forwarding.md +++ b/docs/networking/port-forwarding.md @@ -12,14 +12,34 @@ There are three ways to forward ports in Coder: The `coder port-forward` command is generally more performant. -## coder port-forward +## The `coder port-forward` command -Forward the remote TCP port `8080` to local port `8000` like so: +This command can be used to forward TCP or UDP ports from the remote +workspace so they can be accessed locally. Both the TCP and UDP command +line flags (`--tcp` and `--udp`) can be given once or multiple times. + +The supported syntax variations for the `--tcp` and `--udp` flag are: + +- Single port with optional remote port: `local_port[:remote_port]` +- Comma separation `local_port1,local_port2` +- Port ranges `start_port-end_port` +- Any combination of the above + +### Examples + +Forward the remote TCP port `8080` to local port `8000`: ```console coder port-forward myworkspace --tcp 8000:8080 ``` +Forward the remote TCP port `3000` and all ports from `9990` to `9999` +to their respective local ports. + +```console +coder port-forward myworkspace --tcp 3000,9990-9999 +``` + For more examples, see `coder port-forward --help`. ## Dashboard diff --git a/docs/quickstart.md b/docs/quickstart.md index 40c0de39840e8..ed599293800a1 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -44,7 +44,7 @@ Connect to your workspace via SSH: coder ssh ``` -To access your workspace in the Coder dashboard, navigate to the [configured access URL](./install/configure.md), +To access your workspace in the Coder dashboard, navigate to the [configured access URL](../admin/configure#access-url), and log in with the owner credentials provided to you by Coder. ![Coder Web UI with code-server](./images/code-server.png) diff --git a/enterprise/cli/licenses.go b/enterprise/cli/licenses.go index e1cc4992387ea..024253b3ee213 100644 --- a/enterprise/cli/licenses.go +++ b/enterprise/cli/licenses.go @@ -24,6 +24,9 @@ func licenses() *cobra.Command { Short: "Add, delete, and list licenses", Use: "licenses", Aliases: []string{"license"}, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, } cmd.AddCommand( licenseAdd(), diff --git a/examples/templates/docker-code-server/main.tf b/examples/templates/docker-code-server/main.tf index 1c16784a9cd74..242d0eb956504 100644 --- a/examples/templates/docker-code-server/main.tf +++ b/examples/templates/docker-code-server/main.tf @@ -61,7 +61,7 @@ resource "docker_container" "workspace" { hostname = lower(data.coder_workspace.me.name) dns = ["1.1.1.1"] # Use the docker gateway if the access URL is 127.0.0.1 - entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "127.0.0.1", "host.docker.internal")] + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] host { host = "host.docker.internal" diff --git a/examples/templates/docker-image-builds/main.tf b/examples/templates/docker-image-builds/main.tf index 556e1f2a21a47..fdfad1f104bb7 100644 --- a/examples/templates/docker-image-builds/main.tf +++ b/examples/templates/docker-image-builds/main.tf @@ -90,7 +90,7 @@ resource "docker_container" "workspace" { hostname = lower(data.coder_workspace.me.name) dns = ["1.1.1.1"] # Use the docker gateway if the access URL is 127.0.0.1 - command = ["sh", "-c", replace(coder_agent.main.init_script, "127.0.0.1", "host.docker.internal")] + command = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] host { host = "host.docker.internal" diff --git a/examples/templates/docker-with-dotfiles/main.tf b/examples/templates/docker-with-dotfiles/main.tf index 594cdb72d39e4..6d9e76195b77c 100644 --- a/examples/templates/docker-with-dotfiles/main.tf +++ b/examples/templates/docker-with-dotfiles/main.tf @@ -57,7 +57,7 @@ resource "docker_container" "workspace" { name = "coder-${data.coder_workspace.me.owner}-${lower(data.coder_workspace.me.name)}" dns = ["1.1.1.1"] # Refer to Docker host when Coder is on localhost - command = ["sh", "-c", replace(coder_agent.main.init_script, "127.0.0.1", "host.docker.internal")] + command = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] env = ["CODER_AGENT_TOKEN=${coder_agent.main.token}"] host { host = "host.docker.internal" diff --git a/examples/templates/docker/main.tf b/examples/templates/docker/main.tf index bace09af9b674..b690a00223bf4 100644 --- a/examples/templates/docker/main.tf +++ b/examples/templates/docker/main.tf @@ -83,7 +83,7 @@ resource "docker_container" "workspace" { "sh", "-c", < github.com/coder/ssh v0.0.0-20220811105153- require ( cdr.dev/slog v1.4.2-0.20220525200111-18dce5c2cd5f - cloud.google.com/go/compute v1.9.0 + cloud.google.com/go/compute v1.10.0 github.com/AlecAivazis/survey/v2 v2.3.5 github.com/andybalholm/brotli v1.0.4 github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 @@ -83,7 +83,7 @@ require ( github.com/go-ping/ping v1.1.0 github.com/go-playground/validator/v10 v10.11.0 github.com/gofrs/flock v0.8.1 - github.com/gohugoio/hugo v0.101.0 + github.com/gohugoio/hugo v0.104.2 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.4.2 github.com/golang-migrate/migrate/v4 v4.15.2 @@ -98,12 +98,12 @@ require ( github.com/hashicorp/terraform-json v0.14.0 github.com/hashicorp/yamux v0.0.0-20220718163420-dd80a7ee44ce github.com/imulab/go-scim/pkg/v2 v2.2.0 - github.com/jedib0t/go-pretty/v6 v6.3.5 + github.com/jedib0t/go-pretty/v6 v6.4.0 github.com/justinas/nosurf v1.1.1 github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f github.com/klauspost/compress v1.15.9 github.com/lib/pq v1.10.6 - github.com/mattn/go-isatty v0.0.14 + github.com/mattn/go-isatty v0.0.16 github.com/mitchellh/go-wordwrap v1.0.1 github.com/mitchellh/mapstructure v1.5.0 github.com/moby/moby v20.10.18+incompatible @@ -136,16 +136,16 @@ require ( golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 - golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 + golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f - golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 golang.org/x/text v0.3.8-0.20211105212822-18b340fc7af2 golang.org/x/tools v0.1.11 golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220504211119-3d4a969bb56b - google.golang.org/api v0.95.0 + google.golang.org/api v0.98.0 google.golang.org/protobuf v1.28.1 gopkg.in/natefinch/lumberjack.v2 v2.0.0 gopkg.in/yaml.v3 v3.0.1 @@ -244,7 +244,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.0.3-0.20220114050600-8b9d41f48198 // indirect github.com/opencontainers/runc v1.1.2 // indirect - github.com/pelletier/go-toml/v2 v2.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.4 // indirect github.com/pion/transport v0.13.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -264,7 +264,7 @@ require ( github.com/tailscale/netlink v1.1.1-0.20211101221916-cabfb018fe85 // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect github.com/tcnksm/go-httpstat v0.2.0 // indirect - github.com/tdewolff/parse/v2 v2.6.0 // indirect + github.com/tdewolff/parse/v2 v2.6.3 // indirect github.com/u-root/uio v0.0.0-20220204230159-dac05f7d2cb4 // indirect github.com/vishvananda/netlink v1.1.1-0.20211118161826-650dca95af54 // indirect github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect @@ -274,7 +274,7 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/yashtewari/glob-intersection v0.1.0 // indirect - github.com/yuin/goldmark v1.4.12 // indirect + github.com/yuin/goldmark v1.4.15 // indirect github.com/zclconf/go-cty v1.10.0 // indirect github.com/zeebo/errs v1.3.0 // indirect go.opencensus.io v0.23.0 // indirect @@ -283,12 +283,12 @@ require ( go.opentelemetry.io/proto/otlp v0.19.0 // indirect go4.org/mem v0.0.0-20210711025021-927187094b94 // indirect go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf - golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b // indirect + golang.org/x/net v0.0.0-20220909164309-bea034e7d591 // indirect golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/appengine v1.6.7 // indirect - google.golang.org/genproto v0.0.0-20220804142021-4e6b2dfa6612 // indirect + google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de // indirect google.golang.org/grpc v1.49.0 // indirect gopkg.in/square/go-jose.v2 v2.6.0 gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index b433f8f097c58..2365c5b1e0781 100644 --- a/go.sum +++ b/go.sum @@ -50,8 +50,8 @@ cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6m cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s= cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= -cloud.google.com/go/compute v1.9.0 h1:ED/FP4xv8GJw63v556/ASNc1CeeLUO2Bs8nzaHchkHg= -cloud.google.com/go/compute v1.9.0/go.mod h1:lWv1h/zUWTm/LozzfTJhBSkd6ShQq8la8VeeuOEGxfY= +cloud.google.com/go/compute v1.10.0 h1:aoLIYaA1fX3ywihqpBk2APQKOo20nXsp1GEZQbx5Jk4= +cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= @@ -769,8 +769,8 @@ github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXP github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/gohugoio/hugo v0.101.0 h1:IARZnjaXCak6+x0jG9wLw7ARjB4RAu6i/5G1r0zKjFw= -github.com/gohugoio/hugo v0.101.0/go.mod h1:sqCS5HTRJmPD6ZHqIy8NVfTwWyhaPmN6gsiIP/UUD6M= +github.com/gohugoio/hugo v0.104.2 h1:IdLcaRMfAQv9wGz8Qw9ARmOkvL3W77K/DOp2DaUYYTw= +github.com/gohugoio/hugo v0.104.2/go.mod h1:8iVWX7s/T7lbNtBWFdwBnn8XfbOfBJ9zWVrskeMWyiU= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= @@ -1083,8 +1083,8 @@ github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dv github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jedib0t/go-pretty/v6 v6.3.5 h1:B6RuZT3Gz0NvFwADctw+gZn3cSA+jIHykXyd72zPgOY= -github.com/jedib0t/go-pretty/v6 v6.3.5/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= +github.com/jedib0t/go-pretty/v6 v6.4.0 h1:YlI/2zYDrweA4MThiYMKtGRfT+2qZOO65ulej8GTcVI= +github.com/jedib0t/go-pretty/v6 v6.4.0/go.mod h1:MgmISkTWDSFu0xOqiZ0mKNntMQ2mDgOcwOkwBEkMDJI= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/jgautheron/goconst v1.5.1/go.mod h1:aAosetZ5zaeC/2EfMeRswtxUFBpe2Hr7HzkgX4fanO4= @@ -1262,8 +1262,9 @@ github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcME github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= @@ -1480,8 +1481,8 @@ github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAv github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pelletier/go-toml/v2 v2.0.2 h1:+jQXlF3scKIcSEKkdHzXhCTDLPFi5r1wnK6yPS+49Gw= -github.com/pelletier/go-toml/v2 v2.0.2/go.mod h1:MovirKjgVRESsAvNZlAjtFwV867yGuwRkXbG66OzopI= +github.com/pelletier/go-toml/v2 v2.0.4 h1:MHHO+ZUPwPZQ6BmnnT81iQg5cuurp78CRH7rNsguSMk= +github.com/pelletier/go-toml/v2 v2.0.4/go.mod h1:OMHamSCAODeSsVrwwvcJOaoN0LIUIaFVNZzmWyNfXas= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/phayes/checkstyle v0.0.0-20170904204023-bfd46e6a821d/go.mod h1:3OzsM7FXDQlpCiw2j81fOmAwQLnZnLGXVKUzeKQXIAw= github.com/phpdave11/gofpdf v1.4.2/go.mod h1:zpO6xFn9yxo3YLyMvW8HcKWVdbNqgIfOOp2dXMnm1mY= @@ -1584,7 +1585,7 @@ github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= -github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= @@ -1696,7 +1697,6 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.7.4/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -1722,10 +1722,10 @@ github.com/tchap/go-patricia v2.2.6+incompatible/go.mod h1:bmLyhP68RS6kStMGxByiQ github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= github.com/tdakkota/asciicheck v0.0.0-20200416200610-e657995f937b/go.mod h1:yHp0ai0Z9gUljN3o0xMhYJnH/IcvkdTBOX2fmJ93JEM= -github.com/tdewolff/parse/v2 v2.6.0 h1:f2D7w32JtqjCv6SczWkfwK+m15et42qEtDnZXHoNY70= -github.com/tdewolff/parse/v2 v2.6.0/go.mod h1:WzaJpRSbwq++EIQHYIRTpbYKNA3gn9it1Ik++q4zyho= -github.com/tdewolff/test v1.0.6 h1:76mzYJQ83Op284kMT+63iCNCI7NEERsIN8dLM+RiKr4= -github.com/tdewolff/test v1.0.6/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/tdewolff/parse/v2 v2.6.3 h1:O5rshbkaRmpRtD7k2lG65bEJpcfUMNg5Cx2uRKWVsI8= +github.com/tdewolff/parse/v2 v2.6.3/go.mod h1:woz0cgbLwFdtbjJu8PIKxhW05KplTFQkOdX78o+Jgrs= +github.com/tdewolff/test v1.0.7 h1:8Vs0142DmPFW/bQeHRP3MV19m1gvndjUb1sn8yy74LM= +github.com/tdewolff/test v1.0.7/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= github.com/tenntenn/modver v1.0.1/go.mod h1:bePIyQPb7UeioSRkw3Q0XeMhYZSMx9B8ePqg6SAMGH0= github.com/tenntenn/text/transform v0.0.0-20200319021203-7eef512accb3/go.mod h1:ON8b8w4BN/kE1EOhwT0o+d62W65a6aPw1nouo9LMgyY= github.com/tetafro/godot v1.4.11/go.mod h1:LR3CJpxDVGlYOWn3ZZg1PgNZdTUvzsZWu8xaEohUpn8= @@ -1818,8 +1818,8 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0= -github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.4.15 h1:CFa84T0goNn/UIXYS+dmjjVxMyTAvpOmzld40N/nfK0= +github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= @@ -2099,8 +2099,9 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220531201128-c960675eff93/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b h1:ZmngSVLe/wycRns9MKikG9OWIEjGcGAkacif7oYQaUY= golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI= +golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -2124,8 +2125,9 @@ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE= -golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 h1:2o1E+E8TpNLklK9nHiPiK1uzIYrIHt+cQx3ynCwq9V8= golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= +golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 h1:lxqLZaMad/dJHMFZH0NiNpiEZI/nhgWhe4wgzpE+MuA= +golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -2293,10 +2295,10 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220608164250-635b8c9b7f68/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 h1:WIoqL4EROvwiPdUtaip4VcDdpZ4kha7wBWZrbVKCIZg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -2514,8 +2516,8 @@ google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69 google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw= google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg= google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o= -google.golang.org/api v0.95.0 h1:d1c24AAS01DYqXreBeuVV7ewY/U8Mnhh47pwtsgVtYg= -google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI= +google.golang.org/api v0.98.0 h1:yxZrcxXESimy6r6mdL5Q6EnZwmewDJK2dVg3g75s5Dg= +google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= google.golang.org/appengine v1.0.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -2624,8 +2626,8 @@ google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA= -google.golang.org/genproto v0.0.0-20220804142021-4e6b2dfa6612 h1:NX3L5YesD5qgxxrPHdKqHH38Ao0AG6poRXG+JljPsGU= -google.golang.org/genproto v0.0.0-20220804142021-4e6b2dfa6612/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc= +google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de h1:5ANeKFmGdtiputJJYeUVg8nTGA/1bEirx4CgzcnPSx8= +google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo= google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= diff --git a/helm/templates/coder.yaml b/helm/templates/coder.yaml index 06d455761eab0..60c55f77566e9 100644 --- a/helm/templates/coder.yaml +++ b/helm/templates/coder.yaml @@ -11,6 +11,7 @@ metadata: name: coder labels: {{- include "coder.labels" . | nindent 4 }} + annotations: {{ toYaml .Values.coder.annotations | nindent 4}} spec: # NOTE: this is currently not used as coder v2 does not support high # availability yet. diff --git a/helm/values.yaml b/helm/values.yaml index 23ca6cb495daa..567ecaaf0dd92 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -18,6 +18,10 @@ coder: # https://kubernetes.io/docs/concepts/containers/images/#image-pull-policy pullPolicy: IfNotPresent + # coder.annotations -- The Deployment annotations. See: + # https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ + annotations: {} + # coder.serviceAccount -- Configuration for the automatically created service # account. Creation of the service account cannot be disabled. serviceAccount: diff --git a/site/package.json b/site/package.json index 0091b7e0d9dab..8618c728e636a 100644 --- a/site/package.json +++ b/site/package.json @@ -38,7 +38,7 @@ "@xstate/react": "3.0.1", "axios": "0.26.1", "can-ndjson-stream": "1.0.2", - "chart.js": "3.5.0", + "chart.js": "3.9.1", "chartjs-adapter-date-fns": "2.0.0", "cron-parser": "4.6.0", "cronstrue": "2.11.0", @@ -57,7 +57,7 @@ "react-helmet-async": "1.3.0", "react-i18next": "11.18.4", "react-markdown": "8.0.3", - "react-router-dom": "6.3.0", + "react-router-dom": "6.4.1", "react-syntax-highlighter": "15.5.0", "remark-gfm": "3.0.1", "sourcemapped-stacktrace": "1.1.11", @@ -75,7 +75,7 @@ "yup": "0.32.11" }, "devDependencies": { - "@playwright/test": "1.25.1", + "@playwright/test": "1.26.1", "@storybook/addon-actions": "6.5.9", "@storybook/addon-essentials": "6.5.12", "@storybook/addon-links": "6.5.9", @@ -93,12 +93,12 @@ "@types/superagent": "4.1.15", "@types/ua-parser-js": "0.7.36", "@types/uuid": "8.3.4", - "@typescript-eslint/eslint-plugin": "5.36.1", - "@typescript-eslint/parser": "5.36.2", + "@typescript-eslint/eslint-plugin": "5.38.1", + "@typescript-eslint/parser": "5.38.1", "@xstate/cli": "0.3.0", "canvas": "2.10.0", - "chromatic": "6.9.0", - "eslint": "8.23.0", + "chromatic": "6.10.1", + "eslint": "8.24.0", "eslint-config-prettier": "8.5.0", "eslint-import-resolver-alias": "1.1.2", "eslint-import-resolver-typescript": "3.5.0", @@ -109,7 +109,7 @@ "eslint-plugin-no-storage": "1.0.2", "eslint-plugin-react": "7.31.1", "eslint-plugin-react-hooks": "4.6.0", - "eslint-plugin-unicorn": "43.0.2", + "eslint-plugin-unicorn": "44.0.0", "jest": "27.5.1", "jest-canvas-mock": "2.4.0", "jest-junit": "14.0.0", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 1e43dc93aaeba..5871d5cb5e71a 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -666,6 +666,7 @@ export interface WorkspaceBuild { readonly reason: BuildReason readonly resources: WorkspaceResource[] readonly deadline?: string + readonly status: WorkspaceStatus } // From codersdk/workspaces.go @@ -780,5 +781,18 @@ export type WorkspaceAgentStatus = "connected" | "connecting" | "disconnected" // From codersdk/workspaceapps.go export type WorkspaceAppHealth = "disabled" | "healthy" | "initializing" | "unhealthy" +// From codersdk/workspacebuilds.go +export type WorkspaceStatus = + | "canceled" + | "canceling" + | "deleted" + | "deleting" + | "failed" + | "pending" + | "running" + | "starting" + | "stopped" + | "stopping" + // From codersdk/workspacebuilds.go export type WorkspaceTransition = "delete" | "start" | "stop" diff --git a/site/src/components/Tooltips/UserRoleHelpTooltip.tsx b/site/src/components/Tooltips/UserRoleHelpTooltip.tsx index 674e3cb96baf6..232c56ab60567 100644 --- a/site/src/components/Tooltips/UserRoleHelpTooltip.tsx +++ b/site/src/components/Tooltips/UserRoleHelpTooltip.tsx @@ -21,7 +21,7 @@ export const UserRoleHelpTooltip: FC = () => { {Language.title} {Language.text} - + {Language.link} diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx index 4ba95a70dc3de..1e2bb06b33caa 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.stories.tsx @@ -72,6 +72,22 @@ WorkspaceOffLong.args = { }, } +export const WorkspaceOn = Template.bind({}) +WorkspaceOn.args = { + deadlineMinusEnabled: () => true, + deadlinePlusEnabled: () => true, + workspace: { + ...Mocks.MockWorkspace, + + latest_build: { + ...Mocks.MockWorkspaceBuild, + transition: "start", + deadline: "2022-05-17T23:59:00.00Z", + }, + ttl_ms: 2 * 365 * 24 * 60 * 60 * 1000, // 2 years + }, +} + export const CannotEdit = Template.bind({}) CannotEdit.args = { workspace: { diff --git a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx index e264321d377a9..e3ef8211d3ea1 100644 --- a/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx +++ b/site/src/components/WorkspaceScheduleButton/WorkspaceScheduleButton.tsx @@ -31,6 +31,16 @@ export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => { return isWorkspaceOn(workspace) && Boolean(workspace.latest_build.deadline) } +export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => { + if (shouldDisplayPlusMinus(workspace)) { + return true + } + if (isWorkspaceOn(workspace)) { + return false + } + return Boolean(workspace.autostart_schedule) +} + export interface WorkspaceScheduleButtonProps { workspace: Workspace onDeadlinePlus: () => void @@ -60,33 +70,35 @@ export const WorkspaceScheduleButton: React.FC = ( return ( - - - {canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && ( - - - - - - - - - - - - - )} - + {shouldDisplayScheduleLabel(workspace) && ( + + + {canUpdateWorkspace && shouldDisplayPlusMinus(workspace) && ( + + + + + + + + + + + + + )} + + )} <> @@ -124,8 +138,8 @@ const useStyles = makeStyles((theme) => ({ wrapper: { display: "inline-flex", alignItems: "center", - border: `1px solid ${theme.palette.divider}`, borderRadius: `${theme.shape.borderRadius}px`, + border: `1px solid ${theme.palette.divider}`, [theme.breakpoints.down("sm")]: { flexDirection: "column", @@ -153,15 +167,22 @@ const useStyles = makeStyles((theme) => ({ }, scheduleButton: { border: "none", - borderLeft: `1px solid ${theme.palette.divider}`, - borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, + borderRadius: `${theme.shape.borderRadius}px`, flexShrink: 0, + "&.label": { + borderLeft: `1px solid ${theme.palette.divider}`, + borderRadius: `0px ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0px`, + }, + [theme.breakpoints.down("sm")]: { width: "100%", - borderLeft: 0, - borderTop: `1px solid ${theme.palette.divider}`, - borderRadius: `0 0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px`, + + "&.label": { + borderRadius: `0 0 ${theme.shape.borderRadius}px ${theme.shape.borderRadius}px`, + borderLeft: 0, + borderTop: `1px solid ${theme.palette.divider}`, + }, }, }, iconButton: { diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index 106a653ecb66e..05f225813d371 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -309,6 +309,7 @@ export const WorkspaceScheduleForm: FC } label={Language.stopSwitch} diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 20259771298e5..7daa934990507 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,52 +1,21 @@ import { makeStyles } from "@material-ui/core/styles" -import { useActor, useMachine, useSelector } from "@xstate/react" -import { FeatureNames } from "api/types" -import dayjs from "dayjs" -import minMax from "dayjs/plugin/minMax" -import { FC, useContext, useEffect } from "react" -import { Helmet } from "react-helmet-async" -import { useTranslation } from "react-i18next" +import { useMachine } from "@xstate/react" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { FC, useEffect } from "react" import { useParams } from "react-router-dom" -import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" -import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" -import { Workspace, WorkspaceErrors } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" -import { pageTitle } from "../../util/page" -import { canExtendDeadline, canReduceDeadline, maxDeadline, minDeadline } from "../../util/schedule" -import { getFaviconByStatus } from "../../util/workspace" -import { XServiceContext } from "../../xServices/StateContext" import { workspaceMachine } from "../../xServices/workspace/workspaceXService" -import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService" - -dayjs.extend(minMax) +import { WorkspaceReadyPage } from "./WorkspaceReadyPage" export const WorkspacePage: FC = () => { const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams() const username = firstOrItem(usernameQueryParam, null) const workspaceName = firstOrItem(workspaceQueryParam, null) - const { t } = useTranslation("workspacePage") - const xServices = useContext(XServiceContext) - const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility) const [workspaceState, workspaceSend] = useMachine(workspaceMachine) - const { - workspace, - getWorkspaceError, - template, - getTemplateWarning, - refreshWorkspaceWarning, - builds, - getBuildsError, - permissions, - checkPermissionsError, - buildError, - cancellationError, - applicationsHost, - } = workspaceState.context - const canUpdateWorkspace = Boolean(permissions?.updateWorkspace) - const [bannerState, bannerSend] = useMachine(workspaceScheduleBannerMachine) - const [buildInfoState] = useActor(xServices.buildInfoXService) + const { workspace, getWorkspaceError, getTemplateWarning, checkPermissionsError } = + workspaceState.context const styles = useStyles() /** @@ -57,95 +26,23 @@ export const WorkspacePage: FC = () => { username && workspaceName && workspaceSend({ type: "GET_WORKSPACE", username, workspaceName }) }, [username, workspaceName, workspaceSend]) - if (workspaceState.matches("error")) { - return ( -
- {Boolean(getWorkspaceError) && } - {Boolean(getTemplateWarning) && } - {Boolean(checkPermissionsError) && } -
- ) - } else if (!workspace || !permissions) { - return - } else if (!template) { - return - } else { - const deadline = dayjs(workspace.latest_build.deadline).utc() - const favicon = getFaviconByStatus(workspace.latest_build) - return ( - <> - - {pageTitle(`${workspace.owner_name}/${workspace.name}`)} - - - - - { - bannerSend({ - type: "UPDATE_DEADLINE", - workspaceId: workspace.id, - newDeadline: dayjs.min(deadline.add(4, "hours"), maxDeadline(workspace, template)), - }) - }, - }} - scheduleProps={{ - onDeadlineMinus: () => { - bannerSend({ - type: "UPDATE_DEADLINE", - workspaceId: workspace.id, - newDeadline: dayjs.max(deadline.add(-1, "hours"), minDeadline()), - }) - }, - onDeadlinePlus: () => { - bannerSend({ - type: "UPDATE_DEADLINE", - workspaceId: workspace.id, - newDeadline: dayjs.min(deadline.add(1, "hours"), maxDeadline(workspace, template)), - }) - }, - deadlineMinusEnabled: () => { - return canReduceDeadline(deadline) - }, - deadlinePlusEnabled: () => { - return canExtendDeadline(deadline, workspace, template) - }, - }} - isUpdating={workspaceState.hasTag("updating")} - workspace={workspace} - handleStart={() => workspaceSend("START")} - handleStop={() => workspaceSend("STOP")} - handleDelete={() => workspaceSend("ASK_DELETE")} - handleUpdate={() => workspaceSend("UPDATE")} - handleCancel={() => workspaceSend("CANCEL")} - resources={workspace.latest_build.resources} - builds={builds} - canUpdateWorkspace={canUpdateWorkspace} - hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]} - workspaceErrors={{ - [WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning, - [WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError, - [WorkspaceErrors.BUILD_ERROR]: buildError, - [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError, - }} - buildInfo={buildInfoState.context.buildInfo} - applicationsHost={applicationsHost} - /> - workspaceSend("CANCEL_DELETE")} - onConfirm={() => { - workspaceSend("DELETE") - }} - /> - - ) - } + return ( + + +
+ {Boolean(getWorkspaceError) && } + {Boolean(getTemplateWarning) && } + {Boolean(checkPermissionsError) && } +
+
+ + + + + + +
+ ) } const useStyles = makeStyles((theme) => ({ diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx new file mode 100644 index 0000000000000..25dad00ca3181 --- /dev/null +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -0,0 +1,112 @@ +import { useActor, useSelector } from "@xstate/react" +import { FeatureNames } from "api/types" +import dayjs from "dayjs" +import { useContext } from "react" +import { Helmet } from "react-helmet-async" +import { useTranslation } from "react-i18next" +import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" +import { StateFrom } from "xstate" +import { DeleteDialog } from "../../components/Dialogs/DeleteDialog/DeleteDialog" +import { Workspace, WorkspaceErrors } from "../../components/Workspace/Workspace" +import { pageTitle } from "../../util/page" +import { getFaviconByStatus } from "../../util/workspace" +import { XServiceContext } from "../../xServices/StateContext" +import { WorkspaceEvent, workspaceMachine } from "../../xServices/workspace/workspaceXService" + +interface WorkspaceReadyPageProps { + workspaceState: StateFrom + workspaceSend: (event: WorkspaceEvent) => void +} + +export const WorkspaceReadyPage = ({ + workspaceState, + workspaceSend, +}: WorkspaceReadyPageProps): JSX.Element => { + const [bannerState, bannerSend] = useActor(workspaceState.children["scheduleBannerMachine"]) + const xServices = useContext(XServiceContext) + const featureVisibility = useSelector(xServices.entitlementsXService, selectFeatureVisibility) + const [buildInfoState] = useActor(xServices.buildInfoXService) + const { + workspace, + refreshWorkspaceWarning, + builds, + getBuildsError, + buildError, + cancellationError, + applicationsHost, + permissions, + } = workspaceState.context + if (workspace === undefined) { + throw Error("Workspace is undefined") + } + const canUpdateWorkspace = Boolean(permissions?.updateWorkspace) + const { t } = useTranslation("workspacePage") + const favicon = getFaviconByStatus(workspace.latest_build) + + return ( + <> + + {pageTitle(`${workspace.owner_name}/${workspace.name}`)} + + + + + { + bannerSend({ + type: "INCREASE_DEADLINE", + hours: 4, + }) + }, + }} + scheduleProps={{ + onDeadlineMinus: () => { + bannerSend({ + type: "DECREASE_DEADLINE", + hours: 1, + }) + }, + onDeadlinePlus: () => { + bannerSend({ + type: "INCREASE_DEADLINE", + hours: 1, + }) + }, + deadlineMinusEnabled: () => !bannerState.matches("atMinDeadline"), + deadlinePlusEnabled: () => !bannerState.matches("atMaxDeadline"), + }} + isUpdating={workspaceState.hasTag("updating")} + workspace={workspace} + handleStart={() => workspaceSend({ type: "START" })} + handleStop={() => workspaceSend({ type: "STOP" })} + handleDelete={() => workspaceSend({ type: "ASK_DELETE" })} + handleUpdate={() => workspaceSend({ type: "UPDATE" })} + handleCancel={() => workspaceSend({ type: "CANCEL" })} + resources={workspace.latest_build.resources} + builds={builds} + canUpdateWorkspace={canUpdateWorkspace} + hideSSHButton={featureVisibility[FeatureNames.BrowserOnly]} + workspaceErrors={{ + [WorkspaceErrors.GET_RESOURCES_ERROR]: refreshWorkspaceWarning, + [WorkspaceErrors.GET_BUILDS_ERROR]: getBuildsError, + [WorkspaceErrors.BUILD_ERROR]: buildError, + [WorkspaceErrors.CANCELLATION_ERROR]: cancellationError, + }} + buildInfo={buildInfoState.context.buildInfo} + applicationsHost={applicationsHost} + /> + workspaceSend({ type: "CANCEL_DELETE" })} + onConfirm={() => { + workspaceSend({ type: "DELETE" }) + }} + /> + + ) +} diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 2aeb89928a9d7..eaa92896f6113 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -216,6 +216,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { deadline: "2022-05-17T23:39:00.00Z", reason: "initiator", resources: [], + status: "running", } export const MockFailedWorkspaceBuild = ( @@ -237,6 +238,7 @@ export const MockFailedWorkspaceBuild = ( deadline: "2022-05-17T23:39:00.00Z", reason: "initiator", resources: [], + status: "running", }) export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = { diff --git a/site/src/util/schedule.test.ts b/site/src/util/schedule.test.ts index 584fc9b12a422..998c4666cd866 100644 --- a/site/src/util/schedule.test.ts +++ b/site/src/util/schedule.test.ts @@ -8,8 +8,8 @@ import { deadlineExtensionMax, deadlineExtensionMin, extractTimezone, - maxDeadline, - minDeadline, + getMaxDeadline, + getMinDeadline, stripTimezone, } from "./schedule" @@ -55,7 +55,7 @@ describe("maxDeadline", () => { } // Then: deadlineMinusDisabled should be falsy - const delta = maxDeadline(workspace, template).diff(now) + const delta = getMaxDeadline(workspace, template).diff(now) expect(delta).toBeLessThanOrEqual(deadlineExtensionMax.asMilliseconds()) }) }) @@ -68,7 +68,7 @@ describe("maxDeadline", () => { } // Then: deadlineMinusDisabled should be falsy - const delta = maxDeadline(workspace, template).diff(now) + const delta = getMaxDeadline(workspace, template).diff(now) expect(delta).toBeLessThanOrEqual(deadlineExtensionMax.asMilliseconds()) }) }) @@ -76,7 +76,7 @@ describe("maxDeadline", () => { describe("minDeadline", () => { it("should never be less than 30 minutes", () => { - const delta = minDeadline().diff(now) + const delta = getMinDeadline().diff(now) expect(delta).toBeGreaterThanOrEqual(deadlineExtensionMin.asMilliseconds()) }) }) diff --git a/site/src/util/schedule.ts b/site/src/util/schedule.ts index 41560d0b934ce..fffa8cb294d47 100644 --- a/site/src/util/schedule.ts +++ b/site/src/util/schedule.ts @@ -113,16 +113,30 @@ export const autoStopDisplay = (workspace: Workspace): string => { export const deadlineExtensionMin = dayjs.duration(30, "minutes") export const deadlineExtensionMax = dayjs.duration(24, "hours") -export function maxDeadline(ws: Workspace, tpl: Template): dayjs.Dayjs { +/** + * Depends on the time the workspace was last updated, the template config, + * and a global constant. + * @param ws workspace + * @param tpl template + * @returns the latest datetime at which the workspace can be automatically shut down. + */ +export function getMaxDeadline(ws: Workspace | undefined, tpl: Template): dayjs.Dayjs { // note: we count runtime from updated_at as started_at counts from the start of // the workspace build process, which can take a while. + if (ws === undefined) { + throw Error("Cannot calculate max deadline because workspace is undefined") + } const startedAt = dayjs(ws.latest_build.updated_at) const maxTemplateDeadline = startedAt.add(dayjs.duration(tpl.max_ttl_ms, "milliseconds")) const maxGlobalDeadline = startedAt.add(deadlineExtensionMax) return dayjs.min(maxTemplateDeadline, maxGlobalDeadline) } -export function minDeadline(): dayjs.Dayjs { +/** + * Depends on the current time and a global constant. + * @returns the earliest datetime at which the workspace can be automatically shut down. + */ +export function getMinDeadline(): dayjs.Dayjs { return dayjs().add(deadlineExtensionMin) } @@ -131,9 +145,12 @@ export function canExtendDeadline( workspace: Workspace, template: Template, ): boolean { - return deadline < maxDeadline(workspace, template) + return deadline < getMaxDeadline(workspace, template) } export function canReduceDeadline(deadline: dayjs.Dayjs): boolean { - return deadline > minDeadline() + return deadline > getMinDeadline() } + +export const getDeadline = (workspace: Workspace): dayjs.Dayjs => + dayjs(workspace.latest_build.deadline).utc() diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 7538a2453f0fa..e8cee0d67f6e5 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from "api/errors" +import { workspaceScheduleBannerMachine } from "xServices/workspaceSchedule/workspaceScheduleBannerXService" import { assign, createMachine, send } from "xstate" import * as API from "../../api/api" import * as Types from "../../api/types" @@ -78,6 +79,8 @@ export type WorkspaceEvent = | { type: "CANCEL" } | { type: "REFRESH_TIMELINE"; checkRefresh?: boolean; data?: TypesGen.ServerSentEvent["data"] } | { type: "EVENT_SOURCE_ERROR"; error: Error | unknown } + | { type: "INCREASE_DEADLINE"; hours: number } + | { type: "DECREASE_DEADLINE"; hours: number } export const checks = { readWorkspace: "readWorkspace", @@ -216,6 +219,7 @@ export const workspaceMachine = createMachine( }, ], }, + tags: "loading", }, ready: { type: "parallel", @@ -440,6 +444,19 @@ export const workspaceMachine = createMachine( }, }, }, + schedule: { + invoke: { + id: "scheduleBannerMachine", + src: workspaceScheduleBannerMachine, + data: { + workspace: (context: WorkspaceContext) => context.workspace, + template: (context: WorkspaceContext) => context.template, + }, + }, + on: { + REFRESH_WORKSPACE: { actions: "sendWorkspaceToSchedule" }, + }, + }, }, }, error: { @@ -551,6 +568,13 @@ export const workspaceMachine = createMachine( const message = getErrorMessage(data, "Error getting the applications host.") displayError(message) }, + sendWorkspaceToSchedule: send( + (context) => ({ + type: "REFRESH_WORKSPACE", + workspace: context.workspace, + }), + { to: "scheduleBannerMachine" }, + ), }, guards: { moreBuildsAvailable, diff --git a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts index 5dd803bc3b9ae..0f09b48825594 100644 --- a/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts +++ b/site/src/xServices/workspaceSchedule/workspaceScheduleBannerXService.ts @@ -3,22 +3,49 @@ * presented as an Alert/banner, for reactively updating a workspace schedule. */ import { getErrorMessage } from "api/errors" +import { Template, Workspace } from "api/typesGenerated" import dayjs from "dayjs" -import { createMachine } from "xstate" +import minMax from "dayjs/plugin/minMax" +import { + canExtendDeadline, + canReduceDeadline, + getDeadline, + getMaxDeadline, + getMinDeadline, +} from "util/schedule" +import { ActorRefFrom, assign, createMachine } from "xstate" import * as API from "../../api/api" import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils" +dayjs.extend(minMax) + export const Language = { errorExtension: "Failed to update workspace shutdown time.", successExtension: "Updated workspace shutdown time.", } -export type WorkspaceScheduleBannerEvent = { - type: "UPDATE_DEADLINE" - workspaceId: string - newDeadline: dayjs.Dayjs +export interface WorkspaceScheduleBannerContext { + workspace: Workspace + template: Template + deadline?: dayjs.Dayjs } +export type WorkspaceScheduleBannerEvent = + | { + type: "INCREASE_DEADLINE" + hours: number + } + | { + type: "DECREASE_DEADLINE" + hours: number + } + | { + type: "REFRESH_WORKSPACE" + workspace: Workspace + } + +export type WorkspaceScheduleBannerMachineRef = ActorRefFrom + export const workspaceScheduleBannerMachine = createMachine( { id: "workspaceScheduleBannerState", @@ -26,24 +53,75 @@ export const workspaceScheduleBannerMachine = createMachine( tsTypes: {} as import("./workspaceScheduleBannerXService.typegen").Typegen0, schema: { events: {} as WorkspaceScheduleBannerEvent, + context: {} as WorkspaceScheduleBannerContext, + }, + initial: "initialize", + on: { + REFRESH_WORKSPACE: { actions: "assignWorkspace" }, }, - initial: "idle", states: { - idle: { + initialize: { + always: [ + { cond: "isAtMaxDeadline", target: "atMaxDeadline" }, + { cond: "isAtMinDeadline", target: "atMinDeadline" }, + { target: "midRange" }, + ], + }, + midRange: { on: { - UPDATE_DEADLINE: "updatingDeadline", + INCREASE_DEADLINE: "increasingDeadline", + DECREASE_DEADLINE: "decreasingDeadline", }, }, - updatingDeadline: { + atMaxDeadline: { + on: { + DECREASE_DEADLINE: "decreasingDeadline", + }, + }, + atMinDeadline: { + on: { + INCREASE_DEADLINE: "increasingDeadline", + }, + }, + increasingDeadline: { invoke: { - src: "updateDeadline", - id: "updateDeadline", - onDone: { - target: "idle", - actions: "displaySuccessMessage", + src: "increaseDeadline", + id: "increaseDeadline", + onDone: [ + { + cond: "isAtMaxDeadline", + target: "atMaxDeadline", + actions: "displaySuccessMessage", + }, + { + target: "midRange", + actions: "displaySuccessMessage", + }, + ], + onError: { + target: "midRange", + actions: "displayFailureMessage", }, + }, + tags: "loading", + }, + decreasingDeadline: { + invoke: { + src: "decreaseDeadline", + id: "decreaseDeadline", + onDone: [ + { + cond: "isAtMinDeadline", + target: "atMinDeadline", + actions: "displaySuccessMessage", + }, + { + target: "midRange", + actions: "displaySuccessMessage", + }, + ], onError: { - target: "idle", + target: "midRange", actions: "displayFailureMessage", }, }, @@ -52,6 +130,14 @@ export const workspaceScheduleBannerMachine = createMachine( }, }, { + guards: { + isAtMaxDeadline: (context) => + context.deadline + ? !canExtendDeadline(context.deadline, context.workspace, context.template) + : false, + isAtMinDeadline: (context) => + context.deadline ? !canReduceDeadline(context.deadline) : false, + }, actions: { // This error does not have a detail, so using the snackbar is okay displayFailureMessage: (_, event) => { @@ -60,11 +146,31 @@ export const workspaceScheduleBannerMachine = createMachine( displaySuccessMessage: () => { displaySuccess(Language.successExtension) }, + assignWorkspace: assign((_, event) => ({ + workspace: event.workspace, + deadline: getDeadline(event.workspace), + })), }, services: { - updateDeadline: async (_, event) => { - await API.putWorkspaceExtension(event.workspaceId, event.newDeadline) + increaseDeadline: async (context, event) => { + if (!context.deadline) { + throw Error("Deadline is undefined.") + } + const proposedDeadline = context.deadline.add(event.hours, "hours") + const newDeadline = dayjs.min( + proposedDeadline, + getMaxDeadline(context.workspace, context.template), + ) + await API.putWorkspaceExtension(context.workspace.id, newDeadline) + }, + decreaseDeadline: async (context, event) => { + if (!context.deadline) { + throw Error("Deadline is undefined.") + } + const proposedDeadline = context.deadline.subtract(event.hours, "hours") + const newDeadline = dayjs.max(proposedDeadline, getMinDeadline()) + await API.putWorkspaceExtension(context.workspace.id, newDeadline) }, }, }, diff --git a/site/static/error.html b/site/static/error.html new file mode 100644 index 0000000000000..a8be2267085f9 --- /dev/null +++ b/site/static/error.html @@ -0,0 +1,135 @@ +{{/* This template is used by application handlers to render friendly error pages when there is a +proxy error (for example, when the target app isn't running). */}} + + + + + + + {{ .Error.Status }} - {{ .Error.Title }} + + + +
+ + + + + + + + + + + + + + + + +

{{ .Error.Status }} - {{ .Error.Title }}

+

{{ .Error.Description }}

+
+ {{- if .Error.RetryEnabled }} + + {{ end }} + Back to site +
+
+ + diff --git a/site/yarn.lock b/site/yarn.lock index 605301aad822e..69c339576ebb3 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -265,7 +265,7 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.18.10.tgz#181f22d28ebe1b3857fa575f5c290b1aaf659b56" integrity sha512-XtIfWmeNY3i4t7t4D2t02q50HvqHybPqW2ki1kosnvWCwuCMeo81Jf0gwr85jy/neUdg5XDdeFE/80DXiO+njw== -"@babel/helper-validator-identifier@^7.18.6": +"@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": version "7.19.1" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== @@ -1201,7 +1201,7 @@ resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.9.tgz#b658a97babf1f40783354af7039b84c3fdfc3fc3" integrity sha512-O+NfmkfRrb3uSsTa4jE3WApidSe3N5++fyOVGP1SmMZi4A3BZELkhUUvj5hwmMuNdlpzAZ8iAPz2vmcR7DCFQA== -"@eslint/eslintrc@^1.3.1": +"@eslint/eslintrc@^1.3.2": version "1.3.2" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.3.2.tgz#58b69582f3b7271d8fa67fe5251767a5b38ea356" integrity sha512-AXYd23w1S/bv3fTs3Lz0vjiYemS08jWkI3hYyS9I1ry+0f+Yjs1wm+sU0BS8qDOPrBIkp4qHYC16I8uVtpLajQ== @@ -1231,10 +1231,10 @@ resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== -"@humanwhocodes/config-array@^0.10.4": - version "0.10.5" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.5.tgz#bb679745224745fff1e9a41961c1d45a49f81c04" - integrity sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug== +"@humanwhocodes/config-array@^0.10.5": + version "0.10.7" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.7.tgz#6d53769fd0c222767e6452e8ebda825c22e9f0dc" + integrity sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w== dependencies: "@humanwhocodes/object-schema" "^1.2.1" debug "^4.1.1" @@ -1792,13 +1792,13 @@ tiny-glob "^0.2.9" tslib "^2.4.0" -"@playwright/test@1.25.1": - version "1.25.1" - resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.25.1.tgz#9847234b6f2b0cca71962b338da7db366a1e9720" - integrity sha512-IJ4X0yOakXtwkhbnNzKkaIgXe6df7u3H3FnuhI9Jqh+CdO0e/lYQlDLYiyI9cnXK8E7UAppAWP+VqAv6VX7HQg== +"@playwright/test@1.26.1": + version "1.26.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.26.1.tgz#73ada4e70f618bca69ba7509c4ba65b5a41c4b10" + integrity sha512-bNxyZASVt2adSZ9gbD7NCydzcb5JaI0OR9hc7s+nmPeH604gwp0zp17NNpwXY4c8nvuBGQQ9oGDx72LE+cUWvw== dependencies: "@types/node" "*" - playwright-core "1.25.1" + playwright-core "1.26.1" "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": version "0.5.7" @@ -1815,6 +1815,11 @@ schema-utils "^3.0.0" source-map "^0.7.3" +"@remix-run/router@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.0.1.tgz#88d7ac31811ab0cef14aaaeae2a0474923b278bc" + integrity sha512-eBV5rvW4dRFOU1eajN7FmYxjAIVz/mRHgUE9En9mBn6m3mulK3WTR5C3iQhL9MZ14rWAq+xOlEaCkDiW0/heOg== + "@sinclair/typebox@^0.24.1": version "0.24.42" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.42.tgz#a74b608d494a1f4cc079738e050142a678813f52" @@ -3329,165 +3334,84 @@ dependencies: "@types/yargs-parser" "*" -"@typescript-eslint/eslint-plugin@5.36.1": - version "5.36.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.36.1.tgz#471f64dc53600025e470dad2ca4a9f2864139019" - integrity sha512-iC40UK8q1tMepSDwiLbTbMXKDxzNy+4TfPWgIL661Ym0sD42vRcQU93IsZIrmi+x292DBr60UI/gSwfdVYexCA== +"@typescript-eslint/eslint-plugin@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.38.1.tgz#9f05d42fa8fb9f62304cc2f5c2805e03c01c2620" + integrity sha512-ky7EFzPhqz3XlhS7vPOoMDaQnQMn+9o5ICR9CPr/6bw8HrFkzhMSxuA3gRfiJVvs7geYrSeawGJjZoZQKCOglQ== dependencies: - "@typescript-eslint/scope-manager" "5.36.1" - "@typescript-eslint/type-utils" "5.36.1" - "@typescript-eslint/utils" "5.36.1" + "@typescript-eslint/scope-manager" "5.38.1" + "@typescript-eslint/type-utils" "5.38.1" + "@typescript-eslint/utils" "5.38.1" debug "^4.3.4" - functional-red-black-tree "^1.0.1" ignore "^5.2.0" regexpp "^3.2.0" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.36.2.tgz#3ddf323d3ac85a25295a55fcb9c7a49ab4680ddd" - integrity sha512-qS/Kb0yzy8sR0idFspI9Z6+t7mqk/oRjnAYfewG+VN73opAUvmYL3oPIMmgOX6CnQS6gmVIXGshlb5RY/R22pA== +"@typescript-eslint/parser@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.38.1.tgz#c577f429f2c32071b92dff4af4f5fbbbd2414bd0" + integrity sha512-LDqxZBVFFQnQRz9rUZJhLmox+Ep5kdUmLatLQnCRR6523YV+XhRjfYzStQ4MheFA8kMAfUlclHSbu+RKdRwQKw== dependencies: - "@typescript-eslint/scope-manager" "5.36.2" - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/typescript-estree" "5.36.2" + "@typescript-eslint/scope-manager" "5.38.1" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/typescript-estree" "5.38.1" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.36.1": - version "5.36.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.1.tgz#23c49b7ddbcffbe09082e6694c2524950766513f" - integrity sha512-pGC2SH3/tXdu9IH3ItoqciD3f3RRGCh7hb9zPdN2Drsr341zgd6VbhP5OHQO/reUqihNltfPpMpTNihFMarP2w== - dependencies: - "@typescript-eslint/types" "5.36.1" - "@typescript-eslint/visitor-keys" "5.36.1" - -"@typescript-eslint/scope-manager@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.36.2.tgz#a75eb588a3879ae659514780831370642505d1cd" - integrity sha512-cNNP51L8SkIFSfce8B1NSUBTJTu2Ts4nWeWbFrdaqjmn9yKrAaJUBHkyTZc0cL06OFHpb+JZq5AUHROS398Orw== - dependencies: - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/visitor-keys" "5.36.2" - -"@typescript-eslint/scope-manager@5.38.0": - version "5.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.38.0.tgz#8f0927024b6b24e28671352c93b393a810ab4553" - integrity sha512-ByhHIuNyKD9giwkkLqzezZ9y5bALW8VNY6xXcP+VxoH4JBDKjU5WNnsiD4HJdglHECdV+lyaxhvQjTUbRboiTA== +"@typescript-eslint/scope-manager@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.38.1.tgz#f87b289ef8819b47189351814ad183e8801d5764" + integrity sha512-BfRDq5RidVU3RbqApKmS7RFMtkyWMM50qWnDAkKgQiezRtLKsoyRKIvz1Ok5ilRWeD9IuHvaidaLxvGx/2eqTQ== dependencies: - "@typescript-eslint/types" "5.38.0" - "@typescript-eslint/visitor-keys" "5.38.0" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/visitor-keys" "5.38.1" -"@typescript-eslint/type-utils@5.36.1": - version "5.36.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.36.1.tgz#016fc2bff6679f54c0b2df848a493f0ca3d4f625" - integrity sha512-xfZhfmoQT6m3lmlqDvDzv9TiCYdw22cdj06xY0obSznBsT///GK5IEZQdGliXpAOaRL34o8phEvXzEo/VJx13Q== +"@typescript-eslint/type-utils@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.38.1.tgz#7f038fcfcc4ade4ea76c7c69b2aa25e6b261f4c1" + integrity sha512-UU3j43TM66gYtzo15ivK2ZFoDFKKP0k03MItzLdq0zV92CeGCXRfXlfQX5ILdd4/DSpHkSjIgLLLh1NtkOJOAw== dependencies: - "@typescript-eslint/typescript-estree" "5.36.1" - "@typescript-eslint/utils" "5.36.1" + "@typescript-eslint/typescript-estree" "5.38.1" + "@typescript-eslint/utils" "5.38.1" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.36.1": - version "5.36.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.1.tgz#1cf0e28aed1cb3ee676917966eb23c2f8334ce2c" - integrity sha512-jd93ShpsIk1KgBTx9E+hCSEuLCUFwi9V/urhjOWnOaksGZFbTOxAT47OH2d4NLJnLhkVD+wDbB48BuaycZPLBg== +"@typescript-eslint/types@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.38.1.tgz#74f9d6dcb8dc7c58c51e9fbc6653ded39e2e225c" + integrity sha512-QTW1iHq1Tffp9lNfbfPm4WJabbvpyaehQ0SrvVK2yfV79SytD9XDVxqiPvdrv2LK7DGSFo91TB2FgWanbJAZXg== -"@typescript-eslint/types@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.36.2.tgz#a5066e500ebcfcee36694186ccc57b955c05faf9" - integrity sha512-9OJSvvwuF1L5eS2EQgFUbECb99F0mwq501w0H0EkYULkhFa19Qq7WFbycdw1PexAc929asupbZcgjVIe6OK/XQ== - -"@typescript-eslint/types@5.38.0": - version "5.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.38.0.tgz#8cd15825e4874354e31800dcac321d07548b8a5f" - integrity sha512-HHu4yMjJ7i3Cb+8NUuRCdOGu2VMkfmKyIJsOr9PfkBVYLYrtMCK/Ap50Rpov+iKpxDTfnqvDbuPLgBE5FwUNfA== - -"@typescript-eslint/typescript-estree@5.36.1": - version "5.36.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.1.tgz#b857f38d6200f7f3f4c65cd0a5afd5ae723f2adb" - integrity sha512-ih7V52zvHdiX6WcPjsOdmADhYMDN15SylWRZrT2OMy80wzKbc79n8wFW0xpWpU0x3VpBz/oDgTm2xwDAnFTl+g== +"@typescript-eslint/typescript-estree@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.38.1.tgz#657d858d5d6087f96b638ee383ee1cff52605a1e" + integrity sha512-99b5e/Enoe8fKMLdSuwrfH/C0EIbpUWmeEKHmQlGZb8msY33qn1KlkFww0z26o5Omx7EVjzVDCWEfrfCDHfE7g== dependencies: - "@typescript-eslint/types" "5.36.1" - "@typescript-eslint/visitor-keys" "5.36.1" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/visitor-keys" "5.38.1" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/typescript-estree@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.36.2.tgz#0c93418b36c53ba0bc34c61fe9405c4d1d8fe560" - integrity sha512-8fyH+RfbKc0mTspfuEjlfqA4YywcwQK2Amcf6TDOwaRLg7Vwdu4bZzyvBZp4bjt1RRjQ5MDnOZahxMrt2l5v9w== - dependencies: - "@typescript-eslint/types" "5.36.2" - "@typescript-eslint/visitor-keys" "5.36.2" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/typescript-estree@5.38.0": - version "5.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.38.0.tgz#89f86b2279815c6fb7f57d68cf9b813f0dc25d98" - integrity sha512-6P0RuphkR+UuV7Avv7MU3hFoWaGcrgOdi8eTe1NwhMp2/GjUJoODBTRWzlHpZh6lFOaPmSvgxGlROa0Sg5Zbyg== - dependencies: - "@typescript-eslint/types" "5.38.0" - "@typescript-eslint/visitor-keys" "5.38.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - semver "^7.3.7" - tsutils "^3.21.0" - -"@typescript-eslint/utils@5.36.1": - version "5.36.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.36.1.tgz#136d5208cc7a3314b11c646957f8f0b5c01e07ad" - integrity sha512-lNj4FtTiXm5c+u0pUehozaUWhh7UYKnwryku0nxJlYUEWetyG92uw2pr+2Iy4M/u0ONMKzfrx7AsGBTCzORmIg== - dependencies: - "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.36.1" - "@typescript-eslint/types" "5.36.1" - "@typescript-eslint/typescript-estree" "5.36.1" - eslint-scope "^5.1.1" - eslint-utils "^3.0.0" - -"@typescript-eslint/utils@^5.10.0": - version "5.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.38.0.tgz#5b31f4896471818153790700eb02ac869a1543f4" - integrity sha512-6sdeYaBgk9Fh7N2unEXGz+D+som2QCQGPAf1SxrkEr+Z32gMreQ0rparXTNGRRfYUWk/JzbGdcM8NSSd6oqnTA== +"@typescript-eslint/utils@5.38.1", "@typescript-eslint/utils@^5.10.0": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.38.1.tgz#e3ac37d7b33d1362bb5adf4acdbe00372fb813ef" + integrity sha512-oIuUiVxPBsndrN81oP8tXnFa/+EcZ03qLqPDfSZ5xIJVm7A9V0rlkQwwBOAGtrdN70ZKDlKv+l1BeT4eSFxwXA== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.38.0" - "@typescript-eslint/types" "5.38.0" - "@typescript-eslint/typescript-estree" "5.38.0" + "@typescript-eslint/scope-manager" "5.38.1" + "@typescript-eslint/types" "5.38.1" + "@typescript-eslint/typescript-estree" "5.38.1" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.36.1": - version "5.36.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.1.tgz#7731175312d65738e501780f923896d200ad1615" - integrity sha512-ojB9aRyRFzVMN3b5joSYni6FAS10BBSCAfKJhjJAV08t/a95aM6tAhz+O1jF+EtgxktuSO3wJysp2R+Def/IWQ== - dependencies: - "@typescript-eslint/types" "5.36.1" - eslint-visitor-keys "^3.3.0" - -"@typescript-eslint/visitor-keys@5.36.2": - version "5.36.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.36.2.tgz#2f8f78da0a3bad3320d2ac24965791ac39dace5a" - integrity sha512-BtRvSR6dEdrNt7Net2/XDjbYKU5Ml6GqJgVfXT0CxTCJlnIqK7rAGreuWKMT2t8cFUT2Msv5oxw0GMRD7T5J7A== - dependencies: - "@typescript-eslint/types" "5.36.2" - eslint-visitor-keys "^3.3.0" - -"@typescript-eslint/visitor-keys@5.38.0": - version "5.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.38.0.tgz#60591ca3bf78aa12b25002c0993d067c00887e34" - integrity sha512-MxnrdIyArnTi+XyFLR+kt/uNAcdOnmT+879os7qDRI+EYySR4crXJq9BXPfRzzLGq0wgxkwidrCJ9WCAoacm1w== +"@typescript-eslint/visitor-keys@5.38.1": + version "5.38.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.38.1.tgz#508071bfc6b96d194c0afe6a65ad47029059edbc" + integrity sha512-bSHr1rRxXt54+j2n4k54p4fj8AHJ49VDWtjpImOpzQj4qjAiOpPni+V1Tyajh19Api1i844F757cur8wH3YvOA== dependencies: - "@typescript-eslint/types" "5.38.0" + "@typescript-eslint/types" "5.38.1" eslint-visitor-keys "^3.3.0" "@vitejs/plugin-react@2.1.0": @@ -4982,10 +4906,10 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -chart.js@3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.5.0.tgz#6eb075332d4ebbbb20a94e5a07a234052ed6c4fb" - integrity sha512-J1a4EAb1Gi/KbhwDRmoovHTRuqT8qdF0kZ4XgwxpGethJHUdDrkqyPYwke0a+BuvSeUxPf8Cos6AX2AB8H8GLA== +chart.js@3.9.1: + version "3.9.1" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.9.1.tgz#3abf2c775169c4c71217a107163ac708515924b8" + integrity sha512-Ro2JbLmvg83gXF5F4sniaQ+lTbSv18E+TIf2cOeiH1Iqd2PGFOtem+DUufMZsCJwFE7ywPOpfXFBwRTGq7dh6w== chartjs-adapter-date-fns@2.0.0: version "2.0.0" @@ -5036,10 +4960,10 @@ chownr@^2.0.0: resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== -chromatic@6.9.0: - version "6.9.0" - resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-6.9.0.tgz#6ca326d2157263a7ffa73cde2ca2d0ea9d5cd816" - integrity sha512-ykfUHLyznZTPi8djKMfrBoOpWinSUfa2HDLmvHgYc2e6Jc4Oqm5jYW9S7l92navSF+EOzov90L7W8rX2NpiBjQ== +chromatic@6.10.1: + version "6.10.1" + resolved "https://registry.yarnpkg.com/chromatic/-/chromatic-6.10.1.tgz#5a52e7d616dfd35b6857d724d503cb7baa842922" + integrity sha512-x8UN5rQ4HK1bUv2uDsHnCQW3bcsFmJH90F5w8mXHkiDc7kEAvbOAm/im2cWbi/VUDI+jW+A7SvZTUi54lBF5wQ== dependencies: "@discoveryjs/json-ext" "^0.5.7" "@types/webpack-env" "^1.17.0" @@ -5054,7 +4978,7 @@ ci-info@^2.0.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== -ci-info@^3.2.0, ci-info@^3.3.2: +ci-info@^3.2.0, ci-info@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.4.0.tgz#b28484fd436cbc267900364f096c9dc185efb251" integrity sha512-t5QdPT5jq3o262DOQ8zA6E1tlH2upmUc4Hlvrbx1pGYJuiiHl7O7rvVNI+l8HTVhd/q3Qc9vqimkNk5yiXsAug== @@ -6497,18 +6421,18 @@ eslint-plugin-react@7.31.1: semver "^6.3.0" string.prototype.matchall "^4.0.7" -eslint-plugin-unicorn@43.0.2: - version "43.0.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-43.0.2.tgz#b189d58494c8a0985a4b89dba5dbfde3ad7575a5" - integrity sha512-DtqZ5mf/GMlfWoz1abIjq5jZfaFuHzGBZYIeuJfEoKKGWRHr2JiJR+ea+BF7Wx2N1PPRoT/2fwgiK1NnmNE3Hg== +eslint-plugin-unicorn@44.0.0: + version "44.0.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-unicorn/-/eslint-plugin-unicorn-44.0.0.tgz#ddb2d7bf3674077d6f3b227b9a0ce22dfc1e3ceb" + integrity sha512-GbkxkdNzY7wNEfZnraAP+oA+aqqzSrNZmO37kjW1DyqnSK/ah08ySDdIecObpx46twv+zcQvH8i0CHP98Wo64w== dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - ci-info "^3.3.2" + "@babel/helper-validator-identifier" "^7.19.1" + ci-info "^3.4.0" clean-regexp "^1.0.0" eslint-utils "^3.0.0" esquery "^1.4.0" indent-string "^4.0.0" - is-builtin-module "^3.1.0" + is-builtin-module "^3.2.0" lodash "^4.17.21" pluralize "^8.0.0" read-pkg-up "^7.0.1" @@ -6558,13 +6482,13 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.23.0: - version "8.23.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.23.0.tgz#a184918d288820179c6041bb3ddcc99ce6eea040" - integrity sha512-pBG/XOn0MsJcKcTRLr27S5HpzQo4kLr+HjLQIyK4EiCsijDl/TB+h5uEuJU6bQ8Edvwz1XWOjpaP2qgnXGpTcA== +eslint@8.24.0: + version "8.24.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.24.0.tgz#489516c927a5da11b3979dbfb2679394523383c8" + integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ== dependencies: - "@eslint/eslintrc" "^1.3.1" - "@humanwhocodes/config-array" "^0.10.4" + "@eslint/eslintrc" "^1.3.2" + "@humanwhocodes/config-array" "^0.10.5" "@humanwhocodes/gitignore-to-minimatch" "^1.0.2" "@humanwhocodes/module-importer" "^1.0.1" ajv "^6.10.0" @@ -6582,7 +6506,6 @@ eslint@8.23.0: fast-deep-equal "^3.1.3" file-entry-cache "^6.0.1" find-up "^5.0.0" - functional-red-black-tree "^1.0.1" glob-parent "^6.0.1" globals "^13.15.0" globby "^11.1.0" @@ -6591,6 +6514,7 @@ eslint@8.23.0: import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" + js-sdsl "^4.1.4" js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" @@ -7234,11 +7158,6 @@ function.prototype.name@^1.1.0, function.prototype.name@^1.1.5: es-abstract "^1.19.0" functions-have-names "^1.2.2" -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== - functions-have-names@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" @@ -7664,7 +7583,7 @@ highlight.js@^10.4.1, highlight.js@~10.7.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531" integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A== -history@5.3.0, history@^5.2.0: +history@5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== @@ -8050,7 +7969,7 @@ is-buffer@^2.0.0: resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== -is-builtin-module@^3.1.0: +is-builtin-module@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-3.2.0.tgz#bb0310dfe881f144ca83f30100ceb10cf58835e0" integrity sha512-phDA4oSGt7vl1n5tJvTWooWWAsXLY+2xCnxNqvKhGEzujg+A43wPlPOyDg3C8XQHN+6k/JTQWJ/j0dQh/qr+Hw== @@ -9057,6 +8976,11 @@ js-levenshtein@^1.1.6: resolved "https://registry.yarnpkg.com/js-levenshtein/-/js-levenshtein-1.1.6.tgz#c6cee58eb3550372df8deb85fad5ce66ce01d59d" integrity sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g== +js-sdsl@^4.1.4: + version "4.1.5" + resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.1.5.tgz#1ff1645e6b4d1b028cd3f862db88c9d887f26e2a" + integrity sha512-08bOAKweV2NUC1wqTtf3qZlnpOX/R2DU9ikpjOHs0H+ibQv3zpncVQg6um4uYtRtrwIX8M4Nh3ytK4HGlYAq7Q== + js-string-escape@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/js-string-escape/-/js-string-escape-1.0.1.tgz#e2625badbc0d67c7533e9edc1068c587ae4137ef" @@ -11080,10 +11004,10 @@ pkg-dir@^5.0.0: dependencies: find-up "^5.0.0" -playwright-core@1.25.1: - version "1.25.1" - resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.25.1.tgz#abe56aec8bef645fba988320d9f9328fafab0446" - integrity sha512-lSvPCmA2n7LawD2Hw7gSCLScZ+vYRkhU8xH0AapMyzwN+ojoDqhkH/KIEUxwNu2PjPoE/fcE0wLAksdOhJ2O5g== +playwright-core@1.26.1: + version "1.26.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.26.1.tgz#a162f476488312dcf12638d97685144de6ada512" + integrity sha512-hzFchhhxnEiPc4qVPs9q2ZR+5eKNifY2hQDHtg1HnTTUuphYCBP8ZRb2si+B1TR7BHirgXaPi48LIye5SgrLAA== pluralize@^8.0.0: version "8.0.0" @@ -11617,20 +11541,20 @@ react-refresh@^0.14.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-router-dom@6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" - integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== +react-router-dom@6.4.1: + version "6.4.1" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.1.tgz#99c9b7c4967890701c888517475aa5d54d25760e" + integrity sha512-MY7NJCrGNVJtGp8ODMOBHu20UaIkmwD2V3YsAOUQoCXFk7Ppdwf55RdcGyrSj+ycSL9Uiwrb3gTLYSnzcRoXww== dependencies: - history "^5.2.0" - react-router "6.3.0" + "@remix-run/router" "1.0.1" + react-router "6.4.1" -react-router@6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" - integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== +react-router@6.4.1: + version "6.4.1" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.1.tgz#dd9cc4dfa264751d143a4b6c9d4faa60ab3ce26c" + integrity sha512-OJASKp5AykDWFewgWUim1vlLr7yfD4vO/h+bSgcP/ix8Md+LMHuAjovA74MQfsfhQJGGN1nHRhwS5qQQbbBt3A== dependencies: - history "^5.2.0" + "@remix-run/router" "1.0.1" react-syntax-highlighter@15.5.0, react-syntax-highlighter@^15.4.5: version "15.5.0" From 1c461f7d2b4963e0d79c2228895720617a6677a8 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 3 Oct 2022 16:53:50 -0500 Subject: [PATCH 104/138] add groups to license entitlements (#4345) --- codersdk/features.go | 2 + enterprise/cli/features_test.go | 4 +- enterprise/cli/server.go | 1 + enterprise/coderd/authorize_test.go | 4 ++ enterprise/coderd/coderd.go | 17 +++++ .../coderd/coderdenttest/coderdenttest.go | 9 +++ .../coderdenttest/coderdenttest_test.go | 4 +- enterprise/coderd/groups_test.go | 54 ++++++++++++++++ enterprise/coderd/licenses.go | 1 + enterprise/coderd/licenses_test.go | 4 ++ enterprise/coderd/scim.go | 16 +++++ enterprise/coderd/templates_test.go | 63 +++++++++++++++++++ 12 files changed, 177 insertions(+), 2 deletions(-) diff --git a/codersdk/features.go b/codersdk/features.go index 0562d9e06c72e..ee9c595933621 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -20,6 +20,7 @@ const ( FeatureBrowserOnly = "browser_only" FeatureSCIM = "scim" FeatureWorkspaceQuota = "workspace_quota" + FeatureRBAC = "rbac" ) var FeatureNames = []string{ @@ -28,6 +29,7 @@ var FeatureNames = []string{ FeatureBrowserOnly, FeatureSCIM, FeatureWorkspaceQuota, + FeatureRBAC, } type Feature struct { diff --git a/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index 4621dd07e3def..dad602736db78 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 4) + assert.Len(t, entitlements.Features, 5) assert.Empty(t, entitlements.Warnings) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) @@ -67,6 +67,8 @@ func TestFeaturesList(t *testing.T) { entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureWorkspaceQuota].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureRBAC].Entitlement) assert.False(t, entitlements.HasLicense) }) } diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 6dde1c31cd75c..e2a08c2924d2d 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -26,6 +26,7 @@ func server() *cobra.Command { BrowserOnly: browserOnly, SCIMAPIKey: []byte(scimAuthHeader), UserWorkspaceQuota: userWorkspaceQuota, + RBACEnabled: true, Options: options, }) if err != nil { diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go index 8c3338b693f70..c770eff417541 100644 --- a/enterprise/coderd/authorize_test.go +++ b/enterprise/coderd/authorize_test.go @@ -27,6 +27,10 @@ func TestCheckACLPermissions(t *testing.T) { }) // Create adminClient, member, and org adminClient adminUser := coderdtest.CreateFirstUser(t, adminClient) + _ = coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) memberUser, err := memberClient.User(ctx, codersdk.Me) require.NoError(t, err) diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 4da78abe60f26..2e024c302a3f5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -79,6 +79,7 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Route("/templates/{template}/acl", func(r chi.Router) { r.Use( + api.rbacEnabledMW, apiKeyMiddleware, httpmw.ExtractTemplateParam(api.Database), ) @@ -88,6 +89,7 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Route("/groups/{group}", func(r chi.Router) { r.Use( + api.rbacEnabledMW, apiKeyMiddleware, httpmw.ExtractGroupParam(api.Database), ) @@ -130,6 +132,7 @@ func New(ctx context.Context, options *Options) (*API, error) { type Options struct { *coderd.Options + RBACEnabled bool AuditLogging bool // Whether to block non-browser connections. BrowserOnly bool @@ -156,6 +159,7 @@ type entitlements struct { browserOnly codersdk.Entitlement scim codersdk.Entitlement workspaceQuota codersdk.Entitlement + rbac codersdk.Entitlement } func (api *API) Close() error { @@ -183,6 +187,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { scim: codersdk.EntitlementNotEntitled, browserOnly: codersdk.EntitlementNotEntitled, workspaceQuota: codersdk.EntitlementNotEntitled, + rbac: codersdk.EntitlementNotEntitled, } // Here we loop through licenses to detect enabled features. @@ -224,6 +229,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { if claims.Features.WorkspaceQuota > 0 { entitlements.workspaceQuota = entitlement } + if claims.Features.RBAC > 0 { + entitlements.rbac = entitlement + } } if entitlements.auditLogs != api.entitlements.auditLogs { @@ -318,6 +326,15 @@ func (api *API) serveEntitlements(rw http.ResponseWriter, r *http.Request) { "Workspace quotas are enabled but your license for this feature is expired.") } + resp.Features[codersdk.FeatureRBAC] = codersdk.Feature{ + Entitlement: entitlements.rbac, + Enabled: api.RBACEnabled, + } + if entitlements.rbac == codersdk.EntitlementGracePeriod && api.RBACEnabled { + resp.Warnings = append(resp.Warnings, + "RBAC is enabled but your license for this feature is expired.") + } + httpapi.Write(ctx, rw, http.StatusOK, resp) } diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 912eb29090f4e..10b634a258bdd 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -58,6 +58,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ AuditLogging: true, + RBACEnabled: true, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, UserWorkspaceQuota: options.UserWorkspaceQuota, @@ -73,6 +74,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c if options.IncludeProvisionerDaemon { provisionerCloser = coderdtest.NewProvisionerDaemon(t, coderAPI.AGPL) } + t.Cleanup(func() { cancelFunc() _ = provisionerCloser.Close() @@ -91,6 +93,7 @@ type LicenseOptions struct { BrowserOnly bool SCIM bool WorkspaceQuota bool + RBACEnabled bool } // AddLicense generates a new license with the options provided and inserts it. @@ -127,6 +130,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { workspaceQuota = 1 } + groups := int64(0) + if options.RBACEnabled { + groups = 1 + } + c := &coderd.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "test@testing.test", @@ -144,6 +152,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { BrowserOnly: browserOnly, SCIM: scim, WorkspaceQuota: workspaceQuota, + RBAC: groups, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index bd9e747eb6340..cd31f4a07fafc 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -32,7 +32,9 @@ func TestAuthorizeAllEndpoints(t *testing.T) { }) ctx, _ := testutil.Context(t) admin := coderdtest.CreateFirstUser(t, client) - license := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{}) + license := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ Name: "testgroup", }) diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index 8e4229076608f..fac66b116574f 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -23,6 +23,9 @@ func TestCreateGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -39,6 +42,9 @@ func TestCreateGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -60,6 +66,9 @@ func TestCreateGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: database.AllUsersGroup, @@ -80,6 +89,9 @@ func TestPatchGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -99,6 +111,9 @@ func TestPatchGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -122,6 +137,9 @@ func TestPatchGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user4 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -154,6 +172,9 @@ func TestPatchGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -175,6 +196,9 @@ func TestPatchGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -196,6 +220,9 @@ func TestPatchGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ @@ -219,6 +246,9 @@ func TestPatchGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -245,6 +275,9 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -262,6 +295,9 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -289,6 +325,9 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) ctx, _ := testutil.Context(t) @@ -307,6 +346,9 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -337,6 +379,9 @@ func TestGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -375,6 +420,9 @@ func TestGroups(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user4 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) @@ -427,6 +475,9 @@ func TestDeleteGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ Name: "hi", @@ -449,6 +500,9 @@ func TestDeleteGroup(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) ctx, _ := testutil.Context(t) groups, err := client.GroupsByOrganization(ctx, user.OrganizationID) require.NoError(t, err) diff --git a/enterprise/coderd/licenses.go b/enterprise/coderd/licenses.go index 9d43bbe6c2996..e4f106b75b273 100644 --- a/enterprise/coderd/licenses.go +++ b/enterprise/coderd/licenses.go @@ -50,6 +50,7 @@ type Features struct { BrowserOnly int64 `json:"browser_only"` SCIM int64 `json:"scim"` WorkspaceQuota int64 `json:"workspace_quota"` + RBAC int64 `json:"rbac"` } type Claims struct { diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index c4b7111597079..ec6dab52ae89b 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -82,6 +82,7 @@ func TestGetLicense(t *testing.T) { AuditLog: true, SCIM: true, BrowserOnly: true, + RBACEnabled: true, }) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ @@ -90,6 +91,7 @@ func TestGetLicense(t *testing.T) { SCIM: true, BrowserOnly: true, UserLimit: 200, + RBACEnabled: false, }) licenses, err := client.Licenses(ctx) @@ -103,6 +105,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureSCIM: json.Number("1"), codersdk.FeatureBrowserOnly: json.Number("1"), codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureRBAC: json.Number("1"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) @@ -112,6 +115,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureSCIM: json.Number("1"), codersdk.FeatureBrowserOnly: json.Number("1"), codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureRBAC: json.Number("0"), }, licenses[1].Claims["features"]) }) } diff --git a/enterprise/coderd/scim.go b/enterprise/coderd/scim.go index 1d01a5601dcd7..3224ab5093b63 100644 --- a/enterprise/coderd/scim.go +++ b/enterprise/coderd/scim.go @@ -33,6 +33,22 @@ func (api *API) scimEnabledMW(next http.Handler) http.Handler { }) } +// TODO reduce the duplication across all of these. +func (api *API) rbacEnabledMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + api.entitlementsMu.RLock() + rbac := api.entitlements.rbac + api.entitlementsMu.RUnlock() + + if rbac == codersdk.EntitlementNotEntitled { + httpapi.RouteNotFound(rw) + return + } + + next.ServeHTTP(rw, r) + }) +} + func (api *API) scimVerifyAuthHeader(r *http.Request) bool { hdr := []byte(r.Header.Get("Authorization")) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 6e0b151208c05..478cd2ac5fb73 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -21,6 +21,10 @@ func TestTemplateACL(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -58,6 +62,9 @@ func TestTemplateACL(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -79,6 +86,10 @@ func TestTemplateACL(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -125,6 +136,10 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -159,6 +174,10 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -193,6 +212,10 @@ func TestTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -241,6 +264,10 @@ func TestUpdateTemplateACL(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -280,6 +307,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -337,6 +368,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) req := codersdk.UpdateTemplateACL{ @@ -358,6 +393,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) req := codersdk.UpdateTemplateACL{ @@ -379,6 +418,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -401,6 +444,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + 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) @@ -432,6 +479,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -470,6 +521,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -488,6 +543,10 @@ func TestUpdateTemplateACL(t *testing.T) { client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) @@ -549,6 +608,10 @@ func TestUpdateTemplateACL(t *testing.T) { t.Parallel() client := coderdenttest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) From c5ecbf4af4ce142c35c191e4138d18560abe1d43 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 3 Oct 2022 17:13:44 -0500 Subject: [PATCH 105/138] omit all users from groups endpoint (#4350) --- coderd/database/databasefake/databasefake.go | 3 ++- coderd/database/queries.sql.go | 2 ++ coderd/database/queries/groups.sql | 4 +++- enterprise/coderd/groups.go | 18 ++---------------- enterprise/coderd/groups_test.go | 17 ++--------------- 5 files changed, 11 insertions(+), 33 deletions(-) diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index d2e348f310b5a..0b42e474f81ad 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -2961,7 +2961,8 @@ func (q *fakeQuerier) GetGroupsByOrganizationID(_ context.Context, organizationI var groups []database.Group for _, group := range q.groups { - if group.OrganizationID == organizationID { + // Omit the allUsers group. + if group.OrganizationID == organizationID && group.ID != organizationID { groups = append(groups, group) } } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 09043f5cf9dbf..36f5b45662712 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -944,6 +944,8 @@ FROM groups WHERE organization_id = $1 +AND + id != $1 ` func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) { diff --git a/coderd/database/queries/groups.sql b/coderd/database/queries/groups.sql index c57631279a0ec..f3b87db1ea5d7 100644 --- a/coderd/database/queries/groups.sql +++ b/coderd/database/queries/groups.sql @@ -66,7 +66,9 @@ SELECT FROM groups WHERE - organization_id = $1; + organization_id = $1 +AND + id != $1; -- name: InsertGroup :one INSERT INTO groups ( diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go index 3c3e639f904dc..4c81c4a5efaf9 100644 --- a/enterprise/coderd/groups.go +++ b/enterprise/coderd/groups.go @@ -218,15 +218,7 @@ func (api *API) group(rw http.ResponseWriter, r *http.Request) { return } - var ( - users []database.User - err error - ) - if group.Name == database.AllUsersGroup { - users, err = api.Database.GetAllOrganizationMembers(ctx, group.OrganizationID) - } else { - users, err = api.Database.GetGroupMembers(ctx, group.ID) - } + users, err := api.Database.GetGroupMembers(ctx, group.ID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { httpapi.InternalServerError(rw, err) return @@ -259,13 +251,7 @@ func (api *API) groups(rw http.ResponseWriter, r *http.Request) { resp := make([]codersdk.Group, 0, len(groups)) for _, group := range groups { - var members []database.User - - if group.Name == database.AllUsersGroup { - members, err = api.Database.GetAllOrganizationMembers(ctx, group.OrganizationID) - } else { - members, err = api.Database.GetGroupMembers(ctx, group.ID) - } + members, err := api.Database.GetGroupMembers(ctx, group.ID) if err != nil { httpapi.InternalServerError(rw, err) return diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go index fac66b116574f..384419c2eb83f 100644 --- a/enterprise/coderd/groups_test.go +++ b/enterprise/coderd/groups_test.go @@ -451,18 +451,9 @@ func TestGroups(t *testing.T) { groups, err := client.GroupsByOrganization(ctx, user.OrganizationID) require.NoError(t, err) - require.Len(t, groups, 3, "Should contain allUsers + 2 created groups") + require.Len(t, groups, 2) require.Contains(t, groups, group1) require.Contains(t, groups, group2) - - for _, group := range groups { - if group.Name == database.AllUsersGroup { - require.Contains(t, group.Members, user2) - require.Contains(t, group.Members, user3) - require.Contains(t, group.Members, user4) - require.Contains(t, group.Members, user5) - } - } }) } @@ -504,11 +495,7 @@ func TestDeleteGroup(t *testing.T) { RBACEnabled: true, }) ctx, _ := testutil.Context(t) - groups, err := client.GroupsByOrganization(ctx, user.OrganizationID) - require.NoError(t, err) - require.Len(t, groups, 1) - - err = client.DeleteGroup(ctx, groups[0].ID) + err := client.DeleteGroup(ctx, user.OrganizationID) require.Error(t, err) cerr, ok := codersdk.AsError(err) require.True(t, ok) From f0f5a939607a32eeab99af5c5acc6b464376092c Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 4 Oct 2022 14:33:52 +0000 Subject: [PATCH 106/138] Add paywall into the entitlements --- site/src/api/types.ts | 1 + site/src/components/Paywall/Paywall.tsx | 63 ++++++ .../components/UsersLayout/UsersLayout.tsx | 4 +- site/src/hooks/useFeatureVisibility.ts | 9 + site/src/pages/GroupsPage/GroupsPage.tsx | 202 +++++++++++------- .../TemplatePermissionsPage.tsx | 94 +++++--- .../xServices/template/templateACLXService.ts | 2 +- 7 files changed, 263 insertions(+), 112 deletions(-) create mode 100644 site/src/components/Paywall/Paywall.tsx create mode 100644 site/src/hooks/useFeatureVisibility.ts diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 420f805ccd85d..5fdabd35be45f 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -22,4 +22,5 @@ export enum FeatureNames { BrowserOnly = "browser_only", SCIM = "scim", WorkspaceQuota = "workspace_quota", + RBAC = "rbac", } diff --git a/site/src/components/Paywall/Paywall.tsx b/site/src/components/Paywall/Paywall.tsx new file mode 100644 index 0000000000000..0ad07d38f57e7 --- /dev/null +++ b/site/src/components/Paywall/Paywall.tsx @@ -0,0 +1,63 @@ +import Box from "@material-ui/core/Box" +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import { FC, ReactNode } from "react" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" + +export interface PaywallProps { + message: string + description?: string | React.ReactNode + cta?: ReactNode +} + +export const Paywall: FC> = (props) => { + const { message, description, cta } = props + const styles = useStyles() + + return ( + +
+ + {message} + + {description && ( + + {description} + + )} +
+ {cta} +
+ ) +} + +const useStyles = makeStyles( + (theme) => ({ + root: { + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + textAlign: "center", + minHeight: 300, + padding: theme.spacing(3), + fontFamily: MONOSPACE_FONT_FAMILY, + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + }, + header: { + marginBottom: theme.spacing(3), + }, + title: { + fontWeight: 600, + fontFamily: "inherit", + }, + description: { + marginTop: theme.spacing(1), + fontFamily: "inherit", + maxWidth: 420, + }, + }), + { name: "Paywall" }, +) diff --git a/site/src/components/UsersLayout/UsersLayout.tsx b/site/src/components/UsersLayout/UsersLayout.tsx index 10fd7b8e2f144..634f522202c0a 100644 --- a/site/src/components/UsersLayout/UsersLayout.tsx +++ b/site/src/components/UsersLayout/UsersLayout.tsx @@ -4,6 +4,7 @@ import { makeStyles } from "@material-ui/core/styles" import GroupAdd from "@material-ui/icons/GroupAddOutlined" import PersonAdd from "@material-ui/icons/PersonAddOutlined" import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" +import { useFeatureVisibility } from "hooks/useFeatureVisibility" import { usePermissions } from "hooks/usePermissions" import { FC, PropsWithChildren } from "react" import { Link as RouterLink, NavLink, useNavigate } from "react-router-dom" @@ -15,6 +16,7 @@ export const UsersLayout: FC = ({ children }) => { const styles = useStyles() const { createUser: canCreateUser, createGroup: canCreateGroup } = usePermissions() const navigate = useNavigate() + const { rbac: isRBACEnabled } = useFeatureVisibility() return ( <> @@ -32,7 +34,7 @@ export const UsersLayout: FC = ({ children }) => { Create user )} - {canCreateGroup && ( + {canCreateGroup && isRBACEnabled && ( diff --git a/site/src/hooks/useFeatureVisibility.ts b/site/src/hooks/useFeatureVisibility.ts new file mode 100644 index 0000000000000..75533272a1728 --- /dev/null +++ b/site/src/hooks/useFeatureVisibility.ts @@ -0,0 +1,9 @@ +import { useSelector } from "@xstate/react" +import { useContext } from "react" +import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" +import { XServiceContext } from "xServices/StateContext" + +export const useFeatureVisibility = (): Record => { + const xServices = useContext(XServiceContext) + return useSelector(xServices.entitlementsXService, selectFeatureVisibility) +} diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx index 7bad0976d6a2b..c2dbe6c656070 100644 --- a/site/src/pages/GroupsPage/GroupsPage.tsx +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -7,6 +7,7 @@ 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 ArrowRightAltOutlined from "@material-ui/icons/ArrowRightAltOutlined" import AddCircleOutline from "@material-ui/icons/AddCircleOutline" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" import AvatarGroup from "@material-ui/lab/AvatarGroup" @@ -14,8 +15,10 @@ import { useMachine } from "@xstate/react" import { AvatarData } from "components/AvatarData/AvatarData" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { EmptyState } from "components/EmptyState/EmptyState" +import { Stack } from "components/Stack/Stack" import { TableLoader } from "components/TableLoader/TableLoader" import { UserAvatar } from "components/UserAvatar/UserAvatar" +import { useFeatureVisibility } from "hooks/useFeatureVisibility" import { useOrganizationId } from "hooks/useOrganizationId" import { usePermissions } from "hooks/usePermissions" import React from "react" @@ -23,6 +26,7 @@ import { Helmet } from "react-helmet-async" import { Link as RouterLink, useNavigate } from "react-router-dom" import { pageTitle } from "util/page" import { groupsMachine } from "xServices/groups/groupsXService" +import { Paywall } from "components/Paywall/Paywall" export const GroupsPage: React.FC = () => { const organizationId = useOrganizationId() @@ -37,103 +41,137 @@ export const GroupsPage: React.FC = () => { const navigate = useNavigate() const styles = useStyles() const { createGroup: canCreateGroup } = usePermissions() + const { rbac: isRBACEnabled } = useFeatureVisibility() return ( <> {pageTitle("Groups")} - -
- - - Name - Users - - - - - - - - - + + + + + + + + Read the docs + + + } + /> + + + +
+ - - - - - ) - } - /> - + Name + Users + - + + + + + + - - {groups?.map((group) => { - const groupPageLink = `/groups/${group.id}` - - return ( - { - navigate(groupPageLink) - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - navigate(groupPageLink) - } - }} - className={styles.clickableTableRow} - > - - + + + + + + ) + } /> + + - - {group.members.length === 0 && "-"} - - {group.members.map((member) => ( - + {groups?.map((group) => { + const groupPageLink = `/groups/${group.id}` + + return ( + { + navigate(groupPageLink) + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + navigate(groupPageLink) + } + }} + className={styles.clickableTableRow} + > + + - ))} - - + - -
- -
-
- - ) - })} - -
-
-
-
+ + {group.members.length === 0 && "-"} + + {group.members.map((member) => ( + + ))} + + + + +
+ +
+
+ + ) + })} + + + + + + + ) } diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx index e9cbf980cd4de..5a963282d31b6 100644 --- a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -1,4 +1,11 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import ArrowRightAltOutlined from "@material-ui/icons/ArrowRightAltOutlined" import { useMachine } from "@xstate/react" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { Paywall } from "components/Paywall/Paywall" +import { Stack } from "components/Stack/Stack" +import { useFeatureVisibility } from "hooks/useFeatureVisibility" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useOutletContext } from "react-router-dom" @@ -14,11 +21,10 @@ export const TemplatePermissionsPage: FC> = () permissions: Permissions }>() const { template, permissions } = templateContext - if (!template || !permissions) { throw new Error("This page should not be displayed until template or permissions being loaded.") } - + const { rbac: isRBACEnabled } = useFeatureVisibility() const [state, send] = useMachine(templateACLMachine, { context: { templateId: template.id } }) const { templateACL, userToBeUpdated, groupToBeUpdated } = state.context @@ -27,32 +33,64 @@ export const TemplatePermissionsPage: FC> = () {pageTitle(`${template.name} · Permissions`)} - { - 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 }) - }} - onAddGroup={(group, role, reset) => { - send("ADD_GROUP", { group, role, onDone: reset }) - }} - isAddingGroup={state.matches("addingGroup")} - onUpdateGroup={(group, role) => { - send("UPDATE_GROUP_ROLE", { group, role }) - }} - updatingGroup={groupToBeUpdated} - onRemoveGroup={(group) => { - send("REMOVE_GROUP", { group }) - }} - /> + + + + + + + + Read the docs + +
+ } + /> + + + { + 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 }) + }} + onAddGroup={(group, role, reset) => { + send("ADD_GROUP", { group, role, onDone: reset }) + }} + isAddingGroup={state.matches("addingGroup")} + onUpdateGroup={(group, role) => { + send("UPDATE_GROUP_ROLE", { group, role }) + }} + updatingGroup={groupToBeUpdated} + onRemoveGroup={(group) => { + send("REMOVE_GROUP", { group }) + }} + /> + + ) } diff --git a/site/src/xServices/template/templateACLXService.ts b/site/src/xServices/template/templateACLXService.ts index 8af975cb77fa1..fa54f25e0b6b4 100644 --- a/site/src/xServices/template/templateACLXService.ts +++ b/site/src/xServices/template/templateACLXService.ts @@ -71,7 +71,7 @@ export const templateACLMachine = createMachine( }, }, tsTypes: {} as import("./templateACLXService.typegen").Typegen0, - id: "templateUserRoles", + id: "templateACL", initial: "loading", states: { loading: { From cbaafca8f51a9b338c7f4576d46ef5ab00f1245f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 4 Oct 2022 15:59:05 -0400 Subject: [PATCH 107/138] Fix rego -> SQL in acl cases with string literals --- coderd/rbac/query.go | 102 +++++++++++++++++++++-------- coderd/rbac/query_internal_test.go | 19 +++++- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/coderd/rbac/query.go b/coderd/rbac/query.go index d8b1a140e9eb0..077b41f69323d 100644 --- a/coderd/rbac/query.go +++ b/coderd/rbac/query.go @@ -16,6 +16,7 @@ type TermType string const ( VarTypeJsonbTextArray TermType = "jsonb-text-array" VarTypeText TermType = "text" + VarTypeBoolean TermType = "boolean" ) type SQLColumn struct { @@ -218,21 +219,22 @@ func processTerms(expected int, terms []*ast.Term) ([]Term, error) { } func processTerm(term *ast.Term) (Term, error) { - base := base{Rego: term.String()} + termBase := base{Rego: term.String()} switch v := term.Value.(type) { case ast.Boolean: return &termBoolean{ - base: base, + base: termBase, Value: bool(v), }, nil case ast.Ref: obj := &termObject{ - base: base, - Variables: []termVariable{}, + base: termBase, + Path: []Term{}, } var idx int // A ref is a set of terms. If the first term is a var, then the // following terms are the path to the value. + isRef := true var builder strings.Builder for _, term := range v { if idx == 0 { @@ -241,15 +243,37 @@ func processTerm(term *ast.Term) (Term, error) { } } - if _, ok := term.Value.(ast.Ref); ok { + _, newRef := term.Value.(ast.Ref) + if newRef || + // This is an unfortunate hack. To fix this, we need to rewrite + // our SQL config as a path ([]string{"input", "object", "acl_group"}). + // In the rego AST, there is no difference between selecting + // a field by a variable, and selecting a field by a literal (string). + // This was a misunderstanding. + // Example (these are equivalent by AST): + // input.object.acl_group_list['4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75'] + // input.object.acl_group_list.organization_id + // + // This is not equivalent + // input.object.acl_group_list[input.object.organization_id] + // + // If this becomes even more hairy, we should fix the sql config. + builder.String() == "input.object.acl_group_list" || + builder.String() == "input.object.acl_user_list" { + if !newRef { + isRef = false + } // New obj - obj.Variables = append(obj.Variables, termVariable{ - base: base, + obj.Path = append(obj.Path, termVariable{ + base: base{ + Rego: builder.String(), + }, Name: builder.String(), }) builder.Reset() idx = 0 } + if builder.Len() != 0 { builder.WriteString(".") } @@ -257,20 +281,27 @@ func processTerm(term *ast.Term) (Term, error) { idx++ } - obj.Variables = append(obj.Variables, termVariable{ - base: base, - Name: builder.String(), - }) + if isRef { + obj.Path = append(obj.Path, termVariable{ + base: termBase, + Name: builder.String(), + }) + } else { + obj.Path = append(obj.Path, termString{ + base: termBase, + Value: builder.String(), + }) + } return obj, nil case ast.Var: return &termVariable{ Name: trimQuotes(v.String()), - base: base, + base: termBase, }, nil case ast.String: return &termString{ Value: trimQuotes(v.String()), - base: base, + base: termBase, }, nil case ast.Set: slice := v.Slice() @@ -285,7 +316,7 @@ func processTerm(term *ast.Term) (Term, error) { return &termSet{ Value: set, - base: base, + base: termBase, }, nil default: return nil, xerrors.Errorf("invalid term: %T not supported, %q", v, term.String()) @@ -440,7 +471,8 @@ func (t opInternalMember2) SQLString(cfg SQLConfig) string { type Term interface { RegoString() string SQLString(cfg SQLConfig) string - // Eval will evaluate the term + SQLType(cfg SQLConfig) TermType + // EvalTerm will evaluate the term // Terms can eval to any type. The operator/expression will type check. EvalTerm(object Object) interface{} } @@ -471,12 +503,12 @@ func (termString) SQLType(_ SQLConfig) TermType { // term type. type termObject struct { base - Variables []termVariable + Path []Term } func (t termObject) EvalTerm(obj Object) interface{} { - if len(t.Variables) == 0 { - return t.Variables[0].EvalTerm(obj) + if len(t.Path) == 0 { + return t.Path[0].EvalTerm(obj) } panic("no nested structures are supported yet") } @@ -486,30 +518,30 @@ func (t termObject) SQLType(cfg SQLConfig) TermType { // is the resulting type. This is correct for our use case. // Solving this more generally requires a full type system, which is // excessive for our mostly static policy. - return t.Variables[0].SQLType(cfg) + return t.Path[0].SQLType(cfg) } func (t termObject) SQLString(cfg SQLConfig) string { - if len(t.Variables) == 1 { - return t.Variables[0].SQLString(cfg) + if len(t.Path) == 1 { + return t.Path[0].SQLString(cfg) } // Combine the last 2 variables into 1 variable. - end := t.Variables[len(t.Variables)-1] - before := t.Variables[len(t.Variables)-2] + end := t.Path[len(t.Path)-1] + before := t.Path[len(t.Path)-2] // Recursively solve the SQLString by removing the last nested reference. // This continues until we have a single variable. return termObject{ base: t.base, - Variables: append( - t.Variables[:len(t.Variables)-2], + Path: append( + t.Path[:len(t.Path)-2], termVariable{ base: base{ - Rego: before.base.Rego + "[" + end.base.Rego + "]", + Rego: before.RegoString() + "[" + end.RegoString() + "]", }, // Convert the end to SQL string. We evaluate each term // one at a time. - Name: before.Name + "." + end.SQLString(cfg), + Name: before.RegoString() + "." + end.SQLString(cfg), }, ), }.SQLString(cfg) @@ -576,6 +608,17 @@ type termSet struct { Value []Term } +func (t termSet) SQLType(cfg SQLConfig) TermType { + if len(t.Value) == 0 { + return VarTypeText + } + // Without a full type system, let's just assume the type of the first var + // is the resulting type. This is correct for our use case. + // Solving this more generally requires a full type system, which is + // excessive for our mostly static policy. + return t.Value[0].SQLType(cfg) +} + func (t termSet) EvalTerm(obj Object) interface{} { set := make([]interface{}, 0, len(t.Value)) for _, term := range t.Value { @@ -599,6 +642,11 @@ type termBoolean struct { Value bool } +func (termBoolean) SQLType(cfg SQLConfig) TermType { + return VarTypeBoolean + +} + func (t termBoolean) Eval(_ Object) bool { return t.Value } diff --git a/coderd/rbac/query_internal_test.go b/coderd/rbac/query_internal_test.go index 92d8b91543953..72edb45917b59 100644 --- a/coderd/rbac/query_internal_test.go +++ b/coderd/rbac/query_internal_test.go @@ -53,7 +53,7 @@ func TestCompileQuery(t *testing.T) { require.NoError(t, err, "compile") require.Equal(t, `internal.member_2("*", input.object.acl_group_list.allUsers)`, expression.RegoString(), "convert to internal_member") - require.Equal(t, `group_acl->allUsers ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in") + require.Equal(t, `group_acl->'allUsers' ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in") }) t.Run("Complex", func(t *testing.T) { @@ -72,8 +72,8 @@ func TestCompileQuery(t *testing.T) { require.Equal(t, `(organization_id :: text != '' OR `+ `organization_id :: text = ANY(ARRAY ['a','b','c']) OR `+ `organization_id :: text != '' OR `+ - `group_acl->allUsers ? 'read' OR `+ - `user_acl->me ? 'read')`, + `group_acl->'allUsers' ? 'read' OR `+ + `user_acl->'me' ? 'read')`, expression.SQLString(DefaultConfig()), "complex") }) @@ -89,4 +89,17 @@ func TestCompileQuery(t *testing.T) { require.Equal(t, `group_acl->organization_id :: text ? '*'`, expression.SQLString(DefaultConfig()), "set dereference") }) + + t.Run("JsonbLiteralDereference", func(t *testing.T) { + t.Parallel() + expression, err := Compile(®o.PartialQueries{ + Queries: []ast.Body{ + ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, opts), + }, + Support: []*ast.Module{}, + }) + require.NoError(t, err, "compile") + require.Equal(t, `group_acl->'4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75' ? '*'`, + expression.SQLString(DefaultConfig()), "literal dereference") + }) } From cc2138d0dfb617783e2c8ca2013e0d7b43e043e2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Tue, 4 Oct 2022 16:50:26 -0400 Subject: [PATCH 108/138] Use rego to eval, not custom --- coderd/rbac/authz_internal_test.go | 2 +- coderd/rbac/partial.go | 2 +- coderd/rbac/query.go | 155 ++++++++++++----------------- coderd/rbac/query_internal_test.go | 131 ++++++++++++++++-------- 4 files changed, 154 insertions(+), 136 deletions(-) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 2767f26c472c2..6d51583e5a391 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -847,7 +847,7 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes // Ensure the partial can compile to a SQL clause. // This does not guarantee that the clause is valid SQL. - _, err = Compile(partialAuthz.partialQueries) + _, err = Compile(partialAuthz) require.NoError(t, err, "compile prepared authorizer") // Also check the rego policy can form a valid partial query result. diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index 73565a92fd86b..6049b6754fa84 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -29,7 +29,7 @@ type PartialAuthorizer struct { var _ PreparedAuthorized = (*PartialAuthorizer)(nil) func (pa *PartialAuthorizer) Compile() (AuthorizeFilter, error) { - filter, err := Compile(pa.partialQueries) + filter, err := Compile(pa) if err != nil { return nil, xerrors.Errorf("compile: %w", err) } diff --git a/coderd/rbac/query.go b/coderd/rbac/query.go index 077b41f69323d..56ec9555c25e7 100644 --- a/coderd/rbac/query.go +++ b/coderd/rbac/query.go @@ -1,13 +1,13 @@ package rbac import ( + "context" "fmt" "regexp" "strconv" "strings" "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/rego" "golang.org/x/xerrors" ) @@ -17,6 +17,8 @@ const ( VarTypeJsonbTextArray TermType = "jsonb-text-array" VarTypeText TermType = "text" VarTypeBoolean TermType = "boolean" + // VarTypeSkip means this variable does not exist to use. + VarTypeSkip TermType = "skip" ) type SQLColumn struct { @@ -80,19 +82,54 @@ func DefaultConfig() SQLConfig { } } +func NoACLConfig() SQLConfig { + return SQLConfig{ + Variables: []SQLColumn{ + { + RegoMatch: regexp.MustCompile(`^input\.object\.acl_group_list\.?(.*)$`), + ColumnSelect: "", + Type: VarTypeSkip, + }, + { + RegoMatch: regexp.MustCompile(`^input\.object\.acl_user_list\.?(.*)$`), + ColumnSelect: "", + Type: VarTypeSkip, + }, + { + RegoMatch: regexp.MustCompile(`^input\.object\.org_owner$`), + ColumnSelect: "organization_id :: text", + Type: VarTypeText, + }, + { + RegoMatch: regexp.MustCompile(`^input\.object\.owner$`), + ColumnSelect: "owner_id :: text", + Type: VarTypeText, + }, + }, + } +} + type AuthorizeFilter interface { - // RegoString is used in debugging to see the original rego expression. - RegoString() string - // SQLString returns the SQL expression that can be used in a WHERE clause. - SQLString(cfg SQLConfig) string + Expression // Eval is required for the fake in memory database to work. The in memory // database can use this function to filter the results. Eval(object Object) bool } +// expressionTop handles Eval(object Object) for in memory expressions +type expressionTop struct { + Expression + Auth *PartialAuthorizer +} + +func (e expressionTop) Eval(object Object) bool { + return e.Auth.Authorize(context.Background(), object) == nil +} + // Compile will convert a rego query AST into our custom types. The output is // an AST that can be used to generate SQL. -func Compile(partialQueries *rego.PartialQueries) (Expression, error) { +func Compile(pa *PartialAuthorizer) (AuthorizeFilter, error) { + partialQueries := pa.partialQueries if len(partialQueries.Support) > 0 { return nil, xerrors.Errorf("cannot convert support rules, expect 0 found %d", len(partialQueries.Support)) } @@ -129,11 +166,15 @@ func Compile(partialQueries *rego.PartialQueries) (Expression, error) { } builder.WriteString(partialQueries.Queries[i].String()) } - return expOr{ + exp := expOr{ base: base{ Rego: builder.String(), }, Expressions: result, + } + return expressionTop{ + Expression: &exp, + Auth: pa, }, nil } @@ -283,12 +324,16 @@ func processTerm(term *ast.Term) (Term, error) { if isRef { obj.Path = append(obj.Path, termVariable{ - base: termBase, + base: base{ + Rego: builder.String(), + }, Name: builder.String(), }) } else { obj.Path = append(obj.Path, termString{ - base: termBase, + base: base{ + Rego: fmt.Sprintf("%q", builder.String()), + }, Value: builder.String(), }) } @@ -337,7 +382,10 @@ func (b base) RegoString() string { // // Eg: neq(input.object.org_owner, "") AND input.object.org_owner == "foo" type Expression interface { - AuthorizeFilter + // RegoString is used in debugging to see the original rego expression. + RegoString() string + // SQLString returns the SQL expression that can be used in a WHERE clause. + SQLString(cfg SQLConfig) string } type expAnd struct { @@ -357,15 +405,6 @@ func (t expAnd) SQLString(cfg SQLConfig) string { return "(" + strings.Join(exprs, " AND ") + ")" } -func (t expAnd) Eval(object Object) bool { - for _, expr := range t.Expressions { - if !expr.Eval(object) { - return false - } - } - return true -} - type expOr struct { base Expressions []Expression @@ -383,15 +422,6 @@ func (t expOr) SQLString(cfg SQLConfig) string { return "(" + strings.Join(exprs, " OR ") + ")" } -func (t expOr) Eval(object Object) bool { - for _, expr := range t.Expressions { - if expr.Eval(object) { - return true - } - } - return false -} - // Operator joins terms together to form an expression. // Operators are also expressions. // @@ -415,14 +445,6 @@ func (t opEqual) SQLString(cfg SQLConfig) string { return fmt.Sprintf("%s %s %s", t.Terms[0].SQLString(cfg), op, t.Terms[1].SQLString(cfg)) } -func (t opEqual) Eval(object Object) bool { - a, b := t.Terms[0].EvalTerm(object), t.Terms[1].EvalTerm(object) - if t.Not { - return a != b - } - return a == b -} - // opInternalMember2 is checking if the first term is a member of the second term. // The second term is a set or list. type opInternalMember2 struct { @@ -431,20 +453,6 @@ type opInternalMember2 struct { Haystack Term } -func (t opInternalMember2) Eval(object Object) bool { - a, b := t.Needle.EvalTerm(object), t.Haystack.EvalTerm(object) - bset, ok := b.([]interface{}) - if !ok { - return false - } - for _, elem := range bset { - if a == elem { - return true - } - } - return false -} - func (t opInternalMember2) SQLString(cfg SQLConfig) string { if haystack, ok := t.Haystack.(*termObject); ok { // This is a special case where the haystack is a jsonb array. @@ -456,9 +464,14 @@ func (t opInternalMember2) SQLString(cfg SQLConfig) string { // having to add more "if" branches here. // But until we need more cases, our basic type system is ok, and // this is the only case we need to handle. - if haystack.SQLType(cfg) == VarTypeJsonbTextArray { + sqlType := haystack.SQLType(cfg) + if sqlType == VarTypeJsonbTextArray { return fmt.Sprintf("%s ? %s", haystack.SQLString(cfg), t.Needle.SQLString(cfg)) } + + if sqlType == VarTypeSkip { + return "true" + } } return fmt.Sprintf("%s = ANY(%s)", t.Needle.SQLString(cfg), t.Haystack.SQLString(cfg)) @@ -472,9 +485,6 @@ type Term interface { RegoString() string SQLString(cfg SQLConfig) string SQLType(cfg SQLConfig) TermType - // EvalTerm will evaluate the term - // Terms can eval to any type. The operator/expression will type check. - EvalTerm(object Object) interface{} } type termString struct { @@ -482,10 +492,6 @@ type termString struct { Value string } -func (t termString) EvalTerm(_ Object) interface{} { - return t.Value -} - func (t termString) SQLString(_ SQLConfig) string { return "'" + t.Value + "'" } @@ -506,13 +512,6 @@ type termObject struct { Path []Term } -func (t termObject) EvalTerm(obj Object) interface{} { - if len(t.Path) == 0 { - return t.Path[0].EvalTerm(obj) - } - panic("no nested structures are supported yet") -} - func (t termObject) SQLType(cfg SQLConfig) TermType { // Without a full type system, let's just assume the type of the first var // is the resulting type. This is correct for our use case. @@ -552,19 +551,6 @@ type termVariable struct { Name string } -func (t termVariable) EvalTerm(obj Object) interface{} { - switch t.Name { - case "input.object.org_owner": - return obj.OrgID - case "input.object.owner": - return obj.Owner - case "input.object.type": - return obj.Type - default: - return fmt.Sprintf("'Unknown variable %s'", t.Name) - } -} - func (t termVariable) SQLType(cfg SQLConfig) TermType { if col := t.ColumnConfig(cfg); col != nil { return col.Type @@ -619,15 +605,6 @@ func (t termSet) SQLType(cfg SQLConfig) TermType { return t.Value[0].SQLType(cfg) } -func (t termSet) EvalTerm(obj Object) interface{} { - set := make([]interface{}, 0, len(t.Value)) - for _, term := range t.Value { - set = append(set, term.EvalTerm(obj)) - } - - return set -} - func (t termSet) SQLString(cfg SQLConfig) string { elems := make([]string, 0, len(t.Value)) for _, v := range t.Value { @@ -651,10 +628,6 @@ func (t termBoolean) Eval(_ Object) bool { return t.Value } -func (t termBoolean) EvalTerm(_ Object) interface{} { - return t.Value -} - func (t termBoolean) SQLString(_ SQLConfig) string { return strconv.FormatBool(t.Value) } diff --git a/coderd/rbac/query_internal_test.go b/coderd/rbac/query_internal_test.go index 72edb45917b59..712b063787830 100644 --- a/coderd/rbac/query_internal_test.go +++ b/coderd/rbac/query_internal_test.go @@ -1,6 +1,7 @@ package rbac import ( + "context" "testing" "github.com/open-policy-agent/opa/ast" @@ -11,17 +12,10 @@ import ( func TestCompileQuery(t *testing.T) { t.Parallel() - opts := ast.ParserOptions{ - AllFutureKeywords: true, - } + t.Run("EmptyQuery", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - must(ast.ParseBody("")), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, "")) require.NoError(t, err, "compile empty") require.Equal(t, "true", expression.RegoString(), "empty query is rego 'true'") @@ -30,12 +24,7 @@ func TestCompileQuery(t *testing.T) { t.Run("TrueQuery", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - must(ast.ParseBody("true")), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, "true")) require.NoError(t, err, "compile") require.Equal(t, "true", expression.RegoString(), "true query is rego 'true'") @@ -44,12 +33,7 @@ func TestCompileQuery(t *testing.T) { t.Run("ACLIn", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list.allUsers`, opts), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, `"*" in input.object.acl_group_list.allUsers`)) require.NoError(t, err, "compile") require.Equal(t, `internal.member_2("*", input.object.acl_group_list.allUsers)`, expression.RegoString(), "convert to internal_member") @@ -58,16 +42,13 @@ func TestCompileQuery(t *testing.T) { t.Run("Complex", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts), - ast.MustParseBodyWithOpts(`input.object.org_owner in {"a", "b", "c"}`, opts), - ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts), - ast.MustParseBodyWithOpts(`"read" in input.object.acl_group_list.allUsers`, opts), - ast.MustParseBodyWithOpts(`"read" in input.object.acl_user_list.me`, opts), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, + `input.object.org_owner != ""`, + `input.object.org_owner in {"a", "b", "c"}`, + `input.object.org_owner != ""`, + `"read" in input.object.acl_group_list.allUsers`, + `"read" in input.object.acl_user_list.me`, + )) require.NoError(t, err, "compile") require.Equal(t, `(organization_id :: text != '' OR `+ `organization_id :: text = ANY(ARRAY ['a','b','c']) OR `+ @@ -79,12 +60,9 @@ func TestCompileQuery(t *testing.T) { t.Run("SetDereference", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list[input.object.org_owner]`, opts), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, + `"*" in input.object.acl_group_list[input.object.org_owner]`, + )) require.NoError(t, err, "compile") require.Equal(t, `group_acl->organization_id :: text ? '*'`, expression.SQLString(DefaultConfig()), "set dereference") @@ -92,14 +70,81 @@ func TestCompileQuery(t *testing.T) { t.Run("JsonbLiteralDereference", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, opts), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, + `"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, + )) require.NoError(t, err, "compile") require.Equal(t, `group_acl->'4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75' ? '*'`, expression.SQLString(DefaultConfig()), "literal dereference") }) + + t.Run("NoACLColumns", func(t *testing.T) { + t.Parallel() + expression, err := Compile(partialQueries(t, + `"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, + )) + require.NoError(t, err, "compile") + require.Equal(t, `true`, + expression.SQLString(NoACLConfig()), "literal dereference") + }) +} + +func TestEvalQuery(t *testing.T) { + t.Parallel() + + t.Run("GroupACL", func(t *testing.T) { + t.Parallel() + expression, err := Compile(partialQueries(t, + `"read" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, + )) + require.NoError(t, err, "compile") + + result := expression.Eval(Object{ + Owner: "not-me", + OrgID: "random", + Type: "workspace", + ACLUserList: map[string][]Action{}, + ACLGroupList: map[string][]Action{ + "4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75": {"read"}, + }, + }) + require.True(t, result, "eval") + }) +} + +func partialQueries(t *testing.T, queries ...string) *PartialAuthorizer { + opts := ast.ParserOptions{ + AllFutureKeywords: true, + } + + astQueries := make([]ast.Body, 0, len(queries)) + for _, q := range queries { + astQueries = append(astQueries, ast.MustParseBodyWithOpts(q, opts)) + } + + prepareQueries := make([]rego.PreparedEvalQuery, 0, len(queries)) + for _, q := range astQueries { + var prepped rego.PreparedEvalQuery + var err error + if q.String() == "" { + prepped, err = rego.New( + rego.Query("true"), + ).PrepareForEval(context.Background()) + } else { + prepped, err = rego.New( + rego.ParsedQuery(q), + ).PrepareForEval(context.Background()) + } + require.NoError(t, err, "prepare query") + prepareQueries = append(prepareQueries, prepped) + } + return &PartialAuthorizer{ + partialQueries: ®o.PartialQueries{ + Queries: astQueries, + Support: []*ast.Module{}, + }, + preparedQueries: prepareQueries, + input: nil, + alwaysTrue: false, + } } From fd0b43a4dff346890008862988f14801b7c3a713 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 4 Oct 2022 20:52:58 +0000 Subject: [PATCH 109/138] Fix Navbar tests --- site/src/AppRouter.tsx | 5 +++-- site/src/testHelpers/entities.ts | 7 +++++++ site/src/xServices/groups/groupsXService.tsx | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index e37cc81821460..17636960fcdb7 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -9,8 +9,6 @@ import AuditPage from "pages/AuditPage/AuditPage" import GroupsPage from "pages/GroupsPage/GroupsPage" import LoginPage from "pages/LoginPage/LoginPage" import { SetupPage } from "pages/SetupPage/SetupPage" - -import TemplateSummaryPage from "pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage" import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" import TemplatesPage from "pages/TemplatesPage/TemplatesPage" import UsersPage from "pages/UsersPage/UsersPage" @@ -43,6 +41,9 @@ const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")) const TemplatePermissionsPage = lazy( () => import("./pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage"), ) +const TemplateSummaryPage = lazy( + () => import("./pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage"), +) const CreateWorkspacePage = lazy(() => import("./pages/CreateWorkspacePage/CreateWorkspacePage")) const CreateGroupPage = lazy(() => import("./pages/GroupsPage/CreateGroupPage")) const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage")) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index febd93652a39e..1f30705c6e4d3 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -860,3 +860,10 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { user_workspace_count: 0, user_workspace_limit: 100, } + +export const MockGroup: TypesGen.Group = { + id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", + name: "Front-End", + organization_id: MockOrganization.id, + members: [MockUser, MockUser2], +} diff --git a/site/src/xServices/groups/groupsXService.tsx b/site/src/xServices/groups/groupsXService.tsx index 45224713c560d..451bd85fc534f 100644 --- a/site/src/xServices/groups/groupsXService.tsx +++ b/site/src/xServices/groups/groupsXService.tsx @@ -7,6 +7,7 @@ import { assign, createMachine } from "xstate" export const groupsMachine = createMachine( { id: "groupsMachine", + predictableActionArguments: true, schema: { context: {} as { organizationId: string From 599731792b80dcfd0573f5636d64605bf62a2695 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 4 Oct 2022 20:55:54 +0000 Subject: [PATCH 110/138] Fix UsersPage test --- site/src/pages/UsersPage/UsersPage.test.tsx | 90 +++++---------------- 1 file changed, 22 insertions(+), 68 deletions(-) diff --git a/site/src/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 316969271e3ce..f14bddbb6aaca 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -14,7 +14,7 @@ import { MockAuditorRole, MockUser, MockUser2, - render, + renderWithAuth, SuspendedMockUser, } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" @@ -22,6 +22,15 @@ import { Language as UsersPageLanguage, UsersPage } from "./UsersPage" const { t } = i18n +const renderPage = () => { + return renderWithAuth( + <> + + + , + ) +} + const suspendUser = async (setupActionSpies: () => void) => { const user = userEvent.setup() // Get the first user in the table @@ -167,7 +176,7 @@ const updateUserRole = async (setupActionSpies: () => void, role: Role) => { describe("UsersPage", () => { it("shows users", async () => { - render() + renderPage() const users = await screen.findAllByText(/.*@coder.com/) expect(users.length).toEqual(3) }) @@ -175,12 +184,7 @@ describe("UsersPage", () => { describe("suspend user", () => { describe("when it is success", () => { it("shows a success message and refresh the page", async () => { - render( - <> - - - , - ) + renderPage() await suspendUser(() => { jest.spyOn(API, "suspendUser").mockResolvedValueOnce(MockUser) @@ -200,12 +204,7 @@ describe("UsersPage", () => { }) describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await suspendUser(() => { jest.spyOn(API, "suspendUser").mockRejectedValueOnce({}) @@ -224,12 +223,7 @@ describe("UsersPage", () => { describe("delete user", () => { describe("when it is success", () => { it("shows a success message and refresh the page", async () => { - render( - <> - - - , - ) + renderPage() await deleteUser(() => { jest.spyOn(API, "deleteUser").mockResolvedValueOnce(undefined) @@ -252,12 +246,7 @@ describe("UsersPage", () => { }) describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await deleteUser(() => { jest.spyOn(API, "deleteUser").mockRejectedValueOnce({}) @@ -276,12 +265,7 @@ describe("UsersPage", () => { describe("activate user", () => { describe("when user is successfully activated", () => { it("shows a success message and refreshes the page", async () => { - render( - <> - - - , - ) + renderPage() await activateUser(() => { jest.spyOn(API, "activateUser").mockResolvedValueOnce(SuspendedMockUser) @@ -300,12 +284,7 @@ describe("UsersPage", () => { }) describe("when activation fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await activateUser(() => { jest.spyOn(API, "activateUser").mockRejectedValueOnce({}) @@ -324,12 +303,7 @@ describe("UsersPage", () => { describe("reset user password", () => { describe("when it is success", () => { it("shows a success message", async () => { - render( - <> - - - , - ) + renderPage() await resetUserPassword(() => { jest.spyOn(API, "updateUserPassword").mockResolvedValueOnce(undefined) @@ -348,12 +322,7 @@ describe("UsersPage", () => { }) describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await resetUserPassword(() => { jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({}) @@ -375,12 +344,7 @@ describe("UsersPage", () => { describe("Update user role", () => { describe("when it is success", () => { it("updates the roles", async () => { - render( - <> - - - , - ) + renderPage() const { rolesMenuTrigger } = await updateUserRole(() => { jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({ @@ -404,12 +368,7 @@ describe("UsersPage", () => { describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await updateUserRole(() => { jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({}) @@ -429,12 +388,7 @@ describe("UsersPage", () => { ) }) it("shows an error from the backend", async () => { - render( - <> - - - , - ) + renderPage() server.use( rest.put(`/api/v2/users/${MockUser.id}/roles`, (req, res, ctx) => { From 7c76bc01999154f269d0a50c08ceacda96fdbb1a Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 4 Oct 2022 21:38:15 +0000 Subject: [PATCH 111/138] Fix Template tests --- site/src/AppRouter.tsx | 31 ++++++++++----- .../TemplateLayout/TemplateLayout.tsx | 33 +++++++++++----- .../TemplatePermissionsPage.tsx | 11 ++---- .../TemplateSummaryPage.test.tsx | 27 +++++++------ .../TemplateSummaryPage.tsx | 9 ++--- site/src/testHelpers/handlers.ts | 2 +- t | 38 +++++++++++++++++++ 7 files changed, 106 insertions(+), 45 deletions(-) create mode 100644 t diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 17636960fcdb7..f642937b8e28d 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -99,16 +99,27 @@ export const AppRouter: FC = () => { } /> - - - - } - > - } /> - } /> + + + + + + + } + /> + + + + + + } + /> { const { template } = useParams() @@ -32,13 +39,19 @@ const useTemplateName = () => { return template } -const Language = { - settingsButton: "Settings", - createButton: "Create workspace", - noDescription: "", +type TemplateLayoutContextValue = { context: TemplateContext; permissions: Permissions } + +const TemplateLayoutContext = createContext(undefined) + +export const useTemplateLayoutContext = (): TemplateLayoutContextValue => { + const context = useContext(TemplateLayoutContext) + if (!context) { + throw new Error("useTemplateLayoutContext only can be used inside of TemplateLayout") + } + return context } -export const TemplateLayout: FC = () => { +export const TemplateLayout: FC = ({ children }) => { const styles = useStyles() const organizationId = useOrganizationId() const templateName = useTemplateName() @@ -165,7 +178,9 @@ export const TemplateLayout: FC = () => { - + + {children} + > = () => { - const { templateContext } = useOutletContext<{ - templateContext: TemplateContext - permissions: Permissions - }>() - const { template, permissions } = templateContext + const { context } = useTemplateLayoutContext() + const { template, permissions } = context if (!template || !permissions) { throw new Error("This page should not be displayed until template or permissions being loaded.") } diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx index 3a4bfbba3c066..fd2945506365a 100644 --- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, screen } from "@testing-library/react" +import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import { rest } from "msw" import { ResizeObserver } from "resize-observer" import { @@ -18,26 +19,31 @@ Object.defineProperty(window, "ResizeObserver", { value: ResizeObserver, }) +const renderPage = () => + renderWithAuth( + + + , + { + route: `/templates/${MockTemplate.id}`, + path: "/templates/:template", + }, + ) + 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(, { - route: `/templates/${MockTemplate.id}`, - path: "/templates/:template", - }) + renderPage() await screen.findByText(MockTemplate.name) screen.getByTestId("markdown") screen.getByText(MockWorkspaceResource.name) screen.queryAllByText(`${MockTemplateVersion.name}`).length }) it("allows an admin to delete a template", async () => { - renderWithAuth(, { - route: `/templates/${MockTemplate.id}`, - path: "/templates/:template", - }) + renderPage() const dropdownButton = await screen.findByLabelText("open-dropdown") fireEvent.click(dropdownButton) const deleteButton = await screen.findByText("Delete") @@ -50,10 +56,7 @@ describe("TemplateSummaryPage", () => { return res(ctx.status(200), ctx.json(MockMemberPermissions)) }), ) - renderWithAuth(, { - route: `/templates/${MockTemplate.id}`, - path: "/templates/:template", - }) + renderPage() const dropdownButton = screen.queryByLabelText("open-dropdown") expect(dropdownButton).toBe(null) }) diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx index cc7efe62a2bfc..6a5718e613742 100644 --- a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx @@ -1,12 +1,11 @@ +import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout" 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 { templateContext } = useOutletContext<{ templateContext: TemplateContext }>() +export const TemplateSummaryPage: FC = () => { + const { context } = useTemplateLayoutContext() const { template, activeTemplateVersion, @@ -14,7 +13,7 @@ export const TemplateSummaryPage: FC> = () => { templateVersions, deleteTemplateError, templateDAUs, - } = templateContext + } = context if (!template || !activeTemplateVersion || !templateResources) { throw new Error( diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 03754369e52c6..3915cce1eda3c 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -81,7 +81,7 @@ export const handlers = [ return res(ctx.status(200), ctx.json(M.MockSiteRoles)) }), rest.post("/api/v2/authcheck", async (req, res, ctx) => { - const permissions = Object.keys(permissionsToCheck) + const permissions = [...Object.keys(permissionsToCheck), "canUpdateTemplate"] const response = permissions.reduce((obj, permission) => { return { ...obj, diff --git a/t b/t new file mode 100644 index 0000000000000..a5221f80459be --- /dev/null +++ b/t @@ -0,0 +1,38 @@ +usage: git tag [-a | -s | -u ] [-f] [-m | -F ] + [] + or: git tag -d ... + or: git tag -l [-n[]] [--contains ] [--no-contains ] [--points-at ] + [--format=] [--[no-]merged []] [...] + or: git tag -v [--format=] ... + + -l, --list list tag names + -n[] print lines of each tag message + -d, --delete delete tags + -v, --verify verify tags + +Tag creation options + -a, --annotate annotated tag, needs a message + -m, --message + tag message + -F, --file read message from file + -e, --edit force edit of tag message + -s, --sign annotated and GPG-signed tag + --cleanup how to strip spaces and #comments from message + -u, --local-user + use another key to sign the tag + -f, --force replace the tag if exists + --create-reflog create a reflog + +Tag listing options + --column[=