diff --git a/coderd/coderd.go b/coderd/coderd.go index 050d16b86911b..a526354a4289b 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -320,6 +320,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/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index fd31cd55230b9..52bb2a08a385c 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -394,6 +394,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/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index bdcb61505e376..38a21e09659a4 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" @@ -997,6 +998,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 } @@ -1229,6 +1231,59 @@ 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) GetTemplateUserRoles(_ 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() @@ -1708,7 +1763,9 @@ 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) return template, nil } diff --git a/coderd/database/db.go b/coderd/database/db.go index 0a9e8928df253..fa5af737ce554 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" ) @@ -30,24 +31,34 @@ 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: sdb, + db: dbx, + sdb: dbx, } } +// 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. @@ -58,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/dump.sql b/coderd/database/dump.sql index 81557a9022d00..0e648293926c7 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -83,6 +83,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' @@ -285,7 +291,9 @@ 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, + is_private boolean DEFAULT false 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/000051_template_acl.down.sql b/coderd/database/migrations/000051_template_acl.down.sql new file mode 100644 index 0000000000000..55019e33d326d --- /dev/null +++ b/coderd/database/migrations/000051_template_acl.down.sql @@ -0,0 +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/000051_template_acl.up.sql b/coderd/database/migrations/000051_template_acl.up.sql new file mode 100644 index 0000000000000..5f98045f53cbe --- /dev/null +++ b/coderd/database/migrations/000051_template_acl.up.sql @@ -0,0 +1,12 @@ +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', + 'write', + 'admin' +); + +COMMIT; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index f6e28bd5dc824..b9a66b9d04f1c 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -1,9 +1,61 @@ 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 + 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())) + } + + 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: + // TODO: Why does rbac.Wildcard not work here? + return []rbac.Action{rbac.ActionRead, rbac.ActionUpdate, rbac.ActionCreate, rbac.ActionDelete} + } + return nil +} + func (s APIKeyScope) ToRBAC() rbac.Scope { switch s { case APIKeyScopeAll: @@ -16,12 +68,16 @@ func (s APIKeyScope) ToRBAC() rbac.Scope { } func (t Template) RBACObject() rbac.Object { - return rbac.ResourceTemplate.InOrg(t.OrganizationID) + obj := rbac.ResourceTemplate + if t.IsPrivate { + obj = rbac.ResourceTemplatePrivate + } + return obj.InOrg(t.OrganizationID).WithACLUserList(t.UserACL().Actions()) } -func (t TemplateVersion) RBACObject() rbac.Object { +func (TemplateVersion) RBACObject(template Template) rbac.Object { // Just use the parent template resource for controlling versions - return rbac.ResourceTemplate.InOrg(t.OrganizationID) + return template.RBACObject() } func (w Workspace) RBACObject() rbac.Object { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go new file mode 100644 index 0000000000000..8729c726a4fe3 --- /dev/null +++ b/coderd/database/modelqueries.go @@ -0,0 +1,83 @@ +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 + GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]TemplateUser, 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 +} + +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 b5d48bf6c0c32..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" ) @@ -293,6 +294,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 ( @@ -501,6 +522,8 @@ 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"` + IsPrivate bool `db:"is_private" json:"is_private"` } type TemplateVersion struct { @@ -523,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/querier.go b/coderd/database/querier.go index 0b38708a2497e..7ceb368e2d11e 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. // @@ -156,4 +156,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 acbd6df2b46d4..a132b58443a32 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -2123,7 +2123,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, is_private FROM templates WHERE @@ -2149,13 +2149,15 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.MinAutostartInterval, &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 + 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 @@ -2189,12 +2191,14 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.MinAutostartInterval, &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 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 ` @@ -2221,6 +2225,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.IsPrivate, ); err != nil { return nil, err } @@ -2237,7 +2243,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, is_private FROM templates WHERE @@ -2299,6 +2305,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.IsPrivate, ); err != nil { return nil, err } @@ -2327,10 +2335,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 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, is_private ` type InsertTemplateParams struct { @@ -2346,6 +2355,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"` + IsPrivate bool `db:"is_private" json:"is_private"` } func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParams) (Template, error) { @@ -2362,6 +2372,7 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam arg.MinAutostartInterval, arg.CreatedBy, arg.Icon, + arg.IsPrivate, ) var i Template err := row.Scan( @@ -2378,6 +2389,8 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.IsPrivate, ) return i, err } @@ -2433,11 +2446,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 + 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 { @@ -2448,6 +2462,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) { @@ -2459,6 +2474,7 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl arg.MinAutostartInterval, arg.Name, arg.Icon, + arg.IsPrivate, ) var i Template err := row.Scan( @@ -2475,6 +2491,8 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.IsPrivate, ) return i, err } @@ -3027,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, @@ -3057,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, @@ -3175,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, @@ -3219,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, @@ -3254,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) { @@ -3272,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 @@ -3284,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, @@ -3367,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, @@ -3402,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, @@ -3437,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/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/database/sqlc.yaml b/coderd/database/sqlc.yaml index 579264d8a499a..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 @@ -35,3 +39,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..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) @@ -45,8 +47,20 @@ func ExtractTemplateVersionParam(db database.Store) func(http.Handler) http.Hand return } + template, err := db.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + 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(ctx, templateParamContextKey{}, template) + 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/authz.go b/coderd/rbac/authz.go index ee7ddb25cae60..c2d8ead280601 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -22,8 +22,9 @@ 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, scope Scope, action Action, objects []O) ([]O, error) { ctx, span := tracing.StartSpan(ctx, trace.WithAttributes( attribute.String("subject_id", subjID), @@ -36,20 +37,26 @@ 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, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, action, objectType) - if err != nil { - return nil, xerrors.Errorf("prepare: %w", err) - } + 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, action, objectType) + if err != nil { + return nil, xerrors.Errorf("prepare: %w", err) + } + prepared[objectType] = objectAuth + } - for _, object := range objects { 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) - } - err := prepared.Authorize(ctx, rbacObj) + 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 24bb5a90ea468..c14be7a065d94 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{}, ScopeAll, 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. @@ -204,6 +195,38 @@ 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, "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..1cc2fbb93dd97 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,14 @@ 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}, + ResourceTemplatePrivate.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 +123,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}, }), } }, @@ -177,6 +178,11 @@ var ( ResourceType: ResourceOrgRoleAssignment.Type, Action: ActionRead, }, + { + // Can read public templates. + ResourceType: ResourceTemplate.Type, + Action: ActionRead, + }, }, }, } @@ -390,14 +396,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 _, act := range actions { act := act list = append(list, Permission{ Negate: false, - ResourceType: k.Type, + ResourceType: k, Action: act, }) } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index f56804b774cc5..460bf877c335d 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -54,6 +54,10 @@ var ( Type: "template", } + ResourceTemplatePrivate = Object{ + Type: "template_private", + } + ResourceFile = Object{ Type: "file", } @@ -147,7 +151,9 @@ type Object struct { // Type is "workspace", "project", "app", etc Type string `json:"type"` - // TODO: SharedUsers? + + // map[string][]Action + ACLUserList map[string][]Action ` json:"acl_user_list"` } func (z Object) RBACObject() Object { @@ -180,3 +186,13 @@ func (z Object) WithOwner(ownerID string) Object { Type: z.Type, } } + +// 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, + } +} diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index 59e68c202d94b..01f4ae756b1fe 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -101,6 +101,7 @@ func newSubPartialAuthorizer(ctx context.Context, subjectID string, roles []Role rego.Unknowns([]string{ "input.object.owner", "input.object.org_owner", + "input.object.acl_user_list", }), rego.Input(input), ).Partial(ctx) diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 4b94eafa91eb5..9b0761edae247 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_user_list -i input.json # # This policy is specifically constructed to compress to a set of queries if the @@ -156,3 +156,15 @@ allow { org_mem user = 1 } + +# ACL Allow +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 +} + +# ACL wildcard allow +allow { + "*" in input.object.acl_user_list[input.subject.id] +} 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{}, diff --git a/coderd/templates.go b/coderd/templates.go index 96bf39dd268de..1a613bf0f111b 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -259,6 +259,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) @@ -458,6 +459,14 @@ 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. + if (len(req.UserPerms) > 0 || req.IsPrivate != nil) && + !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."}) @@ -466,13 +475,27 @@ 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 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 + } } if len(validErrs) > 0 { @@ -485,9 +508,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 } @@ -503,7 +526,9 @@ 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 && + (req.IsPrivate == nil || req.IsPrivate != nil && *req.IsPrivate == template.IsPrivate) { return nil } @@ -513,6 +538,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 @@ -523,8 +549,29 @@ 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() + for k, v := range req.UserPerms { + // A user with an empty string implies + // deletion. + if v == "" { + 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) + } + } - updated, err = s.UpdateTemplateMetaByID(r.Context(), database.UpdateTemplateMetaByIDParams{ + updated, err = tx.UpdateTemplateMetaByID(r.Context(), database.UpdateTemplateMetaByIDParams{ ID: template.ID, UpdatedAt: database.Now(), Name: name, @@ -532,6 +579,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 @@ -580,6 +628,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 @@ -778,5 +867,64 @@ func (api *API) convertTemplate( MinAutostartIntervalMillis: time.Duration(template.MinAutostartInterval).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: createdByName, + UserRoles: convertTemplateACL(template.UserACL()), + IsPrivate: template.IsPrivate, + } +} + +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) } + + 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 validateTemplateRole(role codersdk.TemplateRole) error { + dbRole := convertSDKTemplateRole(role) + if dbRole == "" && role != codersdk.TemplateRoleDeleted { + return xerrors.Errorf("role %q is not a valid Template role", role) + } + + 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 5052e4f9ba467..5d4ffa2cc9fc8 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}) @@ -260,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 be able to read both templates") + }) } func TestTemplateByOrganizationAndName(t *testing.T) { @@ -315,6 +406,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 +423,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 +434,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) @@ -519,6 +613,248 @@ 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) + }) + + // 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() + + 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) { @@ -561,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() @@ -667,3 +1049,7 @@ func TestTemplateDAUs(t *testing.T) { database.Now(), workspaces[0].LastUsedAt, time.Minute, ) } + +func boolPtr(b bool) *bool { + return &b +} diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 69a98e03b352c..b883606a38752 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -23,8 +23,12 @@ 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 +55,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 +104,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 +155,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 +202,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 +369,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 } @@ -827,8 +846,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 } @@ -849,8 +872,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/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/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 3af058cc19719..9fe99ddcc548d 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -23,25 +23,43 @@ 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"` + IsPrivate bool `json:"is_private"` } type UpdateActiveTemplateVersion struct { ID uuid.UUID `json:"id" validate:"required"` } +type TemplateRole string + +const ( + TemplateRoleAdmin TemplateRole = "admin" + TemplateRoleWrite TemplateRole = "write" + TemplateRoleRead TemplateRole = "read" + 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"` - Icon string `json:"icon,omitempty"` - MaxTTLMillis int64 `json:"max_ttl_ms,omitempty"` - MinAutostartIntervalMillis int64 `json:"min_autostart_interval_ms,omitempty"` + 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. @@ -86,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 { 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/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"}}, }, }, }) 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, diff --git a/go.mod b/go.mod index 07d353cf3ed8d..2369428249e53 100644 --- a/go.mod +++ b/go.mod @@ -165,6 +165,8 @@ require ( tailscale.com v1.30.0 ) +require github.com/jmoiron/sqlx v1.3.5 + 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 991bc738fb5f7..ab9f25cd045cc 100644 --- a/go.sum +++ b/go.sum @@ -694,6 +694,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= @@ -1096,6 +1097,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= @@ -1273,6 +1276,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= 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 response = await axios.get(`/api/v2/templates/${templateId}/user-roles`) + return response.data +} 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" diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx new file mode 100644 index 0000000000000..0b5ce9e83c775 --- /dev/null +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -0,0 +1,231 @@ +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 { useMachine, useSelector } from "@xstate/react" +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" +import { DeleteButton } from "components/DropdownButton/ActionCtas" +import { DropdownButton } from "components/DropdownButton/DropdownButton" +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" +import { selectPermissions } from "xServices/auth/authSelectors" +import { XServiceContext } from "xServices/StateContext" +import { templateMachine } from "xServices/template/templateXService" +import { Margins } from "../../components/Margins/Margins" +import { Stack } from "../../components/Stack/Stack" + +const useTemplateName = () => { + 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/hooks/useMe.ts b/site/src/hooks/useMe.ts new file mode 100644 index 0000000000000..c40fa474b9663 --- /dev/null +++ b/site/src/hooks/useMe.ts @@ -0,0 +1,16 @@ +import { useSelector } from "@xstate/react" +import { User } from "api/typesGenerated" +import { useContext } from "react" +import { selectUser } from "xServices/auth/authSelectors" +import { XServiceContext } from "xServices/StateContext" + +export const useMe = (): User => { + 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 new file mode 100644 index 0000000000000..94fbd73622200 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPage.tsx @@ -0,0 +1,57 @@ +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, deleteTemplateError } = templateContext + + if (!template) { + throw new Error( + "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.", + ) + } + + const [state, send] = useMachine(templateUsersMachine, { context: { templateId: template.id } }) + 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 ( + <> + + Codestin Search App + + { + send("ADD_USER", { user, role, onDone: reset }) + }} + isAddingUser={state.matches("addingUser")} + onUpdateUser={(user, role) => { + send("UPDATE_USER_ROLE", { user, role }) + }} + updatingUser={userToBeUpdated} + onRemoveUser={(user) => { + send("REMOVE_USER", { user }) + }} + /> + + ) +} + +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..01ab29594953c --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateCollaboratorsPage/TemplateCollaboratorsPageView.tsx @@ -0,0 +1,314 @@ +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 { useMachine } from "@xstate/react" +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 { TableRowMenu } from "components/TableRowMenu/TableRowMenu" +import debounce from "just-debounce-it" +import { ChangeEvent, FC, useState } from "react" +import { searchUserMachine } from "xServices/users/searchUserXService" + +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") + } + + return ( +
{ + e.preventDefault() + + if (selectedUser && selectedRole) { + onSubmit(selectedUser, selectedRole, resetValues) + } + }} + > + + { + setIsAutocompleteOpen(true) + }} + onClose={() => { + setIsAutocompleteOpen(false) + }} + onChange={(event, 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} + + ), + }} + /> + )} + /> + + + + } + loading={isLoading} + > + Add collaborator + + +
+ ) +} + +export interface TemplateCollaboratorsPageViewProps { + deleteTemplateError: Error | unknown + 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, + updatingUser, + onUpdateUser, + canUpdateUsers, + onRemoveUser, +}) => { + const styles = useStyles() + const deleteError = deleteTemplateError ? ( + + ) : null + + return ( + + {deleteError} + + + + + + User + Role + + + + + + + + + + + + + + + + 0)}> + {templateUsers?.map((user) => ( + + + + ) : null + } + /> + + + {canUpdateUsers ? ( + + ) : ( + user.role + )} + + + {canUpdateUsers && ( + + onRemoveUser(user), + }, + ]} + /> + + )} + + ))} + + + +
+
+
+ ) +} + +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, + }, + + avatar: { + width: theme.spacing(4.5), + 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/pages/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatePage/TemplatePage.tsx deleted file mode 100644 index 95141c53841f2..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePage.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useMachine, useSelector } from "@xstate/react" -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" -import { FC, useContext } from "react" -import { Helmet } from "react-helmet-async" -import { useTranslation } from "react-i18next" -import { Navigate, useParams } from "react-router-dom" -import { selectPermissions } from "xServices/auth/authSelectors" -import { XServiceContext } from "xServices/StateContext" -import { Loader } from "../../components/Loader/Loader" -import { useOrganizationId } from "../../hooks/useOrganizationId" -import { pageTitle } from "../../util/page" -import { templateMachine } from "../../xServices/template/templateXService" -import { TemplatePageView } from "./TemplatePageView" - -const useTemplateName = () => { - const { template } = useParams() - - if (!template) { - throw new Error("No template found in the URL") - } - - return template -} - -export const TemplatePage: FC> = () => { - const organizationId = useOrganizationId() - const { t } = useTranslation("templatePage") - const templateName = useTemplateName() - const [templateState, templateSend] = useMachine(templateMachine, { - context: { - templateName, - organizationId, - }, - }) - - const { - template, - activeTemplateVersion, - templateResources, - templateVersions, - deleteTemplateError, - templateDAUs, - } = templateState.context - const xServices = useContext(XServiceContext) - const permissions = useSelector(xServices.authXService, selectPermissions) - const isLoading = - !template || !activeTemplateVersion || !templateResources || !permissions || !templateDAUs - - const handleDeleteTemplate = () => { - templateSend("DELETE") - } - - if (isLoading) { - return - } - - if (templateState.matches("deleted")) { - return - } - - return ( - <> - - Codestin Search App - - - - { - templateSend("CONFIRM_DELETE") - }} - onCancel={() => { - templateSend("CANCEL_DELETE") - }} - /> - - ) -} - -export default TemplatePage diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx deleted file mode 100644 index 82414c32bba38..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import Avatar from "@material-ui/core/Avatar" -import Button from "@material-ui/core/Button" -import Link from "@material-ui/core/Link" -import { makeStyles } from "@material-ui/core/styles" -import AddCircleOutline from "@material-ui/icons/AddCircleOutline" -import SettingsOutlined from "@material-ui/icons/SettingsOutlined" -import { DeleteButton } from "components/DropdownButton/ActionCtas" -import { DropdownButton } from "components/DropdownButton/DropdownButton" -import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" -import { Markdown } from "components/Markdown/Markdown" -import frontMatter from "front-matter" -import { FC } from "react" -import { Link as RouterLink } from "react-router-dom" -import { firstLetter } from "util/firstLetter" -import { - Template, - TemplateDAUsResponse, - TemplateVersion, - WorkspaceResource, -} from "../../api/typesGenerated" -import { Margins } from "../../components/Margins/Margins" -import { - PageHeader, - PageHeaderSubtitle, - PageHeaderTitle, -} from "../../components/PageHeader/PageHeader" -import { Stack } from "../../components/Stack/Stack" -import { TemplateResourcesTable } from "../../components/TemplateResourcesTable/TemplateResourcesTable" -import { TemplateStats } from "../../components/TemplateStats/TemplateStats" -import { VersionsTable } from "../../components/VersionsTable/VersionsTable" -import { WorkspaceSection } from "../../components/WorkspaceSection/WorkspaceSection" -import { DAUChart } from "./DAUChart" - -const Language = { - settingsButton: "Settings", - createButton: "Create workspace", - noDescription: "", - readmeTitle: "README", - resourcesTitle: "Resources", - versionsTitle: "Version history", -} - -export interface TemplatePageViewProps { - template: Template - activeTemplateVersion: TemplateVersion - templateResources: WorkspaceResource[] - templateVersions?: TemplateVersion[] - templateDAUs?: TemplateDAUsResponse - handleDeleteTemplate: (templateId: string) => void - deleteTemplateError: Error | unknown - canDeleteTemplate: boolean -} - -export const TemplatePageView: FC> = ({ - template, - activeTemplateVersion, - templateResources, - templateVersions, - templateDAUs, - handleDeleteTemplate, - deleteTemplateError, - canDeleteTemplate, -}) => { - const styles = useStyles() - const readme = frontMatter(activeTemplateVersion.readme) - const hasIcon = template.icon && template.icon !== "" - - const deleteError = deleteTemplateError ? ( - - ) : ( - <> - ) - - const getStartedResources = (resources: WorkspaceResource[]) => { - return resources.filter((resource) => resource.workspace_transition === "start") - } - - const createWorkspaceButton = (className?: string) => ( - - - - ) - - return ( - - <> - - - - - - {canDeleteTemplate ? ( - handleDeleteTemplate(template.id)} /> - ), - }, - ]} - canCancel={false} - /> - ) : ( - createWorkspaceButton() - )} - - } - > - -
- {hasIcon ? ( -
- -
- ) : ( - {firstLetter(template.name)} - )} -
-
- {template.name} - - {template.description === "" ? Language.noDescription : template.description} - -
-
-
- - - {deleteError} - {templateDAUs && } - - - -
- {readme.body} -
-
- - - -
- -
- ) -} - -export const useStyles = makeStyles((theme) => { - return { - actionButton: { - border: "none", - borderRadius: `${theme.shape.borderRadius}px 0px 0px ${theme.shape.borderRadius}px`, - }, - readmeContents: { - margin: 0, - }, - markdownWrapper: { - background: theme.palette.background.paper, - padding: theme.spacing(3, 4), - }, - versionsTableContents: { - margin: 0, - }, - pageTitle: { - alignItems: "center", - }, - avatar: { - width: theme.spacing(6), - height: theme.spacing(6), - fontSize: theme.spacing(3), - }, - iconWrapper: { - width: theme.spacing(6), - height: theme.spacing(6), - "& img": { - width: "100%", - }, - }, - } -}) diff --git a/site/src/pages/TemplatePage/DAUChart.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.test.tsx similarity index 100% rename from site/src/pages/TemplatePage/DAUChart.test.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.test.tsx diff --git a/site/src/pages/TemplatePage/DAUChart.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx similarity index 98% rename from site/src/pages/TemplatePage/DAUChart.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx index 5e12717b75d79..3578fd9defc0e 100644 --- a/site/src/pages/TemplatePage/DAUChart.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/DAUChart.tsx @@ -1,6 +1,6 @@ -import useTheme from "@material-ui/styles/useTheme" - import { Theme } from "@material-ui/core/styles" +import useTheme from "@material-ui/styles/useTheme" +import * as TypesGen from "api/typesGenerated" import { BarElement, CategoryScale, @@ -20,7 +20,6 @@ import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection" import dayjs from "dayjs" import { FC } from "react" import { Line } from "react-chartjs-2" -import * as TypesGen from "../../api/typesGenerated" ChartJS.register( CategoryScale, diff --git a/site/src/pages/TemplatePage/TemplatePage.test.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx similarity index 87% rename from site/src/pages/TemplatePage/TemplatePage.test.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx index 27252bdd7bdfd..98abe02c60b67 100644 --- a/site/src/pages/TemplatePage/TemplatePage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx @@ -1,8 +1,6 @@ import { fireEvent, screen } from "@testing-library/react" import { rest } from "msw" import { ResizeObserver } from "resize-observer" -import { server } from "testHelpers/server" -import * as CreateDayString from "util/createDayString" import { MockMemberPermissions, MockTemplate, @@ -10,8 +8,10 @@ import { MockUser, MockWorkspaceResource, renderWithAuth, -} from "../../testHelpers/renderHelpers" -import { TemplatePage } from "./TemplatePage" +} from "testHelpers/renderHelpers" +import { server } from "testHelpers/server" +import * as CreateDayString from "util/createDayString" +import { TemplateSummaryPage } from "./TemplateSummaryPage" jest.mock("remark-gfm", () => jest.fn()) @@ -19,13 +19,13 @@ Object.defineProperty(window, "ResizeObserver", { value: ResizeObserver, }) -describe("TemplatePage", () => { +describe("TemplateSummaryPage", () => { it("shows the template name, readme and resources", async () => { // Mocking the dayjs module within the createDayString file const mock = jest.spyOn(CreateDayString, "createDayString") mock.mockImplementation(() => "a minute ago") - renderWithAuth(, { + renderWithAuth(, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template", }) @@ -35,7 +35,7 @@ describe("TemplatePage", () => { screen.queryAllByText(`${MockTemplateVersion.name}`).length }) it("allows an admin to delete a template", async () => { - renderWithAuth(, { + renderWithAuth(, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template", }) @@ -51,7 +51,7 @@ describe("TemplatePage", () => { return res(ctx.status(200), ctx.json(MockMemberPermissions)) }), ) - renderWithAuth(, { + renderWithAuth(, { route: `/templates/${MockTemplate.id}`, path: "/templates/:template", }) diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx new file mode 100644 index 0000000000000..cc7efe62a2bfc --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx @@ -0,0 +1,42 @@ +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 }>() + const { + template, + activeTemplateVersion, + templateResources, + templateVersions, + deleteTemplateError, + templateDAUs, + } = templateContext + + if (!template || !activeTemplateVersion || !templateResources) { + throw new Error( + "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.", + ) + } + + return ( + <> + + Codestin Search App + + + + ) +} + +export default TemplateSummaryPage diff --git a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx similarity index 78% rename from site/src/pages/TemplatePage/TemplatePageView.stories.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx index 10a12b701a672..78f91fe7cbeee 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx @@ -1,13 +1,15 @@ import { Story } from "@storybook/react" -import * as Mocks from "../../testHelpers/renderHelpers" -import { TemplatePageView, TemplatePageViewProps } from "./TemplatePageView" +import * as Mocks from "testHelpers/renderHelpers" +import { TemplateSummaryPageView, TemplateSummaryPageViewProps } from "./TemplateSummaryPageView" export default { - title: "pages/TemplatePageView", - component: TemplatePageView, + title: "pages/TemplateSummaryPageView", + component: TemplateSummaryPageView, } -const Template: Story = (args) => +const Template: Story = (args) => ( + +) export const Example = Template.bind({}) Example.args = { diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx new file mode 100644 index 0000000000000..54a9ddad2cff2 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx @@ -0,0 +1,81 @@ +import { makeStyles } from "@material-ui/core/styles" +import { + Template, + TemplateDAUsResponse, + TemplateVersion, + WorkspaceResource, +} from "api/typesGenerated" +import { ErrorSummary } from "components/ErrorSummary/ErrorSummary" +import { Markdown } from "components/Markdown/Markdown" +import { Stack } from "components/Stack/Stack" +import { TemplateResourcesTable } from "components/TemplateResourcesTable/TemplateResourcesTable" +import { TemplateStats } from "components/TemplateStats/TemplateStats" +import { VersionsTable } from "components/VersionsTable/VersionsTable" +import { WorkspaceSection } from "components/WorkspaceSection/WorkspaceSection" +import frontMatter from "front-matter" +import { FC } from "react" +import { DAUChart } from "./DAUChart" + +const Language = { + readmeTitle: "README", + resourcesTitle: "Resources", +} + +export interface TemplateSummaryPageViewProps { + template: Template + activeTemplateVersion: TemplateVersion + templateResources: WorkspaceResource[] + templateVersions?: TemplateVersion[] + templateDAUs?: TemplateDAUsResponse + deleteTemplateError: Error | unknown +} + +export const TemplateSummaryPageView: FC> = ({ + template, + activeTemplateVersion, + templateResources, + templateVersions, + templateDAUs, + deleteTemplateError, +}) => { + const styles = useStyles() + const readme = frontMatter(activeTemplateVersion.readme) + + const deleteError = deleteTemplateError ? ( + + ) : null + + const getStartedResources = (resources: WorkspaceResource[]) => { + return resources.filter((resource) => resource.workspace_transition === "start") + } + + return ( + + {deleteError} + {templateDAUs && } + + + +
+ {readme.body} +
+
+ +
+ ) +} + +export const useStyles = makeStyles((theme) => { + return { + readmeContents: { + margin: 0, + }, + markdownWrapper: { + background: theme.palette.background.paper, + padding: theme.spacing(3, 4), + }, + } +}) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 598d37753a71f..bbb344d97dbb5 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -183,6 +183,8 @@ export const MockTemplate: TypesGen.Template = { created_by_id: "test-creator-id", created_by_name: "test_creator", icon: "/icon/code.svg", + user_roles: {}, + is_private: false, } export const MockWorkspaceAutostartDisabled: TypesGen.UpdateWorkspaceAutostartRequest = { diff --git a/site/src/xServices/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 new file mode 100644 index 0000000000000..c0a20c9592bdc --- /dev/null +++ b/site/src/xServices/template/templateUsersXService.ts @@ -0,0 +1,174 @@ +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!") + }, + }, + }, +) 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 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, + }), + }, + }, +)