diff --git a/coderd/audit.go b/coderd/audit.go index 2dc9069df1c88..ea1acdb1b121f 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -265,6 +265,7 @@ func auditSearchQuery(query string) (database.GetAuditLogsOffsetParams, []coders Username: parser.String(searchParams, "", "username"), Email: parser.String(searchParams, "", "email"), } + return filter, parser.Errors } @@ -296,6 +297,7 @@ func actionFromString(actionString string) string { return actionString case codersdk.AuditActionDelete: return actionString + default: } return "" } diff --git a/coderd/authorize.go b/coderd/authorize.go index b23acbc2ba38c..53fa6e553aff8 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -4,6 +4,8 @@ import ( "fmt" "net/http" + "github.com/google/uuid" + "golang.org/x/xerrors" "cdr.dev/slog" @@ -18,7 +20,7 @@ import ( // This is faster than calling Authorize() on each object. func AuthorizeFilter[O rbac.Objecter](h *HTTPAuthorizer, r *http.Request, action rbac.Action, objects []O) ([]O, error) { roles := httpmw.UserAuthorization(r) - objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, objects) + objects, err := rbac.Filter(r.Context(), h.Authorizer, roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), roles.Groups, action, objects) if err != nil { // Log the error as Filter should not be erroring. h.Logger.Error(r.Context(), "filter failed", @@ -63,7 +65,7 @@ func (api *API) Authorize(r *http.Request, action rbac.Action, object rbac.Objec // } func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool { roles := httpmw.UserAuthorization(r) - err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, object.RBACObject()) + err := h.Authorizer.ByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), roles.Groups, action, object.RBACObject()) if err != nil { // Log the errors for debugging internalError := new(rbac.UnauthorizedError) @@ -95,7 +97,7 @@ func (h *HTTPAuthorizer) Authorize(r *http.Request, action rbac.Action, object r // Note the authorization is only for the given action and object type. func (h *HTTPAuthorizer) AuthorizeSQLFilter(r *http.Request, action rbac.Action, objectType string) (rbac.AuthorizeFilter, error) { roles := httpmw.UserAuthorization(r) - prepared, err := h.Authorizer.PrepareByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), action, objectType) + prepared, err := h.Authorizer.PrepareByRoleName(r.Context(), roles.ID.String(), roles.Roles, roles.Scope.ToRBAC(), roles.Groups, action, objectType) if err != nil { return nil, xerrors.Errorf("prepare filter: %w", err) } @@ -127,6 +129,28 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { ) response := make(codersdk.AuthorizationResponse) + // Prevent using too many resources by ID. This prevents database abuse + // from this endpoint. This also prevents misuse of this endpoint, as + // resource_id should be used for single objects, not for a list of them. + var ( + idFetch int + maxFetch = 10 + ) + for _, v := range params.Checks { + if v.Object.ResourceID != "" { + idFetch++ + } + } + if idFetch > maxFetch { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf( + "Endpoint only supports using \"resource_id\" field %d times, found %d usages. Remove %d objects with this field set.", + maxFetch, idFetch, idFetch-maxFetch, + ), + }) + return + } + for k, v := range params.Checks { if v.Object.ResourceType == "" { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -135,15 +159,60 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { return } - if v.Object.OwnerID == "me" { - v.Object.OwnerID = auth.ID.String() + obj := rbac.Object{ + Owner: v.Object.OwnerID, + OrgID: v.Object.OrganizationID, + Type: v.Object.ResourceType, } - err := api.Authorizer.ByRoleName(r.Context(), auth.ID.String(), auth.Roles, auth.Scope.ToRBAC(), rbac.Action(v.Action), - rbac.Object{ - Owner: v.Object.OwnerID, - OrgID: v.Object.OrganizationID, - Type: v.Object.ResourceType, - }) + if obj.Owner == "me" { + obj.Owner = auth.ID.String() + } + + // If a resource ID is specified, fetch that specific resource. + if v.Object.ResourceID != "" { + id, err := uuid.Parse(v.Object.ResourceID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Object %q id is not a valid uuid.", v.Object.ResourceID), + Validations: []codersdk.ValidationError{{Field: "resource_id", Detail: err.Error()}}, + }) + return + } + + var dbObj rbac.Objecter + var dbErr error + // Only support referencing some resources by ID. + switch v.Object.ResourceType { + case rbac.ResourceWorkspaceExecution.Type: + wrkSpace, err := api.Database.GetWorkspaceByID(ctx, id) + if err == nil { + dbObj = wrkSpace.ExecutionRBAC() + } + dbErr = err + case rbac.ResourceWorkspace.Type: + dbObj, dbErr = api.Database.GetWorkspaceByID(ctx, id) + case rbac.ResourceTemplate.Type: + dbObj, dbErr = api.Database.GetTemplateByID(ctx, id) + case rbac.ResourceUser.Type: + dbObj, dbErr = api.Database.GetUserByID(ctx, id) + case rbac.ResourceGroup.Type: + dbObj, dbErr = api.Database.GetGroupByID(ctx, id) + default: + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Object type %q does not support \"resource_id\" field.", v.Object.ResourceType), + Validations: []codersdk.ValidationError{{Field: "resource_type", Detail: err.Error()}}, + }) + return + } + if dbErr != nil { + // 404 or unauthorized is false + response[k] = false + continue + } + obj = dbObj.RBACObject() + } + + err := api.Authorizer.ByRoleName(r.Context(), auth.ID.String(), auth.Roles, auth.Scope.ToRBAC(), auth.Groups, rbac.Action(v.Action), obj) response[k] = err == nil } diff --git a/coderd/authorize_test.go b/coderd/authorize_test.go index 49c0704f3bf63..b495bda4d0529 100644 --- a/coderd/authorize_test.go +++ b/coderd/authorize_test.go @@ -19,7 +19,9 @@ func TestCheckPermissions(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) t.Cleanup(cancel) - adminClient := coderdtest.New(t, nil) + adminClient := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) // Create adminClient, member, and org adminClient adminUser := coderdtest.CreateFirstUser(t, adminClient) memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) @@ -29,12 +31,17 @@ func TestCheckPermissions(t *testing.T) { orgAdminUser, err := orgAdminClient.User(ctx, codersdk.Me) require.NoError(t, err) + version := coderdtest.CreateTemplateVersion(t, adminClient, adminUser.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, adminClient, version.ID) + template := coderdtest.CreateTemplate(t, adminClient, adminUser.OrganizationID, version.ID) + // With admin, member, and org admin const ( - readAllUsers = "read-all-users" - readOrgWorkspaces = "read-org-workspaces" - readMyself = "read-myself" - readOwnWorkspaces = "read-own-workspaces" + readAllUsers = "read-all-users" + readOrgWorkspaces = "read-org-workspaces" + readMyself = "read-myself" + readOwnWorkspaces = "read-own-workspaces" + updateSpecificTemplate = "update-specific-template" ) params := map[string]codersdk.AuthorizationCheck{ readAllUsers: { @@ -64,6 +71,13 @@ func TestCheckPermissions(t *testing.T) { }, Action: "read", }, + updateSpecificTemplate: { + Object: codersdk.AuthorizationObject{ + ResourceType: rbac.ResourceTemplate.Type, + ResourceID: template.ID.String(), + }, + Action: "update", + }, } testCases := []struct { @@ -77,10 +91,11 @@ func TestCheckPermissions(t *testing.T) { Client: adminClient, UserID: adminUser.UserID, Check: map[string]bool{ - readAllUsers: true, - readMyself: true, - readOwnWorkspaces: true, - readOrgWorkspaces: true, + readAllUsers: true, + readMyself: true, + readOwnWorkspaces: true, + readOrgWorkspaces: true, + updateSpecificTemplate: true, }, }, { @@ -88,10 +103,11 @@ func TestCheckPermissions(t *testing.T) { Client: orgAdminClient, UserID: orgAdminUser.ID, Check: map[string]bool{ - readAllUsers: false, - readMyself: true, - readOwnWorkspaces: true, - readOrgWorkspaces: true, + readAllUsers: false, + readMyself: true, + readOwnWorkspaces: true, + readOrgWorkspaces: true, + updateSpecificTemplate: true, }, }, { @@ -99,10 +115,11 @@ func TestCheckPermissions(t *testing.T) { Client: memberClient, UserID: memberUser.ID, Check: map[string]bool{ - readAllUsers: false, - readMyself: true, - readOwnWorkspaces: true, - readOrgWorkspaces: false, + readAllUsers: false, + readMyself: true, + readOwnWorkspaces: true, + readOrgWorkspaces: false, + updateSpecificTemplate: false, }, }, } diff --git a/coderd/coderd.go b/coderd/coderd.go index a27b25b043632..29439a2001a99 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -283,6 +283,7 @@ func New(options *Options) *API { r.Get("/{hash}", api.fileByHash) r.Post("/", api.postFile) }) + r.Route("/provisionerdaemons", func(r chi.Router) { r.Use( apiKeyMiddleware, diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index 6c6e125d5764b..fa778988641f1 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -499,6 +499,7 @@ func (a *AuthTester) Test(ctx context.Context, assertRoute map[string]RouteCheck type authCall struct { SubjectID string Roles []string + Groups []string Scope rbac.Scope Action rbac.Action Object rbac.Object @@ -513,14 +514,15 @@ var _ rbac.Authorizer = (*RecordingAuthorizer)(nil) // ByRoleNameSQL does not record the call. This matches the postgres behavior // of not calling Authorize() -func (r *RecordingAuthorizer) ByRoleNameSQL(_ context.Context, _ string, _ []string, _ rbac.Scope, _ rbac.Action, _ rbac.Object) error { +func (r *RecordingAuthorizer) ByRoleNameSQL(_ context.Context, _ string, _ []string, _ rbac.Scope, _ []string, _ rbac.Action, _ rbac.Object) error { return r.AlwaysReturn } -func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scope rbac.Scope, action rbac.Action, object rbac.Object) error { +func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, roleNames []string, scope rbac.Scope, groups []string, action rbac.Action, object rbac.Object) error { r.Called = &authCall{ SubjectID: subjectID, Roles: roleNames, + Groups: groups, Scope: scope, Action: action, Object: object, @@ -528,7 +530,7 @@ func (r *RecordingAuthorizer) ByRoleName(_ context.Context, subjectID string, ro return r.AlwaysReturn } -func (r *RecordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, scope rbac.Scope, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { +func (r *RecordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID string, roles []string, scope rbac.Scope, groups []string, action rbac.Action, _ string) (rbac.PreparedAuthorized, error) { return &fakePreparedAuthorizer{ Original: r, SubjectID: subjectID, @@ -536,6 +538,7 @@ func (r *RecordingAuthorizer) PrepareByRoleName(_ context.Context, subjectID str Scope: scope, Action: action, HardCodedSQLString: "true", + Groups: groups, }, nil } @@ -549,12 +552,13 @@ type fakePreparedAuthorizer struct { Roles []string Scope rbac.Scope Action rbac.Action + Groups []string HardCodedSQLString string HardCodedRegoString string } func (f *fakePreparedAuthorizer) Authorize(ctx context.Context, object rbac.Object) error { - return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Scope, f.Action, object) + return f.Original.ByRoleName(ctx, f.SubjectID, f.Roles, f.Scope, f.Groups, f.Action, object) } // Compile returns a compiled version of the authorizer that will work for @@ -564,7 +568,7 @@ func (f *fakePreparedAuthorizer) Compile() (rbac.AuthorizeFilter, error) { } func (f *fakePreparedAuthorizer) Eval(object rbac.Object) bool { - return f.Original.ByRoleNameSQL(context.Background(), f.SubjectID, f.Roles, f.Scope, f.Action, object) == nil + return f.Original.ByRoleNameSQL(context.Background(), f.SubjectID, f.Roles, f.Scope, f.Groups, f.Action, object) == nil } func (f fakePreparedAuthorizer) RegoString() string { diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 4f7fd2692a4c7..d7ac4eb14be97 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -9,7 +9,6 @@ import ( "crypto/sha256" "crypto/x509" "crypto/x509/pkix" - "database/sql" "encoding/base64" "encoding/json" "encoding/pem" @@ -21,7 +20,6 @@ import ( "net/http" "net/http/httptest" "net/url" - "os" "strconv" "strings" "testing" @@ -49,8 +47,7 @@ import ( "github.com/coder/coder/coderd/autobuild/executor" "github.com/coder/coder/coderd/awsidentity" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/database/databasefake" - "github.com/coder/coder/coderd/database/postgres" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/gitsshkey" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" @@ -139,26 +136,7 @@ func NewOptions(t *testing.T, options *Options) (*httptest.Server, context.Cance }) } - // This can be hotswapped for a live database instance. - db := databasefake.New() - pubsub := database.NewPubsubInMemory() - if os.Getenv("DB") != "" { - connectionURL, closePg, err := postgres.Open() - require.NoError(t, err) - t.Cleanup(closePg) - sqlDB, err := sql.Open("postgres", connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - _ = sqlDB.Close() - }) - db = database.New(sqlDB) - - pubsub, err = database.NewPubsub(context.Background(), sqlDB, connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - _ = pubsub.Close() - }) - } + db, pubsub := dbtestutil.NewDB(t) ctx, cancelFunc := context.WithCancel(context.Background()) lifecycleExecutor := executor.New( @@ -399,6 +377,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/custom_queries.go b/coderd/database/custom_queries.go deleted file mode 100644 index 219a6cdb13c7b..0000000000000 --- a/coderd/database/custom_queries.go +++ /dev/null @@ -1,62 +0,0 @@ -package database - -import ( - "context" - "fmt" - - "github.com/lib/pq" - "golang.org/x/xerrors" - - "github.com/coder/coder/coderd/rbac" -) - -type customQuerier interface { - GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) -} - -// GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. -// This code is copied from `GetWorkspaces` and adds the authorized filter WHERE -// clause. -func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) { - // The name comment is for metric tracking - query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s AND %s", getWorkspaces, authorizedFilter.SQLString(rbac.DefaultConfig())) - rows, err := q.db.QueryContext(ctx, query, - arg.Deleted, - arg.OwnerID, - arg.OwnerUsername, - arg.TemplateName, - pq.Array(arg.TemplateIds), - arg.Name, - ) - if err != nil { - return nil, xerrors.Errorf("get authorized workspaces: %w", err) - } - defer rows.Close() - var items []Workspace - for rows.Next() { - var i Workspace - if err := rows.Scan( - &i.ID, - &i.CreatedAt, - &i.UpdatedAt, - &i.OwnerID, - &i.OrganizationID, - &i.TemplateID, - &i.Deleted, - &i.Name, - &i.AutostartSchedule, - &i.Ttl, - &i.LastUsedAt, - ); err != nil { - return nil, err - } - items = append(items, i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 895d85033dab8..1a2a919925ec2 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -12,23 +12,30 @@ 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" "github.com/coder/coder/coderd/util/slice" ) +var errDuplicateKey = &pq.Error{ + Code: "23505", + Message: "duplicate key value violates unique constraint", +} + // New returns an in-memory fake of the database. func New() database.Store { return &fakeQuerier{ mutex: &sync.RWMutex{}, data: &data{ - apiKeys: make([]database.APIKey, 0), - agentStats: make([]database.AgentStat, 0), - organizationMembers: make([]database.OrganizationMember, 0), - organizations: make([]database.Organization, 0), - users: make([]database.User, 0), - + apiKeys: make([]database.APIKey, 0), + agentStats: make([]database.AgentStat, 0), + organizationMembers: make([]database.OrganizationMember, 0), + organizations: make([]database.Organization, 0), + users: make([]database.User, 0), + groups: make([]database.Group, 0), + groupMembers: make([]database.GroupMember, 0), auditLogs: make([]database.AuditLog, 0), files: make([]database.File, 0), gitSSHKey: make([]database.GitSSHKey, 0), @@ -84,6 +91,8 @@ type data struct { auditLogs []database.AuditLog files []database.File gitSSHKey []database.GitSSHKey + groups []database.Group + groupMembers []database.GroupMember parameterSchemas []database.ParameterSchema parameterValues []database.ParameterValue provisionerDaemons []database.ProvisionerDaemon @@ -518,6 +527,13 @@ func (q *fakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U } } + var groups []string + for _, member := range q.groupMembers { + if member.UserID == userID { + groups = append(groups, member.GroupID.String()) + } + } + if user == nil { return database.GetAuthorizationUserRolesRow{}, sql.ErrNoRows } @@ -527,6 +543,7 @@ func (q *fakeQuerier) GetAuthorizationUserRoles(_ context.Context, userID uuid.U Username: user.Username, Status: user.Status, Roles: roles, + Groups: groups, }, nil } @@ -1269,6 +1286,116 @@ func (q *fakeQuerier) GetTemplates(_ context.Context) ([]database.Template, erro return templates, nil } +func (q *fakeQuerier) UpdateTemplateUserACLByID(_ context.Context, id uuid.UUID, acl database.TemplateACL) 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) UpdateTemplateGroupACLByID(_ context.Context, id uuid.UUID, acl database.TemplateACL) error { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for i, t := range q.templates { + if t.ID == id { + t = t.SetGroupACL(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 + } + + if user.Deleted || user.Status == database.UserStatusSuspended { + continue + } + + users = append(users, database.TemplateUser{ + User: user, + Actions: v, + }) + } + + return users, nil +} + +func (q *fakeQuerier) GetTemplateGroupRoles(_ context.Context, id uuid.UUID) ([]database.TemplateGroup, 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.GroupACL() + + groups := make([]database.TemplateGroup, 0, len(acl)) + for k, v := range acl { + group, err := q.GetGroupByID(context.Background(), uuid.MustParse(k)) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return nil, xerrors.Errorf("get group by ID: %w", err) + } + // We don't delete groups from the map if they + // get deleted so just skip. + if xerrors.Is(err, sql.ErrNoRows) { + continue + } + + groups = append(groups, database.TemplateGroup{ + Group: group, + Actions: v, + }) + } + + return groups, nil +} + func (q *fakeQuerier) GetOrganizationMemberByUserID(_ context.Context, arg database.GetOrganizationMemberByUserIDParams) (database.OrganizationMember, error) { q.mutex.RLock() defer q.mutex.RUnlock() @@ -1749,6 +1876,10 @@ func (q *fakeQuerier) InsertTemplate(_ context.Context, arg database.InsertTempl MinAutostartInterval: arg.MinAutostartInterval, CreatedBy: arg.CreatedBy, } + template = template.SetUserACL(database.TemplateACL{}) + template = template.SetGroupACL(database.TemplateACL{ + arg.OrganizationID.String(): []rbac.Action{rbac.ActionRead}, + }) q.templates = append(q.templates, template) return template, nil } @@ -2299,7 +2430,7 @@ func (q *fakeQuerier) UpdateWorkspace(_ context.Context, arg database.UpdateWork continue } if other.Name == arg.Name { - return database.Workspace{}, &pq.Error{Code: "23505", Message: "duplicate key value violates unique constraint"} + return database.Workspace{}, errDuplicateKey } } @@ -2437,6 +2568,52 @@ func (q *fakeQuerier) UpdateGitSSHKey(_ context.Context, arg database.UpdateGitS return sql.ErrNoRows } +func (q *fakeQuerier) InsertGroupMember(_ context.Context, arg database.InsertGroupMemberParams) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for _, member := range q.groupMembers { + if member.GroupID == arg.GroupID && + member.UserID == arg.UserID { + return errDuplicateKey + } + } + + //nolint:gosimple + q.groupMembers = append(q.groupMembers, database.GroupMember{ + GroupID: arg.GroupID, + UserID: arg.UserID, + }) + + return nil +} + +func (q *fakeQuerier) DeleteGroupMember(_ context.Context, userID uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, member := range q.groupMembers { + if member.UserID == userID { + q.groupMembers = append(q.groupMembers[:i], q.groupMembers[i+1:]...) + } + } + return nil +} + +func (q *fakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGroupByIDParams) (database.Group, error) { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, group := range q.groups { + if group.ID == arg.ID { + group.Name = arg.Name + q.groups[i] = group + return group, nil + } + } + return database.Group{}, sql.ErrNoRows +} + func (q *fakeQuerier) DeleteGitSSHKey(_ context.Context, userID uuid.UUID) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -2714,3 +2891,137 @@ func (q *fakeQuerier) UpdateUserLink(_ context.Context, params database.UpdateUs return database.UserLink{}, sql.ErrNoRows } + +func (q *fakeQuerier) GetGroupByID(_ context.Context, id uuid.UUID) (database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, group := range q.groups { + if group.ID == id { + return group, nil + } + } + + return database.Group{}, sql.ErrNoRows +} + +func (q *fakeQuerier) GetGroupByOrgAndName(_ context.Context, arg database.GetGroupByOrgAndNameParams) (database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, group := range q.groups { + if group.OrganizationID == arg.OrganizationID && + group.Name == arg.Name { + return group, nil + } + } + + return database.Group{}, sql.ErrNoRows +} + +func (q *fakeQuerier) InsertAllUsersGroup(ctx context.Context, orgID uuid.UUID) (database.Group, error) { + return q.InsertGroup(ctx, database.InsertGroupParams{ + ID: orgID, + Name: database.AllUsersGroup, + OrganizationID: orgID, + }) +} + +func (q *fakeQuerier) InsertGroup(_ context.Context, arg database.InsertGroupParams) (database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, group := range q.groups { + if group.OrganizationID.String() == arg.OrganizationID.String() && + group.Name == arg.Name { + return database.Group{}, errDuplicateKey + } + } + + //nolint:gosimple + group := database.Group{ + ID: arg.ID, + Name: arg.Name, + OrganizationID: arg.OrganizationID, + } + + q.groups = append(q.groups, group) + + return group, nil +} + +func (*fakeQuerier) GetUserGroups(_ context.Context, _ uuid.UUID) ([]database.Group, error) { + panic("not implemented") +} + +func (q *fakeQuerier) GetGroupMembers(_ context.Context, groupID uuid.UUID) ([]database.User, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var members []database.GroupMember + for _, member := range q.groupMembers { + if member.GroupID == groupID { + members = append(members, member) + } + } + + users := make([]database.User, 0, len(members)) + + for _, member := range members { + for _, user := range q.users { + if user.ID == member.UserID && user.Status == database.UserStatusActive && !user.Deleted { + users = append(users, user) + break + } + } + } + + return users, nil +} + +func (q *fakeQuerier) GetGroupsByOrganizationID(_ context.Context, organizationID uuid.UUID) ([]database.Group, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var groups []database.Group + for _, group := range q.groups { + // Omit the allUsers group. + if group.OrganizationID == organizationID && group.ID != organizationID { + groups = append(groups, group) + } + } + + return groups, nil +} + +func (q *fakeQuerier) GetAllOrganizationMembers(_ context.Context, organizationID uuid.UUID) ([]database.User, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + var users []database.User + for _, member := range q.organizationMembers { + if member.OrganizationID == organizationID { + for _, user := range q.users { + if user.ID == member.UserID { + users = append(users, user) + } + } + } + } + + return users, nil +} + +func (q *fakeQuerier) DeleteGroupByID(_ context.Context, id uuid.UUID) error { + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, group := range q.groups { + if group.ID == id { + q.groups = append(q.groups[:i], q.groups[i+1:]...) + return nil + } + } + + return sql.ErrNoRows +} diff --git a/coderd/database/db.go b/coderd/database/db.go index 9997a88f1e148..4cbbdb399f193 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" ) @@ -32,24 +33,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. @@ -60,7 +71,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/dbtestutil/db.go b/coderd/database/dbtestutil/db.go new file mode 100644 index 0000000000000..2ca9e95a8af25 --- /dev/null +++ b/coderd/database/dbtestutil/db.go @@ -0,0 +1,40 @@ +package dbtestutil + +import ( + "context" + "database/sql" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/database/postgres" +) + +func NewDB(t *testing.T) (database.Store, database.Pubsub) { + t.Helper() + + db := databasefake.New() + pubsub := database.NewPubsubInMemory() + if os.Getenv("DB") != "" { + connectionURL, closePg, err := postgres.Open() + require.NoError(t, err) + t.Cleanup(closePg) + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = sqlDB.Close() + }) + db = database.New(sqlDB) + + pubsub, err = database.NewPubsub(context.Background(), sqlDB, connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + _ = pubsub.Close() + }) + } + + return db, pubsub +} diff --git a/coderd/database/drivers.go b/coderd/database/drivers.go new file mode 100644 index 0000000000000..b0bf0f79612c8 --- /dev/null +++ b/coderd/database/drivers.go @@ -0,0 +1,26 @@ +package database + +import ( + "database/sql/driver" + "encoding/json" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/rbac" +) + +type Actions []rbac.Action + +func (a *Actions) Scan(src interface{}) error { + switch v := src.(type) { + case string: + return json.Unmarshal([]byte(v), &a) + case []byte: + return json.Unmarshal(v, &a) + } + return xerrors.Errorf("unexpected type %T", src) +} + +func (a *Actions) Value() (driver.Value, error) { + return json.Marshal(a) +} diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 28cc7b65bbf48..eb16074e90525 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -162,6 +162,17 @@ CREATE TABLE gitsshkeys ( public_key text NOT NULL ); +CREATE TABLE group_members ( + user_id uuid NOT NULL, + group_id uuid NOT NULL +); + +CREATE TABLE groups ( + id uuid NOT NULL, + name text NOT NULL, + organization_id uuid NOT NULL +); + CREATE TABLE licenses ( id integer NOT NULL, uploaded_at timestamp with time zone NOT NULL, @@ -295,7 +306,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, + group_acl jsonb DEFAULT '{}'::jsonb NOT NULL ); CREATE TABLE user_links ( @@ -424,6 +437,15 @@ ALTER TABLE ONLY files ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_pkey PRIMARY KEY (user_id); +ALTER TABLE ONLY group_members + ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); + +ALTER TABLE ONLY groups + ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); + +ALTER TABLE ONLY groups + ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); @@ -545,6 +567,15 @@ ALTER TABLE ONLY api_keys ALTER TABLE ONLY gitsshkeys ADD CONSTRAINT gitsshkeys_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id); +ALTER TABLE ONLY group_members + ADD CONSTRAINT group_members_group_id_fkey FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE; + +ALTER TABLE ONLY group_members + ADD CONSTRAINT group_members_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; + +ALTER TABLE ONLY groups + ADD CONSTRAINT groups_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY organization_members ADD CONSTRAINT organization_members_organization_id_uuid_fkey FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; diff --git a/coderd/database/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/000058_template_acl.down.sql b/coderd/database/migrations/000058_template_acl.down.sql new file mode 100644 index 0000000000000..6b34ddf33119b --- /dev/null +++ b/coderd/database/migrations/000058_template_acl.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP TABLE group_members; +DROP TABLE groups; +ALTER TABLE templates DROP COLUMN group_acl; +ALTER TABLE templates DROP COLUMN user_acl; + +COMMIT; diff --git a/coderd/database/migrations/000058_template_acl.up.sql b/coderd/database/migrations/000058_template_acl.up.sql new file mode 100644 index 0000000000000..f87cd759f9e94 --- /dev/null +++ b/coderd/database/migrations/000058_template_acl.up.sql @@ -0,0 +1,48 @@ +BEGIN; + +ALTER TABLE templates ADD COLUMN user_acl jsonb NOT NULL default '{}'; +ALTER TABLE templates ADD COLUMN group_acl jsonb NOT NULL default '{}'; + +CREATE TABLE groups ( + id uuid NOT NULL, + name text NOT NULL, + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + PRIMARY KEY(id), + UNIQUE(name, organization_id) +); + +CREATE TABLE group_members ( + user_id uuid NOT NULL, + group_id uuid NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE CASCADE, + UNIQUE(user_id, group_id) +); + +-- Insert a group for every organization (which should just be 1). +INSERT INTO groups ( + id, + name, + organization_id +) SELECT + id, 'Everyone' as name, id +FROM + organizations; + +-- Insert allUsers groups into every existing template to avoid breaking +-- existing deployments. +UPDATE + templates +SET + group_acl = ( + SELECT + json_build_object( + organizations.id, array_to_json('{"read"}'::text[]) + ) + FROM + organizations + WHERE + templates.organization_id = organizations.id + ); + +COMMIT; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index f6e28bd5dc824..f12d8b7f2dceb 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -1,9 +1,65 @@ package database import ( + "encoding/json" + "fmt" + "github.com/coder/coder/coderd/rbac" ) +const AllUsersGroup = "Everyone" + +// TemplateACL is a map of user_ids to permissions. +type TemplateACL map[string][]rbac.Action + +func (t Template) UserACL() TemplateACL { + var acl TemplateACL + 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) GroupACL() TemplateACL { + var acl TemplateACL + if len(t.groupACL) == 0 { + return acl + } + + err := json.Unmarshal(t.groupACL, &acl) + if err != nil { + panic(fmt.Sprintf("failed to unmarshal template.userACL: %v", err.Error())) + } + + return acl +} + +func (t Template) SetGroupACL(acl TemplateACL) Template { + raw, err := json.Marshal(acl) + if err != nil { + panic(fmt.Sprintf("marshal user acl: %v", err)) + } + + t.groupACL = raw + return t +} + +func (t Template) SetUserACL(acl TemplateACL) Template { + raw, err := json.Marshal(acl) + if err != nil { + panic(fmt.Sprintf("marshal user acl: %v", err)) + } + + t.userACL = raw + return t +} + func (s APIKeyScope) ToRBAC() rbac.Scope { switch s { case APIKeyScopeAll: @@ -16,12 +72,19 @@ func (s APIKeyScope) ToRBAC() rbac.Scope { } func (t Template) RBACObject() rbac.Object { - return rbac.ResourceTemplate.InOrg(t.OrganizationID) + obj := rbac.ResourceTemplate + return obj.InOrg(t.OrganizationID). + WithACLUserList(t.UserACL()). + WithGroupACL(t.GroupACL()) } -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 (g Group) RBACObject() rbac.Object { + return rbac.ResourceGroup.InOrg(g.OrganizationID) } func (w Workspace) RBACObject() rbac.Object { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go new file mode 100644 index 0000000000000..109f74db3852d --- /dev/null +++ b/coderd/database/modelqueries.go @@ -0,0 +1,208 @@ +package database + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/lib/pq" + + "github.com/coder/coder/coderd/rbac" + + "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 + workspaceQuerier +} + +type templateQuerier interface { + UpdateTemplateUserACLByID(ctx context.Context, id uuid.UUID, acl TemplateACL) error + UpdateTemplateGroupACLByID(ctx context.Context, id uuid.UUID, acl TemplateACL) error + GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]TemplateGroup, error) + GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]TemplateUser, error) +} + +type TemplateUser struct { + User + Actions Actions `db:"actions"` +} + +func (q *sqlQuerier) UpdateTemplateUserACLByID(ctx context.Context, id uuid.UUID, acl TemplateACL) 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 actions, 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 + WHERE + users.deleted = false + AND + users.status = 'active'; + ` + + var tus []TemplateUser + err := q.db.SelectContext(ctx, &tus, query, id.String()) + if err != nil { + return nil, xerrors.Errorf("select user actions: %w", err) + } + + return tus, nil +} + +type TemplateGroup struct { + Group + Actions Actions `db:"actions"` +} + +func (q *sqlQuerier) UpdateTemplateGroupACLByID(ctx context.Context, id uuid.UUID, acl TemplateACL) error { + raw, err := json.Marshal(acl) + if err != nil { + return xerrors.Errorf("marshal user acl: %w", err) + } + + const query = ` +UPDATE + templates +SET + group_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) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]TemplateGroup, error) { + const query = ` + SELECT + perms.value as actions, groups.* + FROM + groups + JOIN + ( + SELECT + * + FROM + jsonb_each_text( + ( + SELECT + templates.group_acl + FROM + templates + WHERE + id = $1 + ) + ) + ) AS perms + ON + groups.id::text = perms.key; + ` + + var tgs []TemplateGroup + err := q.db.SelectContext(ctx, &tgs, query, id.String()) + if err != nil { + return nil, xerrors.Errorf("select group roles: %w", err) + } + + return tgs, nil +} + +type workspaceQuerier interface { + GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) +} + +// GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. +// This code is copied from `GetWorkspaces` and adds the authorized filter WHERE +// clause. +func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) { + // The name comment is for metric tracking + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s AND %s", getWorkspaces, authorizedFilter.SQLString(rbac.NoACLConfig())) + rows, err := q.db.QueryContext(ctx, query, + arg.Deleted, + arg.OwnerID, + arg.OwnerUsername, + arg.TemplateName, + pq.Array(arg.TemplateIds), + arg.Name, + ) + if err != nil { + return nil, xerrors.Errorf("get authorized workspaces: %w", err) + } + defer rows.Close() + var items []Workspace + for rows.Next() { + var i Workspace + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/coderd/database/models.go b/coderd/database/models.go index e1aa0782a6150..f669b5e618138 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" ) @@ -413,6 +414,17 @@ type GitSSHKey struct { PublicKey string `db:"public_key" json:"public_key"` } +type Group struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +type GroupMember struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` +} + type License struct { ID int32 `db:"id" json:"id"` UploadedAt time.Time `db:"uploaded_at" json:"uploaded_at"` @@ -524,6 +536,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"` + groupACL json.RawMessage `db:"group_acl" json:"group_acl"` } type TemplateVersion struct { @@ -546,7 +560,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 8e02949736d5b..b58f6abbccfb8 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. // @@ -21,6 +21,8 @@ type querier interface { AcquireProvisionerJob(ctx context.Context, arg AcquireProvisionerJobParams) (ProvisionerJob, error) DeleteAPIKeyByID(ctx context.Context, id string) error DeleteGitSSHKey(ctx context.Context, userID uuid.UUID) error + DeleteGroupByID(ctx context.Context, id uuid.UUID) error + DeleteGroupMember(ctx context.Context, userID uuid.UUID) error DeleteLicense(ctx context.Context, id int32) (int32, error) DeleteOldAgentStats(ctx context.Context) error DeleteParameterValueByID(ctx context.Context, id uuid.UUID) error @@ -28,6 +30,7 @@ type querier interface { GetAPIKeysByLoginType(ctx context.Context, loginType LoginType) ([]APIKey, error) GetAPIKeysLastUsedAfter(ctx context.Context, lastUsed time.Time) ([]APIKey, error) GetActiveUserCount(ctx context.Context) (int64, error) + GetAllOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]User, error) GetAuditLogCount(ctx context.Context, arg GetAuditLogCountParams) (int64, error) // GetAuditLogsBefore retrieves `row_limit` number of audit logs before the provided // ID. @@ -38,6 +41,10 @@ type querier interface { GetDeploymentID(ctx context.Context) (string, error) GetFileByHash(ctx context.Context, hash string) (File, error) GetGitSSHKey(ctx context.Context, userID uuid.UUID) (GitSSHKey, error) + GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) + GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) + GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) + GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) GetLatestAgentStat(ctx context.Context, agentID uuid.UUID) (AgentStat, error) GetLatestWorkspaceBuildByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) (WorkspaceBuild, error) GetLatestWorkspaceBuilds(ctx context.Context) ([]WorkspaceBuild, error) @@ -73,6 +80,7 @@ type querier interface { GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) GetUserCount(ctx context.Context) (int64, error) + GetUserGroups(ctx context.Context, userID uuid.UUID) ([]Group, error) GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error) GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error) GetUsers(ctx context.Context, arg GetUsersParams) ([]User, error) @@ -108,10 +116,16 @@ type querier interface { GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) InsertAgentStat(ctx context.Context, arg InsertAgentStatParams) (AgentStat, error) + // We use the organization_id as the id + // for simplicity since all users is + // every member of the org. + InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) (AuditLog, error) InsertDeploymentID(ctx context.Context, value string) error InsertFile(ctx context.Context, arg InsertFileParams) (File, error) InsertGitSSHKey(ctx context.Context, arg InsertGitSSHKeyParams) (GitSSHKey, error) + InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) + InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error InsertLicense(ctx context.Context, arg InsertLicenseParams) (License, error) InsertOrganization(ctx context.Context, arg InsertOrganizationParams) (Organization, error) InsertOrganizationMember(ctx context.Context, arg InsertOrganizationMemberParams) (OrganizationMember, error) @@ -134,6 +148,7 @@ type querier interface { ParameterValues(ctx context.Context, arg ParameterValuesParams) ([]ParameterValue, error) UpdateAPIKeyByID(ctx context.Context, arg UpdateAPIKeyByIDParams) error UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) error + UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error) UpdateProvisionerDaemonByID(ctx context.Context, arg UpdateProvisionerDaemonByIDParams) error UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error @@ -163,4 +178,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 faa4dc42c54ca..ba90e102b819a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -807,6 +807,328 @@ func (q *sqlQuerier) UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyPar return err } +const deleteGroupByID = `-- name: DeleteGroupByID :exec +DELETE FROM + groups +WHERE + id = $1 +` + +func (q *sqlQuerier) DeleteGroupByID(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteGroupByID, id) + return err +} + +const deleteGroupMember = `-- name: DeleteGroupMember :exec +DELETE FROM + group_members +WHERE + user_id = $1 +` + +func (q *sqlQuerier) DeleteGroupMember(ctx context.Context, userID uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteGroupMember, userID) + return err +} + +const getAllOrganizationMembers = `-- name: GetAllOrganizationMembers :many +SELECT + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at +FROM + users +JOIN + organization_members +ON + users.id = organization_members.user_id +WHERE + organization_members.organization_id = $1 +` + +func (q *sqlQuerier) GetAllOrganizationMembers(ctx context.Context, organizationID uuid.UUID) ([]User, error) { + rows, err := q.db.QueryContext(ctx, getAllOrganizationMembers, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.RBACRoles, + &i.LoginType, + &i.AvatarURL, + &i.Deleted, + &i.LastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGroupByID = `-- name: GetGroupByID :one +SELECT + id, name, organization_id +FROM + groups +WHERE + id = $1 +LIMIT + 1 +` + +func (q *sqlQuerier) GetGroupByID(ctx context.Context, id uuid.UUID) (Group, error) { + row := q.db.QueryRowContext(ctx, getGroupByID, id) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + +const getGroupByOrgAndName = `-- name: GetGroupByOrgAndName :one +SELECT + id, name, organization_id +FROM + groups +WHERE + organization_id = $1 +AND + name = $2 +LIMIT + 1 +` + +type GetGroupByOrgAndNameParams struct { + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetGroupByOrgAndName(ctx context.Context, arg GetGroupByOrgAndNameParams) (Group, error) { + row := q.db.QueryRowContext(ctx, getGroupByOrgAndName, arg.OrganizationID, arg.Name) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + +const getGroupMembers = `-- name: GetGroupMembers :many +SELECT + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at +FROM + users +JOIN + group_members +ON + users.id = group_members.user_id +WHERE + group_members.group_id = $1 +AND + users.status = 'active' +AND + users.deleted = 'false' +` + +func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([]User, error) { + rows, err := q.db.QueryContext(ctx, getGroupMembers, groupID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.RBACRoles, + &i.LoginType, + &i.AvatarURL, + &i.Deleted, + &i.LastSeenAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getGroupsByOrganizationID = `-- name: GetGroupsByOrganizationID :many +SELECT + id, name, organization_id +FROM + groups +WHERE + organization_id = $1 +AND + id != $1 +` + +func (q *sqlQuerier) GetGroupsByOrganizationID(ctx context.Context, organizationID uuid.UUID) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, getGroupsByOrganizationID, organizationID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Group + for rows.Next() { + var i Group + if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getUserGroups = `-- name: GetUserGroups :many +SELECT + groups.id, groups.name, groups.organization_id +FROM + groups +JOIN + group_members +ON + groups.id = group_members.group_id +WHERE + group_members.user_id = $1 +` + +func (q *sqlQuerier) GetUserGroups(ctx context.Context, userID uuid.UUID) ([]Group, error) { + rows, err := q.db.QueryContext(ctx, getUserGroups, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Group + for rows.Next() { + var i Group + if err := rows.Scan(&i.ID, &i.Name, &i.OrganizationID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const insertAllUsersGroup = `-- name: InsertAllUsersGroup :one +INSERT INTO groups ( + id, + name, + organization_id +) +VALUES + ( $1, 'Everyone', $1) RETURNING id, name, organization_id +` + +// We use the organization_id as the id +// for simplicity since all users is +// every member of the org. +func (q *sqlQuerier) InsertAllUsersGroup(ctx context.Context, organizationID uuid.UUID) (Group, error) { + row := q.db.QueryRowContext(ctx, insertAllUsersGroup, organizationID) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + +const insertGroup = `-- name: InsertGroup :one +INSERT INTO groups ( + id, + name, + organization_id +) +VALUES + ( $1, $2, $3) RETURNING id, name, organization_id +` + +type InsertGroupParams struct { + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` +} + +func (q *sqlQuerier) InsertGroup(ctx context.Context, arg InsertGroupParams) (Group, error) { + row := q.db.QueryRowContext(ctx, insertGroup, arg.ID, arg.Name, arg.OrganizationID) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + +const insertGroupMember = `-- name: InsertGroupMember :exec +INSERT INTO group_members ( + user_id, + group_id +) +VALUES ( $1, $2) +` + +type InsertGroupMemberParams struct { + UserID uuid.UUID `db:"user_id" json:"user_id"` + GroupID uuid.UUID `db:"group_id" json:"group_id"` +} + +func (q *sqlQuerier) InsertGroupMember(ctx context.Context, arg InsertGroupMemberParams) error { + _, err := q.db.ExecContext(ctx, insertGroupMember, arg.UserID, arg.GroupID) + return err +} + +const updateGroupByID = `-- name: UpdateGroupByID :one +UPDATE + groups +SET + name = $1 +WHERE + id = $2 +RETURNING id, name, organization_id +` + +type UpdateGroupByIDParams struct { + Name string `db:"name" json:"name"` + ID uuid.UUID `db:"id" json:"id"` +} + +func (q *sqlQuerier) UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error) { + row := q.db.QueryRowContext(ctx, updateGroupByID, arg.Name, arg.ID) + var i Group + err := row.Scan(&i.ID, &i.Name, &i.OrganizationID) + return i, err +} + const deleteLicense = `-- name: DeleteLicense :one DELETE FROM licenses @@ -2231,7 +2553,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, group_acl FROM templates WHERE @@ -2257,13 +2579,15 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.groupACL, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl FROM templates WHERE @@ -2297,12 +2621,14 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.groupACL, ) return i, err } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl FROM templates ORDER BY (name, id) ASC ` @@ -2329,6 +2655,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.groupACL, ); err != nil { return nil, err } @@ -2345,7 +2673,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, group_acl FROM templates WHERE @@ -2407,6 +2735,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.groupACL, ); err != nil { return nil, err } @@ -2438,7 +2768,7 @@ INSERT INTO icon ) 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) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl ` type InsertTemplateParams struct { @@ -2486,6 +2816,8 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.groupACL, ) return i, err } @@ -2545,7 +2877,7 @@ SET WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval, created_by, icon, user_acl, group_acl ` type UpdateTemplateMetaByIDParams struct { @@ -2583,6 +2915,8 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.MinAutostartInterval, &i.CreatedBy, &i.Icon, + &i.userACL, + &i.groupACL, ) return i, err } @@ -3071,16 +3405,36 @@ SELECT -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. id, username, status, + -- All user roles, including their org roles. array_cat( -- All users are members - array_append(users.rbac_roles, 'member'), - -- All org_members get the org-member role for their orgs - array_append(organization_members.roles, 'organization-member:'||organization_members.organization_id::text)) :: text[] - AS roles + array_append(users.rbac_roles, 'member'), + ( + SELECT + array_agg(org_roles) + FROM + organization_members, + -- All org_members get the org-member role for their orgs + unnest( + array_append(roles, 'organization-member:' || organization_members.organization_id::text) + ) AS org_roles + WHERE + user_id = users.id + ) + ) :: text[] AS roles, + -- All groups the user is in. + ( + SELECT + array_agg( + group_members.group_id :: text + ) + FROM + group_members + WHERE + user_id = users.id + ) :: text[] AS groups FROM users -LEFT JOIN organization_members - ON id = user_id WHERE id = $1 ` @@ -3090,6 +3444,7 @@ type GetAuthorizationUserRolesRow struct { Username string `db:"username" json:"username"` Status UserStatus `db:"status" json:"status"` Roles []string `db:"roles" json:"roles"` + Groups []string `db:"groups" json:"groups"` } // This function returns roles for authorization purposes. Implied member roles @@ -3102,6 +3457,7 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid. &i.Username, &i.Status, pq.Array(&i.Roles), + pq.Array(&i.Groups), ) return i, err } @@ -3135,7 +3491,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, @@ -3166,7 +3522,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, @@ -3285,7 +3641,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, @@ -3328,7 +3684,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3364,14 +3720,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) { @@ -3382,7 +3738,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 @@ -3394,7 +3750,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, @@ -3468,7 +3824,7 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.CreatedAt, &i.UpdatedAt, &i.Status, - pq.Array(&i.RBACRoles), + &i.RBACRoles, &i.LoginType, &i.AvatarURL, &i.Deleted, @@ -3514,7 +3870,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, @@ -3550,7 +3906,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, @@ -3586,7 +3942,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/groups.sql b/coderd/database/queries/groups.sql new file mode 100644 index 0000000000000..137bf3040a127 --- /dev/null +++ b/coderd/database/queries/groups.sql @@ -0,0 +1,122 @@ +-- name: GetGroupByID :one +SELECT + * +FROM + groups +WHERE + id = $1 +LIMIT + 1; + +-- name: GetGroupByOrgAndName :one +SELECT + * +FROM + groups +WHERE + organization_id = $1 +AND + name = $2 +LIMIT + 1; + +-- name: GetUserGroups :many +SELECT + groups.* +FROM + groups +JOIN + group_members +ON + groups.id = group_members.group_id +WHERE + group_members.user_id = $1; + +-- name: GetGroupMembers :many +SELECT + users.* +FROM + users +JOIN + group_members +ON + users.id = group_members.user_id +WHERE + group_members.group_id = $1 +AND + users.status = 'active' +AND + users.deleted = 'false'; + +-- name: GetAllOrganizationMembers :many +SELECT + users.* +FROM + users +JOIN + organization_members +ON + users.id = organization_members.user_id +WHERE + organization_members.organization_id = $1; + +-- name: GetGroupsByOrganizationID :many +SELECT + * +FROM + groups +WHERE + organization_id = $1 +AND + id != $1; + +-- name: InsertGroup :one +INSERT INTO groups ( + id, + name, + organization_id +) +VALUES + ( $1, $2, $3) RETURNING *; + +-- We use the organization_id as the id +-- for simplicity since all users is +-- every member of the org. +-- name: InsertAllUsersGroup :one +INSERT INTO groups ( + id, + name, + organization_id +) +VALUES + ( sqlc.arg(organization_id), 'Everyone', sqlc.arg(organization_id)) RETURNING *; + +-- name: UpdateGroupByID :one +UPDATE + groups +SET + name = $1 +WHERE + id = $2 +RETURNING *; + +-- name: InsertGroupMember :exec +INSERT INTO group_members ( + user_id, + group_id +) +VALUES ( $1, $2); + +-- name: DeleteGroupMember :exec +DELETE FROM + group_members +WHERE + user_id = $1; + +-- name: DeleteGroupByID :exec +DELETE FROM + groups +WHERE + id = $1; + + diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index 084d7246a05e4..5cf4dd8a12816 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -178,15 +178,35 @@ SELECT -- status is used to enforce 'suspended' users, as all roles are ignored -- when suspended. id, username, status, + -- All user roles, including their org roles. array_cat( -- All users are members - array_append(users.rbac_roles, 'member'), - -- All org_members get the org-member role for their orgs - array_append(organization_members.roles, 'organization-member:'||organization_members.organization_id::text)) :: text[] - AS roles + array_append(users.rbac_roles, 'member'), + ( + SELECT + array_agg(org_roles) + FROM + organization_members, + -- All org_members get the org-member role for their orgs + unnest( + array_append(roles, 'organization-member:' || organization_members.organization_id::text) + ) AS org_roles + WHERE + user_id = users.id + ) + ) :: text[] AS roles, + -- All groups the user is in. + ( + SELECT + array_agg( + group_members.group_id :: text + ) + FROM + group_members + WHERE + user_id = users.id + ) :: text[] AS groups FROM users -LEFT JOIN organization_members - ON id = user_id WHERE id = @user_id; diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index 579264d8a499a..24df14057fcc9 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,5 @@ rename: ip_addresses: IPAddresses ids: IDs jwt: JWT + user_acl: userACL + group_acl: groupACL diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index e0f8dfb9cdbcc..44cfd89d43284 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -6,6 +6,8 @@ type UniqueConstraint string // UniqueConstraint enums. const ( + UniqueGroupMembersUserIDGroupIDKey UniqueConstraint = "group_members_user_id_group_id_key" // ALTER TABLE ONLY group_members ADD CONSTRAINT group_members_user_id_group_id_key UNIQUE (user_id, group_id); + UniqueGroupsNameOrganizationIDKey UniqueConstraint = "groups_name_organization_id_key" // ALTER TABLE ONLY groups ADD CONSTRAINT groups_name_organization_id_key UNIQUE (name, organization_id); UniqueLicensesJWTKey UniqueConstraint = "licenses_jwt_key" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_jwt_key UNIQUE (jwt); UniqueParameterSchemasJobIDNameKey UniqueConstraint = "parameter_schemas_job_id_name_key" // ALTER TABLE ONLY parameter_schemas ADD CONSTRAINT parameter_schemas_job_id_name_key UNIQUE (job_id, name); UniqueParameterValuesScopeIDNameKey UniqueConstraint = "parameter_values_scope_id_name_key" // ALTER TABLE ONLY parameter_values ADD CONSTRAINT parameter_values_scope_id_name_key UNIQUE (scope_id, name); diff --git a/coderd/files.go b/coderd/files.go index 8f8c39fd96326..a0b7be2c8be5d 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -23,7 +23,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) // This requires the site wide action to create files. // Once created, a user can read their own files uploaded - if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceFile) { + if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) { httpapi.Forbidden(rw) return } diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index 11433624eb644..da80337f76bf8 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -54,6 +54,7 @@ type Authorization struct { ID uuid.UUID Username string Roles []string + Groups []string Scope database.APIKeyScope } @@ -360,6 +361,7 @@ func ExtractAPIKey(cfg ExtractAPIKeyConfig) func(http.Handler) http.Handler { Username: roles.Username, Roles: roles.Roles, Scope: key.Scope, + Groups: roles.Groups, }) next.ServeHTTP(rw, r.WithContext(ctx)) diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 9f2d106895fb6..576ef9fd50cff 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -4,23 +4,22 @@ import ( "context" "crypto/sha256" "fmt" + "net" "net/http" "net/http/httptest" "testing" "time" - "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/codersdk" - - "github.com/google/uuid" - - "github.com/coder/coder/coderd/database" - "github.com/go-chi/chi/v5" + "github.com/google/uuid" "github.com/stretchr/testify/require" + "github.com/tabbed/pqtype" - "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" ) func TestExtractUserRoles(t *testing.T) { @@ -71,14 +70,49 @@ func TestExtractUserRoles(t *testing.T) { return user, append(roles, append(orgRoles, rbac.RoleMember(), rbac.RoleOrgMember(org.ID))...), token }, }, + { + Name: "MultipleOrgMember", + AddUser: func(db database.Store) (database.User, []string, string) { + roles := []string{} + user, token := addUser(t, db, roles...) + roles = append(roles, rbac.RoleMember()) + for i := 0; i < 3; i++ { + organization, err := db.InsertOrganization(context.Background(), database.InsertOrganizationParams{ + ID: uuid.New(), + Name: fmt.Sprintf("testorg%d", i), + Description: "test", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + require.NoError(t, err) + + orgRoles := []string{} + if i%2 == 0 { + orgRoles = append(orgRoles, rbac.RoleOrgAdmin(organization.ID)) + } + _, err = db.InsertOrganizationMember(context.Background(), database.InsertOrganizationMemberParams{ + OrganizationID: organization.ID, + UserID: user.ID, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Roles: orgRoles, + }) + require.NoError(t, err) + roles = append(roles, orgRoles...) + roles = append(roles, rbac.RoleOrgMember(organization.ID)) + } + return user, roles, token + }, + }, } for _, c := range testCases { c := c t.Run(c.Name, func(t *testing.T) { t.Parallel() + var ( - db = databasefake.New() + db, _ = dbtestutil.NewDB(t) user, expRoles, token = c.AddUser(db) rw = httptest.NewRecorder() rtr = chi.NewRouter() @@ -118,6 +152,7 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s Email: "admin@email.com", Username: "admin", RBACRoles: roles, + LoginType: database.LoginTypePassword, }) require.NoError(t, err) @@ -129,6 +164,13 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s ExpiresAt: database.Now().Add(time.Minute), LoginType: database.LoginTypePassword, Scope: database.APIKeyScopeAll, + IPAddress: pqtype.Inet{ + IPNet: net.IPNet{ + IP: net.ParseIP("0.0.0.0"), + Mask: net.IPMask{0, 0, 0, 0}, + }, + Valid: true, + }, }) require.NoError(t, err) diff --git a/coderd/httpmw/groupparam.go b/coderd/httpmw/groupparam.go new file mode 100644 index 0000000000000..13328cbcf1552 --- /dev/null +++ b/coderd/httpmw/groupparam.go @@ -0,0 +1,56 @@ +package httpmw + +import ( + "context" + "database/sql" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/codersdk" +) + +type groupParamContextKey struct{} + +// GroupParam returns the group extracted via the ExtraGroupParam middleware. +func GroupParam(r *http.Request) database.Group { + group, ok := r.Context().Value(groupParamContextKey{}).(database.Group) + if !ok { + panic("developer error: group param middleware not provided") + } + return group +} + +// ExtraGroupParam grabs a group from the "group" URL parameter. +func ExtractGroupParam(db database.Store) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + groupID, parsed := parseUUID(rw, r, "group") + if !parsed { + return + } + + group, err := db.GetGroupByID(r.Context(), groupID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.ResourceNotFound(rw) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching group.", + Detail: err.Error(), + }) + return + } + + ctx = context.WithValue(ctx, groupParamContextKey{}, group) + chi.RouteContext(ctx).URLParams.Add("organization", group.OrganizationID.String()) + next.ServeHTTP(rw, r.WithContext(ctx)) + }) + } +} diff --git a/coderd/httpmw/groupparam_test.go b/coderd/httpmw/groupparam_test.go new file mode 100644 index 0000000000000..70850de4ce9be --- /dev/null +++ b/coderd/httpmw/groupparam_test.go @@ -0,0 +1,103 @@ +package httpmw_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/databasefake" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/testutil" +) + +func TestGroupParam(t *testing.T) { + t.Parallel() + + setup := func(t *testing.T) (database.Store, database.Group) { + t.Helper() + + ctx, _ := testutil.Context(t) + db := databasefake.New() + + orgID := uuid.New() + organization, err := db.InsertOrganization(ctx, database.InsertOrganizationParams{ + ID: orgID, + Name: "banana", + Description: "wowie", + CreatedAt: database.Now(), + UpdatedAt: database.Now(), + }) + require.NoError(t, err) + + group, err := db.InsertGroup(ctx, database.InsertGroupParams{ + ID: uuid.New(), + Name: "yeww", + OrganizationID: organization.ID, + }) + require.NoError(t, err) + + return db, group + } + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var ( + db, group = setup(t) + r = httptest.NewRequest("GET", "/", nil) + w = httptest.NewRecorder() + ) + + router := chi.NewRouter() + router.Use(httpmw.ExtractGroupParam(db)) + router.Get("/", func(w http.ResponseWriter, r *http.Request) { + g := httpmw.GroupParam(r) + require.Equal(t, group, g) + w.WriteHeader(http.StatusOK) + }) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("group", group.ID.String()) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + + router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) + + t.Run("NotFound", func(t *testing.T) { + t.Parallel() + + var ( + db, group = setup(t) + r = httptest.NewRequest("GET", "/", nil) + w = httptest.NewRecorder() + ) + + router := chi.NewRouter() + router.Use(httpmw.ExtractGroupParam(db)) + router.Get("/", func(w http.ResponseWriter, r *http.Request) { + g := httpmw.GroupParam(r) + require.Equal(t, group, g) + w.WriteHeader(http.StatusOK) + }) + + rctx := chi.NewRouteContext() + rctx.URLParams.Add("group", uuid.NewString()) + r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx)) + + router.ServeHTTP(w, r) + + res := w.Result() + defer res.Body.Close() + require.Equal(t, http.StatusNotFound, res.StatusCode) + }) +} diff --git a/coderd/httpmw/templateversionparam.go b/coderd/httpmw/templateversionparam.go index 91b2cbf362ebd..ada988cfdf28b 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" @@ -46,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(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template.", + Detail: err.Error(), + }) + return + } + ctx = context.WithValue(ctx, 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/organizations.go b/coderd/organizations.go index cdc1040165011..0165f395c43c8 100644 --- a/coderd/organizations.go +++ b/coderd/organizations.go @@ -60,8 +60,8 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { } var organization database.Organization - err = api.Database.InTx(func(store database.Store) error { - organization, err = store.InsertOrganization(ctx, database.InsertOrganizationParams{ + err = api.Database.InTx(func(tx database.Store) error { + organization, err = tx.InsertOrganization(ctx, database.InsertOrganizationParams{ ID: uuid.New(), Name: req.Name, CreatedAt: database.Now(), @@ -70,7 +70,7 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create organization: %w", err) } - _, err = store.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ + _, err = tx.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, UserID: apiKey.UserID, CreatedAt: database.Now(), @@ -82,6 +82,11 @@ func (api *API) postOrganizations(rw http.ResponseWriter, r *http.Request) { if err != nil { return xerrors.Errorf("create organization admin: %w", err) } + + _, err = tx.InsertAllUsersGroup(ctx, organization.ID) + if err != nil { + return xerrors.Errorf("create %q group: %w", database.AllUsersGroup, err) + } return nil }) if err != nil { diff --git a/coderd/parameters.go b/coderd/parameters.go index 3dc73259457f3..ba3fd2349f48a 100644 --- a/coderd/parameters.go +++ b/coderd/parameters.go @@ -219,7 +219,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 8925e6b29e4b5..58d942f363ff4 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -3,7 +3,6 @@ package rbac import ( "context" _ "embed" - "fmt" "sync" "github.com/open-policy-agent/opa/rego" @@ -15,8 +14,8 @@ import ( ) type Authorizer interface { - ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error - PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) + ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, object Object) error + PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, objectType string) (PreparedAuthorized, error) } type PreparedAuthorized interface { @@ -27,7 +26,7 @@ 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. -func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, action Action, objects []O) ([]O, error) { +func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, groups []string, action Action, objects []O) ([]O, error) { ctx, span := tracing.StartSpan(ctx, trace.WithAttributes( attribute.String("subject_id", subjID), attribute.StringSlice("subject_roles", subjRoles), @@ -52,7 +51,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub if rbacObj.Type != objectType { return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, rbacObj) } - err := auth.ByRoleName(ctx, subjID, subjRoles, scope, action, o.RBACObject()) + err := auth.ByRoleName(ctx, subjID, subjRoles, scope, groups, action, o.RBACObject()) if err == nil { filtered = append(filtered, o) } @@ -60,7 +59,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub return filtered, nil } - prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, action, objectType) + prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, groups, action, objectType) if err != nil { return nil, xerrors.Errorf("prepare: %w", err) } @@ -95,21 +94,11 @@ var ( query rego.PreparedEvalQuery ) -const ( - rolesOkCheck = "role_ok" - scopeOkCheck = "scope_ok" -) - func NewAuthorizer() *RegoAuthorizer { queryOnce.Do(func() { var err error query, err = rego.New( - // Bind the results to 2 variables for easy checking later. - rego.Query( - fmt.Sprintf("%s := data.authz.role_allow "+ - "%s := data.authz.scope_allow", - rolesOkCheck, scopeOkCheck), - ), + rego.Query("data.authz.allow"), rego.Module("policy.rego", policy), ).PrepareForEval(context.Background()) if err != nil { @@ -120,15 +109,16 @@ func NewAuthorizer() *RegoAuthorizer { } type authSubject struct { - ID string `json:"id"` - Roles []Role `json:"roles"` - Scope Role `json:"scope"` + ID string `json:"id"` + Roles []Role `json:"roles"` + Groups []string `json:"groups"` + Scope Role `json:"scope"` } // ByRoleName will expand all roleNames into roles before calling Authorize(). // This is the function intended to be used outside this package. // The role is fetched from the builtin map located in memory. -func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error { +func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, object Object) error { roles, err := RolesByNames(roleNames) if err != nil { return err @@ -139,7 +129,7 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa return err } - err = a.Authorize(ctx, subjectID, roles, scopeRole, action, object) + err = a.Authorize(ctx, subjectID, roles, scopeRole, groups, action, object) if err != nil { return err } @@ -149,12 +139,16 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa // Authorize allows passing in custom Roles. // This is really helpful for unit testing, as we can create custom roles to exercise edge cases. -func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, scope Role, action Action, object Object) error { +func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, object Object) error { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + input := map[string]interface{}{ "subject": authSubject{ - ID: subjectID, - Roles: roles, - Scope: scope, + ID: subjectID, + Roles: roles, + Groups: groups, + Scope: scope, }, "object": object, "action": action, @@ -165,37 +159,19 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [ return ForbiddenWithInternal(xerrors.Errorf("eval rego: %w", err), input, results) } - // We expect only the 2 bindings for scopes and roles checks. - if len(results) == 1 && len(results[0].Bindings) == 2 { - roleCheck, ok := results[0].Bindings[rolesOkCheck].(bool) - if !ok || !roleCheck { - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) - } - - scopeCheck, ok := results[0].Bindings[scopeOkCheck].(bool) - if !ok || !scopeCheck { - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) - } - - // This is purely defensive programming. The two above checks already - // check for 'true' expressions. This is just a sanity check to make - // sure we don't add non-boolean expressions to our query. - // This is super cheap to do, and just adds in some extra safety for - // programmer error. - for _, exp := range results[0].Expressions { - if b, ok := exp.Value.(bool); !ok || !b { - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) - } - } - return nil + if !results.Allowed() { + return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) } - return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results) + return nil } // Prepare will partially execute the rego policy leaving the object fields unknown (except for the type). // This will vastly speed up performance if batch authorization on the same type of objects is needed. -func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Role, action Action, objectType string) (*PartialAuthorizer, error) { - auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, action, objectType) +func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, objectType string) (*PartialAuthorizer, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, groups, action, objectType) if err != nil { return nil, xerrors.Errorf("new partial authorizer: %w", err) } @@ -203,7 +179,10 @@ func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Rol return auth, nil } -func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) { +func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, objectType string) (PreparedAuthorized, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + roles, err := RolesByNames(roleNames) if err != nil { return nil, err @@ -214,5 +193,5 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, return nil, err } - return a.Prepare(ctx, subjectID, roles, scopeRole, action, objectType) + return a.Prepare(ctx, subjectID, roles, scopeRole, groups, action, objectType) } diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index e7b3b8522bcd1..1df2e17f823d9 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -19,8 +19,9 @@ type subject struct { // For the unit test we want to pass in the roles directly, instead of just // by name. This allows us to test custom roles that do not exist in the product, // but test edge cases of the implementation. - Roles []Role `json:"roles"` - Scope Role `json:"scope"` + Roles []Role `json:"roles"` + Groups []string `json:"groups"` + Scope Role `json:"scope"` } type fakeObject struct { @@ -41,7 +42,8 @@ func (w fakeObject) RBACObject() Object { func TestFilterError(t *testing.T) { t.Parallel() auth := NewAuthorizer() - _, err := Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, ActionRead, []Object{ResourceUser, ResourceWorkspace}) + + _, err := Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace}) require.ErrorContains(t, err, "object types must be uniform") } @@ -169,7 +171,7 @@ func TestFilter(t *testing.T) { var allowedCount int for i, obj := range localObjects { obj.Type = tc.ObjectType - err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, scope, ActionRead, obj.RBACObject()) + err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, scope, []string{}, ActionRead, obj.RBACObject()) obj.Allowed = err == nil if err == nil { allowedCount++ @@ -178,7 +180,7 @@ func TestFilter(t *testing.T) { } // Run by filter - list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, scope, tc.Action, localObjects) + list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, scope, []string{}, tc.Action, localObjects) require.NoError(t, err) require.Equal(t, allowedCount, len(list), "expected number of allowed") for _, obj := range list { @@ -193,15 +195,82 @@ func TestAuthorizeDomain(t *testing.T) { t.Parallel() defOrg := uuid.New() unuseID := uuid.New() + allUsersGroup := "Everyone" user := subject{ UserID: "me", + Scope: must(ScopeRole(ScopeAll)), + Groups: []string{allUsersGroup}, Roles: []Role{ must(RoleByName(RoleMember())), must(RoleByName(RoleOrgMember(defOrg))), }, } + testAuthorize(t, "UserACLList", 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{ActionUpdate}, + allow: true, + }, + }) + + testAuthorize(t, "GroupACLList", user, []authTestCase{ + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ + allUsersGroup: allActions(), + }), + actions: allActions(), + allow: true, + }, + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ + allUsersGroup: {WildcardSymbol}, + }), + actions: allActions(), + allow: true, + }, + { + resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{ + allUsersGroup: {ActionRead, ActionUpdate}, + }), + actions: []Action{ActionCreate, ActionDelete}, + allow: false, + }, + { + // By default users cannot update templates + resource: ResourceTemplate.InOrg(defOrg).WithGroupACL(map[string][]Action{ + allUsersGroup: {ActionUpdate}, + }), + actions: []Action{ActionUpdate}, + allow: true, + }, + }) + testAuthorize(t, "Member", user, []authTestCase{ // Org + me {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true}, @@ -743,9 +812,6 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes for _, cases := range sets { for i, c := range cases { c := c - if c.resource.Type != "application_connect" { - continue - } caseName := fmt.Sprintf("%s/%d", name, i) t.Run(caseName, func(t *testing.T) { t.Parallel() @@ -753,23 +819,21 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) t.Cleanup(cancel) - authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, subject.Scope, a, c.resource) + authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, subject.Scope, subject.Groups, a, c.resource) + + d, _ := json.Marshal(map[string]interface{}{ + "subject": subject, + "object": c.resource, + "action": a, + }) // Logging only + t.Logf("input: %s", string(d)) if authError != nil { var uerr *UnauthorizedError xerrors.As(authError, &uerr) - d, _ := json.Marshal(uerr.Input()) - t.Logf("input: %s", string(d)) t.Logf("internal error: %+v", uerr.Internal().Error()) t.Logf("output: %+v", uerr.Output()) - } else { - d, _ := json.Marshal(map[string]interface{}{ - "subject": subject, - "object": c.resource, - "action": a, - }) - t.Log(string(d)) } if c.allow { @@ -778,19 +842,17 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes assert.Error(t, authError, "expected unauthorized") } - partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, subject.Scope, a, c.resource.Type) + partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, subject.Scope, subject.Groups, a, c.resource.Type) require.NoError(t, err, "make prepared authorizer") // Ensure the partial can compile to a SQL clause. // This does not guarantee that the clause is valid SQL. - _, err = Compile(partialAuthz.partialQueries) + _, err = Compile(partialAuthz) require.NoError(t, err, "compile prepared authorizer") // Also check the rego policy can form a valid partial query result. // This ensures we can convert the queries into SQL WHERE clauses in the future. // If this function returns 'Support' sections, then we cannot convert the query into SQL. - d, _ := json.Marshal(partialAuthz.input) - t.Logf("input: %s", string(d)) for _, q := range partialAuthz.partialQueries.Queries { t.Logf("query: %+v", q.String()) } diff --git a/coderd/rbac/builtin.go b/coderd/rbac/builtin.go index 7115162d75dad..2fb4e6f251982 100644 --- a/coderd/rbac/builtin.go +++ b/coderd/rbac/builtin.go @@ -63,8 +63,8 @@ var ( return Role{ Name: owner, DisplayName: "Owner", - Site: permissions(map[Object][]Action{ - ResourceWildcard: {WildcardSymbol}, + Site: permissions(map[string][]Action{ + ResourceWildcard.Type: {WildcardSymbol}, }), } }, @@ -74,15 +74,15 @@ var ( return Role{ Name: member, DisplayName: "", - Site: permissions(map[Object][]Action{ + Site: permissions(map[string][]Action{ // All users can read all other users and know they exist. - ResourceUser: {ActionRead}, - ResourceRoleAssignment: {ActionRead}, + ResourceUser.Type: {ActionRead}, + ResourceRoleAssignment.Type: {ActionRead}, // All users can see the provisioner daemons. - ResourceProvisionerDaemon: {ActionRead}, + ResourceProvisionerDaemon.Type: {ActionRead}, }), - User: permissions(map[Object][]Action{ - ResourceWildcard: {WildcardSymbol}, + User: permissions(map[string][]Action{ + ResourceWildcard.Type: {WildcardSymbol}, }), } }, @@ -94,11 +94,11 @@ var ( return Role{ Name: auditor, DisplayName: "Auditor", - Site: permissions(map[Object][]Action{ + Site: permissions(map[string][]Action{ // Should be able to read all template details, even in orgs they // are not in. - ResourceTemplate: {ActionRead}, - ResourceAuditLog: {ActionRead}, + ResourceTemplate.Type: {ActionRead}, + ResourceAuditLog.Type: {ActionRead}, }), } }, @@ -107,13 +107,13 @@ var ( return Role{ Name: templateAdmin, DisplayName: "Template Admin", - Site: permissions(map[Object][]Action{ - ResourceTemplate: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + Site: permissions(map[string][]Action{ + ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, // CRUD all files, even those they did not upload. - ResourceFile: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceWorkspace: {ActionRead}, + ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceWorkspace.Type: {ActionRead}, // CRUD to provisioner daemons for now. - ResourceProvisionerDaemon: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, }), } }, @@ -122,11 +122,11 @@ var ( return Role{ Name: userAdmin, DisplayName: "User Admin", - Site: permissions(map[Object][]Action{ - ResourceRoleAssignment: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, - ResourceUser: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + Site: permissions(map[string][]Action{ + ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, // Full perms to manage org members - ResourceOrganizationMember: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, + ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete}, }), } }, @@ -168,13 +168,12 @@ var ( Action: ActionRead, }, { - // All org members can read templates in the org - ResourceType: ResourceTemplate.Type, + // Can read available roles. + ResourceType: ResourceOrgRoleAssignment.Type, Action: ActionRead, }, { - // Can read available roles. - ResourceType: ResourceOrgRoleAssignment.Type, + ResourceType: ResourceGroup.Type, Action: ActionRead, }, }, @@ -390,14 +389,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/builtin_test.go b/coderd/rbac/builtin_test.go index 317931e87cf76..5c744d0d259a8 100644 --- a/coderd/rbac/builtin_test.go +++ b/coderd/rbac/builtin_test.go @@ -32,6 +32,7 @@ func BenchmarkRBACFilter(b *testing.B) { benchCases := []struct { Name string Roles []string + Groups []string UserID uuid.UUID Scope rbac.Scope }{ @@ -87,7 +88,7 @@ func BenchmarkRBACFilter(b *testing.B) { b.Run(c.Name, func(b *testing.B) { objects := benchmarkSetup(orgs, users, b.N) b.ResetTimer() - allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Scope, rbac.ActionRead, objects) + allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Scope, c.Groups, rbac.ActionRead, objects) require.NoError(b, err) var _ = allowed }) @@ -96,11 +97,17 @@ func BenchmarkRBACFilter(b *testing.B) { func benchmarkSetup(orgs []uuid.UUID, users []uuid.UUID, size int) []rbac.Object { // Create a "random" but deterministic set of objects. + aclList := map[string][]rbac.Action{ + uuid.NewString(): {rbac.ActionRead, rbac.ActionUpdate}, + uuid.NewString(): {rbac.ActionCreate}, + } objectList := make([]rbac.Object, size) for i := range objectList { objectList[i] = rbac.ResourceWorkspace. InOrg(orgs[i%len(orgs)]). - WithOwner(users[i%len(users)].String()) + WithOwner(users[i%len(users)].String()). + WithACLUserList(aclList). + WithGroupACL(aclList) } return objectList @@ -111,6 +118,7 @@ type authSubject struct { Name string UserID string Roles []string + Groups []string } func TestRolePermissions(t *testing.T) { @@ -227,8 +235,8 @@ func TestRolePermissions(t *testing.T) { Actions: []rbac.Action{rbac.ActionRead}, Resource: rbac.ResourceTemplate.InOrg(orgID), AuthorizeMap: map[bool][]authSubject{ - true: {owner, orgMemberMe, orgAdmin, templateAdmin}, - false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin}, + true: {owner, orgAdmin, templateAdmin}, + false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin, orgMemberMe}, }, }, { @@ -242,7 +250,7 @@ func TestRolePermissions(t *testing.T) { }, { Name: "MyFile", - Actions: []rbac.Action{rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}, + Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete}, Resource: rbac.ResourceFile.WithOwner(currentUser.String()), AuthorizeMap: map[bool][]authSubject{ true: {owner, memberMe, orgMemberMe, templateAdmin}, @@ -348,6 +356,19 @@ func TestRolePermissions(t *testing.T) { false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin}, }, }, + { + Name: "AllUsersGroupACL", + Actions: []rbac.Action{rbac.ActionRead}, + Resource: rbac.ResourceTemplate.InOrg(orgID).WithGroupACL( + map[string][]rbac.Action{ + orgID.String(): {rbac.ActionRead}, + }), + + AuthorizeMap: map[bool][]authSubject{ + true: {owner, orgAdmin, orgMemberMe, templateAdmin}, + false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin}, + }, + }, } for _, c := range testCases { @@ -365,7 +386,7 @@ func TestRolePermissions(t *testing.T) { delete(remainingSubjs, subj.Name) msg := fmt.Sprintf("%s as %q doing %q on %q", c.Name, subj.Name, action, c.Resource.Type) // TODO: scopey - err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAll, action, c.Resource) + err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAll, subj.Groups, action, c.Resource) if result { assert.NoError(t, err, fmt.Sprintf("Should pass: %s", msg)) } else { diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index 1a1e86738cdf7..5492e4397d5f7 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -54,6 +54,14 @@ var ( Type: "template", } + // ResourceGroup CRUD. Org admins only. + // create/delete = Make or delete a new group. + // update = Update the name or members of a group. + // read = Read groups and their members. + ResourceGroup = Object{ + Type: "group", + } + ResourceFile = Object{ Type: "file", } @@ -152,7 +160,9 @@ type Object struct { // Type is "workspace", "project", "app", etc Type string `json:"type"` - // TODO: SharedUsers? + + ACLUserList map[string][]Action ` json:"acl_user_list"` + ACLGroupList map[string][]Action ` json:"acl_group_list"` } func (z Object) RBACObject() Object { @@ -162,26 +172,53 @@ func (z Object) RBACObject() Object { // All returns an object matching all resources of the same type. func (z Object) All() Object { return Object{ - Owner: "", - OrgID: "", - Type: z.Type, + Owner: "", + OrgID: "", + Type: z.Type, + ACLUserList: map[string][]Action{}, + ACLGroupList: map[string][]Action{}, } } // InOrg adds an org OwnerID to the resource func (z Object) InOrg(orgID uuid.UUID) Object { return Object{ - Owner: z.Owner, - OrgID: orgID.String(), - Type: z.Type, + Owner: z.Owner, + OrgID: orgID.String(), + Type: z.Type, + ACLUserList: z.ACLUserList, + ACLGroupList: z.ACLGroupList, } } // WithOwner adds an OwnerID to the resource func (z Object) WithOwner(ownerID string) Object { return Object{ - Owner: ownerID, - OrgID: z.OrgID, - Type: z.Type, + Owner: ownerID, + OrgID: z.OrgID, + Type: z.Type, + ACLUserList: z.ACLUserList, + ACLGroupList: z.ACLGroupList, + } +} + +// WithACLUserList adds an ACL list to a given object +func (z Object) WithACLUserList(acl map[string][]Action) Object { + return Object{ + Owner: z.Owner, + OrgID: z.OrgID, + Type: z.Type, + ACLUserList: acl, + ACLGroupList: z.ACLGroupList, + } +} + +func (z Object) WithGroupACL(groups map[string][]Action) Object { + return Object{ + Owner: z.Owner, + OrgID: z.OrgID, + Type: z.Type, + ACLUserList: z.ACLUserList, + ACLGroupList: groups, } } diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index 6dfd0827f39f4..6049b6754fa84 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -29,7 +29,7 @@ type PartialAuthorizer struct { var _ PreparedAuthorized = (*PartialAuthorizer)(nil) func (pa *PartialAuthorizer) Compile() (AuthorizeFilter, error) { - filter, err := Compile(pa.partialQueries) + filter, err := Compile(pa) if err != nil { return nil, xerrors.Errorf("compile: %w", err) } @@ -99,7 +99,7 @@ EachQueryLoop: // inspect this any further. But just in case, we will verify each expression // did resolve to 'true'. This is purely defensive programming. for _, exp := range results[0].Expressions { - if exp.String() != "true" { + if v, ok := exp.Value.(bool); !ok || !v { continue EachQueryLoop } } @@ -110,15 +110,16 @@ EachQueryLoop: return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), pa.input, nil) } -func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, scope Role, action Action, objectType string) (*PartialAuthorizer, error) { +func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, objectType string) (*PartialAuthorizer, error) { ctx, span := tracing.StartSpan(ctx) defer span.End() input := map[string]interface{}{ "subject": authSubject{ - ID: subjectID, - Roles: roles, - Scope: scope, + ID: subjectID, + Roles: roles, + Scope: scope, + Groups: groups, }, "object": map[string]string{ "type": objectType, @@ -129,11 +130,13 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, s // Run the rego policy with a few unknown fields. This should simplify our // policy to a set of queries. partialQueries, err := rego.New( - rego.Query("data.authz.role_allow = true data.authz.scope_allow = true"), + rego.Query("data.authz.allow = true"), rego.Module("policy.rego", policy), rego.Unknowns([]string{ "input.object.owner", "input.object.org_owner", + "input.object.acl_user_list", + "input.object.acl_group_list", }), rego.Input(input), ).Partial(ctx) diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index fb7f61c3a711a..095f1844bd78d 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -2,8 +2,8 @@ package authz import future.keywords # A great playground: https://play.openpolicyagent.org/ # Helpful cli commands to debug. -# opa eval --format=pretty 'data.authz.role_allow data.authz.scope_allow' -d policy.rego -i input.json -# opa eval --partial --format=pretty 'data.authz.role_allow = true data.authz.scope_allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner -i input.json +# opa eval --format=pretty 'data.authz.allow' -d policy.rego -i input.json +# opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json # # This policy is specifically constructed to compress to a set of queries if the @@ -119,9 +119,13 @@ org_mem := true { input.object.org_owner in org_members } +org_ok { + org_mem +} + # If the object has no organization, then the user is also considered part of # the non-existent org. -org_mem := true { +org_ok { input.object.org_owner == "" } @@ -156,7 +160,6 @@ user_allow(roles) := num { # Allow query: # data.authz.role_allow = true data.authz.scope_allow = true -default role_allow = false role_allow { site = 1 } @@ -171,12 +174,10 @@ role_allow { not org = -1 # If we are not a member of an org, and the object has an org, then we are # not authorized. This is an "implied -1" for not being in the org. - org_mem + org_ok user = 1 } - -default scope_allow = false scope_allow { scope_site = 1 } @@ -191,6 +192,48 @@ scope_allow { not scope_org = -1 # If we are not a member of an org, and the object has an org, then we are # not authorized. This is an "implied -1" for not being in the org. - org_mem + org_ok scope_user = 1 } + +# ACL for users +acl_allow { + # Should you have to be a member of the org too? + perms := input.object.acl_user_list[input.subject.id] + # Either the input action or wildcard + [input.action, "*"][_] in perms +} + +# ACL for groups +acl_allow { + # If there is no organization owner, the object cannot be owned by an + # org_scoped team. + org_mem + group := input.subject.groups[_] + perms := input.object.acl_group_list[group] + # Either the input action or wildcard + [input.action, "*"][_] in perms +} + +# ACL for 'all_users' special group +acl_allow { + org_mem + perms := input.object.acl_group_list[input.object.org_owner] + [input.action, "*"][_] in perms +} + +############### +# Final Allow +# The role or the ACL must allow the action. Scopes can be used to limit, +# so scope_allow must always be true. + +allow { + role_allow + scope_allow +} + +# ACL list must also have the scope_allow to pass +allow { + acl_allow + scope_allow +} diff --git a/coderd/rbac/query.go b/coderd/rbac/query.go index d8b1a140e9eb0..d2442153d7920 100644 --- a/coderd/rbac/query.go +++ b/coderd/rbac/query.go @@ -1,13 +1,13 @@ package rbac import ( + "context" "fmt" "regexp" "strconv" "strings" "github.com/open-policy-agent/opa/ast" - "github.com/open-policy-agent/opa/rego" "golang.org/x/xerrors" ) @@ -16,6 +16,9 @@ type TermType string const ( VarTypeJsonbTextArray TermType = "jsonb-text-array" VarTypeText TermType = "text" + VarTypeBoolean TermType = "boolean" + // VarTypeSkip means this variable does not exist to use. + VarTypeSkip TermType = "skip" ) type SQLColumn struct { @@ -79,19 +82,54 @@ func DefaultConfig() SQLConfig { } } +func NoACLConfig() SQLConfig { + return SQLConfig{ + Variables: []SQLColumn{ + { + RegoMatch: regexp.MustCompile(`^input\.object\.acl_group_list\.?(.*)$`), + ColumnSelect: "", + Type: VarTypeSkip, + }, + { + RegoMatch: regexp.MustCompile(`^input\.object\.acl_user_list\.?(.*)$`), + ColumnSelect: "", + Type: VarTypeSkip, + }, + { + RegoMatch: regexp.MustCompile(`^input\.object\.org_owner$`), + ColumnSelect: "organization_id :: text", + Type: VarTypeText, + }, + { + RegoMatch: regexp.MustCompile(`^input\.object\.owner$`), + ColumnSelect: "owner_id :: text", + Type: VarTypeText, + }, + }, + } +} + type AuthorizeFilter interface { - // RegoString is used in debugging to see the original rego expression. - RegoString() string - // SQLString returns the SQL expression that can be used in a WHERE clause. - SQLString(cfg SQLConfig) string + Expression // Eval is required for the fake in memory database to work. The in memory // database can use this function to filter the results. Eval(object Object) bool } +// expressionTop handles Eval(object Object) for in memory expressions +type expressionTop struct { + Expression + Auth *PartialAuthorizer +} + +func (e expressionTop) Eval(object Object) bool { + return e.Auth.Authorize(context.Background(), object) == nil +} + // Compile will convert a rego query AST into our custom types. The output is // an AST that can be used to generate SQL. -func Compile(partialQueries *rego.PartialQueries) (Expression, error) { +func Compile(pa *PartialAuthorizer) (AuthorizeFilter, error) { + partialQueries := pa.partialQueries if len(partialQueries.Support) > 0 { return nil, xerrors.Errorf("cannot convert support rules, expect 0 found %d", len(partialQueries.Support)) } @@ -128,11 +166,15 @@ func Compile(partialQueries *rego.PartialQueries) (Expression, error) { } builder.WriteString(partialQueries.Queries[i].String()) } - return expOr{ + exp := expOr{ base: base{ Rego: builder.String(), }, Expressions: result, + } + return expressionTop{ + Expression: &exp, + Auth: pa, }, nil } @@ -218,21 +260,22 @@ func processTerms(expected int, terms []*ast.Term) ([]Term, error) { } func processTerm(term *ast.Term) (Term, error) { - base := base{Rego: term.String()} + termBase := base{Rego: term.String()} switch v := term.Value.(type) { case ast.Boolean: return &termBoolean{ - base: base, + base: termBase, Value: bool(v), }, nil case ast.Ref: obj := &termObject{ - base: base, - Variables: []termVariable{}, + base: termBase, + Path: []Term{}, } var idx int // A ref is a set of terms. If the first term is a var, then the // following terms are the path to the value. + isRef := true var builder strings.Builder for _, term := range v { if idx == 0 { @@ -241,15 +284,37 @@ func processTerm(term *ast.Term) (Term, error) { } } - if _, ok := term.Value.(ast.Ref); ok { + _, newRef := term.Value.(ast.Ref) + if newRef || + // This is an unfortunate hack. To fix this, we need to rewrite + // our SQL config as a path ([]string{"input", "object", "acl_group"}). + // In the rego AST, there is no difference between selecting + // a field by a variable, and selecting a field by a literal (string). + // This was a misunderstanding. + // Example (these are equivalent by AST): + // input.object.acl_group_list['4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75'] + // input.object.acl_group_list.organization_id + // + // This is not equivalent + // input.object.acl_group_list[input.object.organization_id] + // + // If this becomes even more hairy, we should fix the sql config. + builder.String() == "input.object.acl_group_list" || + builder.String() == "input.object.acl_user_list" { + if !newRef { + isRef = false + } // New obj - obj.Variables = append(obj.Variables, termVariable{ - base: base, + obj.Path = append(obj.Path, termVariable{ + base: base{ + Rego: builder.String(), + }, Name: builder.String(), }) builder.Reset() idx = 0 } + if builder.Len() != 0 { builder.WriteString(".") } @@ -257,20 +322,31 @@ func processTerm(term *ast.Term) (Term, error) { idx++ } - obj.Variables = append(obj.Variables, termVariable{ - base: base, - Name: builder.String(), - }) + if isRef { + obj.Path = append(obj.Path, termVariable{ + base: base{ + Rego: builder.String(), + }, + Name: builder.String(), + }) + } else { + obj.Path = append(obj.Path, termString{ + base: base{ + Rego: fmt.Sprintf("%q", builder.String()), + }, + Value: builder.String(), + }) + } return obj, nil case ast.Var: return &termVariable{ Name: trimQuotes(v.String()), - base: base, + base: termBase, }, nil case ast.String: return &termString{ Value: trimQuotes(v.String()), - base: base, + base: termBase, }, nil case ast.Set: slice := v.Slice() @@ -285,7 +361,7 @@ func processTerm(term *ast.Term) (Term, error) { return &termSet{ Value: set, - base: base, + base: termBase, }, nil default: return nil, xerrors.Errorf("invalid term: %T not supported, %q", v, term.String()) @@ -306,7 +382,10 @@ func (b base) RegoString() string { // // Eg: neq(input.object.org_owner, "") AND input.object.org_owner == "foo" type Expression interface { - AuthorizeFilter + // RegoString is used in debugging to see the original rego expression. + RegoString() string + // SQLString returns the SQL expression that can be used in a WHERE clause. + SQLString(cfg SQLConfig) string } type expAnd struct { @@ -326,15 +405,6 @@ func (t expAnd) SQLString(cfg SQLConfig) string { return "(" + strings.Join(exprs, " AND ") + ")" } -func (t expAnd) Eval(object Object) bool { - for _, expr := range t.Expressions { - if !expr.Eval(object) { - return false - } - } - return true -} - type expOr struct { base Expressions []Expression @@ -352,15 +422,6 @@ func (t expOr) SQLString(cfg SQLConfig) string { return "(" + strings.Join(exprs, " OR ") + ")" } -func (t expOr) Eval(object Object) bool { - for _, expr := range t.Expressions { - if expr.Eval(object) { - return true - } - } - return false -} - // Operator joins terms together to form an expression. // Operators are also expressions. // @@ -384,14 +445,6 @@ func (t opEqual) SQLString(cfg SQLConfig) string { return fmt.Sprintf("%s %s %s", t.Terms[0].SQLString(cfg), op, t.Terms[1].SQLString(cfg)) } -func (t opEqual) Eval(object Object) bool { - a, b := t.Terms[0].EvalTerm(object), t.Terms[1].EvalTerm(object) - if t.Not { - return a != b - } - return a == b -} - // opInternalMember2 is checking if the first term is a member of the second term. // The second term is a set or list. type opInternalMember2 struct { @@ -400,20 +453,6 @@ type opInternalMember2 struct { Haystack Term } -func (t opInternalMember2) Eval(object Object) bool { - a, b := t.Needle.EvalTerm(object), t.Haystack.EvalTerm(object) - bset, ok := b.([]interface{}) - if !ok { - return false - } - for _, elem := range bset { - if a == elem { - return true - } - } - return false -} - func (t opInternalMember2) SQLString(cfg SQLConfig) string { if haystack, ok := t.Haystack.(*termObject); ok { // This is a special case where the haystack is a jsonb array. @@ -425,9 +464,14 @@ func (t opInternalMember2) SQLString(cfg SQLConfig) string { // having to add more "if" branches here. // But until we need more cases, our basic type system is ok, and // this is the only case we need to handle. - if haystack.SQLType(cfg) == VarTypeJsonbTextArray { + sqlType := haystack.SQLType(cfg) + if sqlType == VarTypeJsonbTextArray { return fmt.Sprintf("%s ? %s", haystack.SQLString(cfg), t.Needle.SQLString(cfg)) } + + if sqlType == VarTypeSkip { + return "true" + } } return fmt.Sprintf("%s = ANY(%s)", t.Needle.SQLString(cfg), t.Haystack.SQLString(cfg)) @@ -440,9 +484,7 @@ func (t opInternalMember2) SQLString(cfg SQLConfig) string { type Term interface { RegoString() string SQLString(cfg SQLConfig) string - // Eval will evaluate the term - // Terms can eval to any type. The operator/expression will type check. - EvalTerm(object Object) interface{} + SQLType(cfg SQLConfig) TermType } type termString struct { @@ -450,10 +492,6 @@ type termString struct { Value string } -func (t termString) EvalTerm(_ Object) interface{} { - return t.Value -} - func (t termString) SQLString(_ SQLConfig) string { return "'" + t.Value + "'" } @@ -471,14 +509,7 @@ func (termString) SQLType(_ SQLConfig) TermType { // term type. type termObject struct { base - Variables []termVariable -} - -func (t termObject) EvalTerm(obj Object) interface{} { - if len(t.Variables) == 0 { - return t.Variables[0].EvalTerm(obj) - } - panic("no nested structures are supported yet") + Path []Term } func (t termObject) SQLType(cfg SQLConfig) TermType { @@ -486,30 +517,30 @@ func (t termObject) SQLType(cfg SQLConfig) TermType { // is the resulting type. This is correct for our use case. // Solving this more generally requires a full type system, which is // excessive for our mostly static policy. - return t.Variables[0].SQLType(cfg) + return t.Path[0].SQLType(cfg) } func (t termObject) SQLString(cfg SQLConfig) string { - if len(t.Variables) == 1 { - return t.Variables[0].SQLString(cfg) + if len(t.Path) == 1 { + return t.Path[0].SQLString(cfg) } // Combine the last 2 variables into 1 variable. - end := t.Variables[len(t.Variables)-1] - before := t.Variables[len(t.Variables)-2] + end := t.Path[len(t.Path)-1] + before := t.Path[len(t.Path)-2] // Recursively solve the SQLString by removing the last nested reference. // This continues until we have a single variable. return termObject{ base: t.base, - Variables: append( - t.Variables[:len(t.Variables)-2], + Path: append( + t.Path[:len(t.Path)-2], termVariable{ base: base{ - Rego: before.base.Rego + "[" + end.base.Rego + "]", + Rego: before.RegoString() + "[" + end.RegoString() + "]", }, // Convert the end to SQL string. We evaluate each term // one at a time. - Name: before.Name + "." + end.SQLString(cfg), + Name: before.RegoString() + "." + end.SQLString(cfg), }, ), }.SQLString(cfg) @@ -520,19 +551,6 @@ type termVariable struct { Name string } -func (t termVariable) EvalTerm(obj Object) interface{} { - switch t.Name { - case "input.object.org_owner": - return obj.OrgID - case "input.object.owner": - return obj.Owner - case "input.object.type": - return obj.Type - default: - return fmt.Sprintf("'Unknown variable %s'", t.Name) - } -} - func (t termVariable) SQLType(cfg SQLConfig) TermType { if col := t.ColumnConfig(cfg); col != nil { return col.Type @@ -576,13 +594,15 @@ type termSet struct { Value []Term } -func (t termSet) EvalTerm(obj Object) interface{} { - set := make([]interface{}, 0, len(t.Value)) - for _, term := range t.Value { - set = append(set, term.EvalTerm(obj)) +func (t termSet) SQLType(cfg SQLConfig) TermType { + if len(t.Value) == 0 { + return VarTypeText } - - return set + // Without a full type system, let's just assume the type of the first var + // is the resulting type. This is correct for our use case. + // Solving this more generally requires a full type system, which is + // excessive for our mostly static policy. + return t.Value[0].SQLType(cfg) } func (t termSet) SQLString(cfg SQLConfig) string { @@ -599,11 +619,11 @@ type termBoolean struct { Value bool } -func (t termBoolean) Eval(_ Object) bool { - return t.Value +func (termBoolean) SQLType(SQLConfig) TermType { + return VarTypeBoolean } -func (t termBoolean) EvalTerm(_ Object) interface{} { +func (t termBoolean) Eval(_ Object) bool { return t.Value } diff --git a/coderd/rbac/query_internal_test.go b/coderd/rbac/query_internal_test.go index 92d8b91543953..712b063787830 100644 --- a/coderd/rbac/query_internal_test.go +++ b/coderd/rbac/query_internal_test.go @@ -1,6 +1,7 @@ package rbac import ( + "context" "testing" "github.com/open-policy-agent/opa/ast" @@ -11,17 +12,10 @@ import ( func TestCompileQuery(t *testing.T) { t.Parallel() - opts := ast.ParserOptions{ - AllFutureKeywords: true, - } + t.Run("EmptyQuery", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - must(ast.ParseBody("")), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, "")) require.NoError(t, err, "compile empty") require.Equal(t, "true", expression.RegoString(), "empty query is rego 'true'") @@ -30,12 +24,7 @@ func TestCompileQuery(t *testing.T) { t.Run("TrueQuery", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - must(ast.ParseBody("true")), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, "true")) require.NoError(t, err, "compile") require.Equal(t, "true", expression.RegoString(), "true query is rego 'true'") @@ -44,49 +33,118 @@ func TestCompileQuery(t *testing.T) { t.Run("ACLIn", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list.allUsers`, opts), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, `"*" in input.object.acl_group_list.allUsers`)) require.NoError(t, err, "compile") require.Equal(t, `internal.member_2("*", input.object.acl_group_list.allUsers)`, expression.RegoString(), "convert to internal_member") - require.Equal(t, `group_acl->allUsers ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in") + require.Equal(t, `group_acl->'allUsers' ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in") }) t.Run("Complex", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts), - ast.MustParseBodyWithOpts(`input.object.org_owner in {"a", "b", "c"}`, opts), - ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts), - ast.MustParseBodyWithOpts(`"read" in input.object.acl_group_list.allUsers`, opts), - ast.MustParseBodyWithOpts(`"read" in input.object.acl_user_list.me`, opts), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, + `input.object.org_owner != ""`, + `input.object.org_owner in {"a", "b", "c"}`, + `input.object.org_owner != ""`, + `"read" in input.object.acl_group_list.allUsers`, + `"read" in input.object.acl_user_list.me`, + )) require.NoError(t, err, "compile") require.Equal(t, `(organization_id :: text != '' OR `+ `organization_id :: text = ANY(ARRAY ['a','b','c']) OR `+ `organization_id :: text != '' OR `+ - `group_acl->allUsers ? 'read' OR `+ - `user_acl->me ? 'read')`, + `group_acl->'allUsers' ? 'read' OR `+ + `user_acl->'me' ? 'read')`, expression.SQLString(DefaultConfig()), "complex") }) t.Run("SetDereference", func(t *testing.T) { t.Parallel() - expression, err := Compile(®o.PartialQueries{ - Queries: []ast.Body{ - ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list[input.object.org_owner]`, opts), - }, - Support: []*ast.Module{}, - }) + expression, err := Compile(partialQueries(t, + `"*" in input.object.acl_group_list[input.object.org_owner]`, + )) require.NoError(t, err, "compile") require.Equal(t, `group_acl->organization_id :: text ? '*'`, expression.SQLString(DefaultConfig()), "set dereference") }) + + t.Run("JsonbLiteralDereference", func(t *testing.T) { + t.Parallel() + expression, err := Compile(partialQueries(t, + `"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, + )) + require.NoError(t, err, "compile") + require.Equal(t, `group_acl->'4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75' ? '*'`, + expression.SQLString(DefaultConfig()), "literal dereference") + }) + + t.Run("NoACLColumns", func(t *testing.T) { + t.Parallel() + expression, err := Compile(partialQueries(t, + `"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, + )) + require.NoError(t, err, "compile") + require.Equal(t, `true`, + expression.SQLString(NoACLConfig()), "literal dereference") + }) +} + +func TestEvalQuery(t *testing.T) { + t.Parallel() + + t.Run("GroupACL", func(t *testing.T) { + t.Parallel() + expression, err := Compile(partialQueries(t, + `"read" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`, + )) + require.NoError(t, err, "compile") + + result := expression.Eval(Object{ + Owner: "not-me", + OrgID: "random", + Type: "workspace", + ACLUserList: map[string][]Action{}, + ACLGroupList: map[string][]Action{ + "4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75": {"read"}, + }, + }) + require.True(t, result, "eval") + }) +} + +func partialQueries(t *testing.T, queries ...string) *PartialAuthorizer { + opts := ast.ParserOptions{ + AllFutureKeywords: true, + } + + astQueries := make([]ast.Body, 0, len(queries)) + for _, q := range queries { + astQueries = append(astQueries, ast.MustParseBodyWithOpts(q, opts)) + } + + prepareQueries := make([]rego.PreparedEvalQuery, 0, len(queries)) + for _, q := range astQueries { + var prepped rego.PreparedEvalQuery + var err error + if q.String() == "" { + prepped, err = rego.New( + rego.Query("true"), + ).PrepareForEval(context.Background()) + } else { + prepped, err = rego.New( + rego.ParsedQuery(q), + ).PrepareForEval(context.Background()) + } + require.NoError(t, err, "prepare query") + prepareQueries = append(prepareQueries, prepped) + } + return &PartialAuthorizer{ + partialQueries: ®o.PartialQueries{ + Queries: astQueries, + Support: []*ast.Module{}, + }, + preparedQueries: prepareQueries, + input: nil, + alwaysTrue: false, + } } 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 6721aa0eb41fa..3366e02b14216 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -61,11 +61,6 @@ func (api *API) template(rw http.ResponseWriter, r *http.Request) { return } - if !api.Authorize(r, rbac.ActionRead, template) { - httpapi.ResourceNotFound(rw) - return - } - count := uint32(0) if len(workspaceCounts) > 0 { count = uint32(workspaceCounts[0].Count) @@ -248,9 +243,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque var dbTemplate database.Template var template codersdk.Template - err = api.Database.InTx(func(db database.Store) error { + err = api.Database.InTx(func(tx database.Store) error { now := database.Now() - dbTemplate, err = db.InsertTemplate(ctx, database.InsertTemplateParams{ + dbTemplate, err = tx.InsertTemplate(ctx, database.InsertTemplateParams{ ID: uuid.New(), CreatedAt: now, UpdatedAt: now, @@ -269,7 +264,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque templateAudit.New = dbTemplate - err = db.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ + err = tx.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ ID: templateVersion.ID, TemplateID: uuid.NullUUID{ UUID: dbTemplate.ID, @@ -288,7 +283,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque templateVersionAudit.New = newTemplateVersion for _, parameterValue := range createTemplate.ParameterValues { - _, err = db.InsertParameterValue(ctx, database.InsertParameterValueParams{ + _, err = tx.InsertParameterValue(ctx, database.InsertParameterValueParams{ ID: uuid.New(), Name: parameterValue.Name, CreatedAt: database.Now(), @@ -304,7 +299,14 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } } - createdByNameMap, err := getCreatedByNamesByTemplateIDs(ctx, db, []database.Template{dbTemplate}) + err = tx.UpdateTemplateGroupACLByID(ctx, dbTemplate.ID, database.TemplateACL{ + dbTemplate.OrganizationID.String(): []rbac.Action{rbac.ActionRead}, + }) + if err != nil { + return xerrors.Errorf("update template group acl: %w", err) + } + + createdByNameMap, err := getCreatedByNamesByTemplateIDs(ctx, tx, []database.Template{dbTemplate}) if err != nil { return xerrors.Errorf("get creator name: %w", err) } @@ -472,13 +474,7 @@ 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(ctx, 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()}) } if len(validErrs) > 0 { @@ -491,9 +487,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(ctx, []uuid.UUID{template.ID}) + workspaceCounts, err := tx.GetWorkspaceOwnerCountsByTemplateIDs(ctx, []uuid.UUID{template.ID}) if xerrors.Is(err, sql.ErrNoRows) { err = nil } @@ -530,7 +526,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { minAutostartInterval = time.Duration(template.MinAutostartInterval) } - updated, err = s.UpdateTemplateMetaByID(ctx, database.UpdateTemplateMetaByIDParams{ + updated, err = tx.UpdateTemplateMetaByID(ctx, database.UpdateTemplateMetaByIDParams{ ID: template.ID, UpdatedAt: database.Now(), Name: name, @@ -597,13 +593,13 @@ type autoImportTemplateOpts struct { func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateOpts) (database.Template, error) { var template database.Template - err := api.Database.InTx(func(s database.Store) error { + err := api.Database.InTx(func(tx database.Store) error { // Insert the archive into the files table. var ( hash = sha256.Sum256(opts.archive) now = database.Now() ) - file, err := s.InsertFile(ctx, database.InsertFileParams{ + file, err := tx.InsertFile(ctx, database.InsertFileParams{ Hash: hex.EncodeToString(hash[:]), CreatedAt: now, CreatedBy: opts.userID, @@ -618,7 +614,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO // Insert parameters for key, value := range opts.params { - _, err = s.InsertParameterValue(ctx, database.InsertParameterValueParams{ + _, err = tx.InsertParameterValue(ctx, database.InsertParameterValueParams{ ID: uuid.New(), Name: key, CreatedAt: now, @@ -635,7 +631,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO } // Create provisioner job - job, err := s.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + job, err := tx.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: jobID, CreatedAt: now, UpdatedAt: now, @@ -652,7 +648,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO } // Create template version - templateVersion, err := s.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + templateVersion, err := tx.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ ID: uuid.New(), TemplateID: uuid.NullUUID{ UUID: uuid.Nil, @@ -674,7 +670,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO } // Create template - template, err = s.InsertTemplate(ctx, database.InsertTemplateParams{ + template, err = tx.InsertTemplate(ctx, database.InsertTemplateParams{ ID: uuid.New(), CreatedAt: now, UpdatedAt: now, @@ -692,7 +688,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO } // Update template version with template ID - err = s.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ + err = tx.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{ ID: templateVersion.ID, TemplateID: uuid.NullUUID{ UUID: template.ID, @@ -705,7 +701,7 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO // Insert parameters at the template scope for key, value := range opts.params { - _, err = s.InsertParameterValue(ctx, database.InsertParameterValueParams{ + _, err = tx.InsertParameterValue(ctx, database.InsertParameterValueParams{ ID: uuid.New(), Name: key, CreatedAt: now, @@ -721,6 +717,13 @@ func (api *API) autoImportTemplate(ctx context.Context, opts autoImportTemplateO } } + err = tx.UpdateTemplateGroupACLByID(ctx, template.ID, database.TemplateACL{ + opts.orgID.String(): []rbac.Action{rbac.ActionRead}, + }) + if err != nil { + return xerrors.Errorf("update template group acl: %w", err) + } + return nil }) diff --git a/coderd/templateversions.go b/coderd/templateversions.go index a4828779d0ec7..85494949e97dc 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -24,8 +24,12 @@ import ( func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - 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 } @@ -53,8 +57,11 @@ func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { func (api *API) patchCancelTemplateVersion(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - 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 } @@ -105,8 +112,12 @@ func (api *API) patchCancelTemplateVersion(rw http.ResponseWriter, r *http.Reque func (api *API) templateVersionSchema(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - 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 } @@ -153,8 +164,11 @@ func (api *API) templateVersionSchema(rw http.ResponseWriter, r *http.Request) { func (api *API) templateVersionParameters(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - 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 } @@ -195,9 +209,12 @@ func (api *API) templateVersionParameters(rw http.ResponseWriter, r *http.Reques func (api *API) postTemplateVersionDryRun(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - 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 } @@ -367,9 +384,11 @@ func (api *API) fetchTemplateVersionDryRunJob(rw http.ResponseWriter, r *http.Re var ( ctx = r.Context() 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 } @@ -667,14 +686,10 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return } - // Making a new template version is the same permission as creating a new template. - if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceTemplate.InOrg(organization.ID)) { - httpapi.ResourceNotFound(rw) - return - } - + var template database.Template if req.TemplateID != uuid.Nil { - _, err := api.Database.GetTemplateByID(ctx, req.TemplateID) + var err error + template, err = api.Database.GetTemplateByID(ctx, req.TemplateID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ Message: "Template does not exist.", @@ -690,6 +705,17 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht } } + if template.ID != uuid.Nil { + if !api.Authorize(r, rbac.ActionCreate, template) { + httpapi.ResourceNotFound(rw) + return + } + } else if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceTemplate.InOrg(organization.ID)) { + // Making a new template version is the same permission as creating a new template. + httpapi.ResourceNotFound(rw) + return + } + file, err := api.Database.GetFileByHash(ctx, req.StorageSource) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ @@ -705,14 +731,16 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return } - if !api.Authorize(r, rbac.ActionRead, file) { - httpapi.ResourceNotFound(rw) - return - } + // TODO(JonA): Readd this check once we update the unique constraint + // on files to be owner + hash. + // if !api.Authorize(r, rbac.ActionRead, file) { + // httpapi.ResourceNotFound(rw) + // return + // } var templateVersion database.TemplateVersion var provisionerJob database.ProvisionerJob - err = api.Database.InTx(func(db database.Store) error { + err = api.Database.InTx(func(tx database.Store) error { jobID := uuid.New() inherits := make([]uuid.UUID, 0) for _, parameterValue := range req.ParameterValues { @@ -727,7 +755,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht return xerrors.Errorf("cannot inherit parameters if template_id is not set") } - inheritedParams, err := db.ParameterValues(ctx, database.ParameterValuesParams{ + inheritedParams, err := tx.ParameterValues(ctx, database.ParameterValuesParams{ IDs: inherits, }) if err != nil { @@ -736,7 +764,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht for _, copy := range inheritedParams { // This is a bit inefficient, as we make a new db call for each // param. - version, err := db.GetTemplateVersionByJobID(ctx, copy.ScopeID) + version, err := tx.GetTemplateVersionByJobID(ctx, copy.ScopeID) if err != nil { return xerrors.Errorf("fetch template version for param %q: %w", copy.Name, err) } @@ -761,7 +789,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht continue } - _, err = db.InsertParameterValue(ctx, database.InsertParameterValueParams{ + _, err = tx.InsertParameterValue(ctx, database.InsertParameterValueParams{ ID: uuid.New(), Name: parameterValue.Name, CreatedAt: database.Now(), @@ -777,7 +805,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht } } - provisionerJob, err = db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ + provisionerJob, err = tx.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{ ID: jobID, CreatedAt: database.Now(), UpdatedAt: database.Now(), @@ -805,7 +833,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht req.Name = namesgenerator.GetRandomName(1) } - templateVersion, err = db.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ + templateVersion, err = tx.InsertTemplateVersion(ctx, database.InsertTemplateVersionParams{ ID: uuid.New(), TemplateID: templateID, OrganizationID: organization.ID, @@ -851,8 +879,12 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht // return agents associated with any particular workspace. func (api *API) templateVersionResources(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - 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 } @@ -874,8 +906,12 @@ func (api *API) templateVersionResources(rw http.ResponseWriter, r *http.Request // Eg: Logs returned from 'terraform plan' when uploading a new terraform file. func (api *API) templateVersionLogs(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() - 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/coderd/templateversions_test.go b/coderd/templateversions_test.go index 3f51c49a2a5dc..5521be8ec339c 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -34,6 +34,22 @@ func TestTemplateVersion(t *testing.T) { _, err := client.TemplateVersion(ctx, version.ID) require.NoError(t, err) }) + + t.Run("MemberCanRead", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, _ := testutil.Context(t) + + client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + _, err := client1.TemplateVersion(ctx, version.ID) + require.NoError(t, err) + }) } func TestPostTemplateVersionsByOrganization(t *testing.T) { diff --git a/coderd/users.go b/coderd/users.go index 286cbf7a44ebb..f48708e9b5ed2 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -1032,6 +1032,11 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create } req.OrganizationID = organization.ID orgRoles = append(orgRoles, rbac.RoleOrgAdmin(req.OrganizationID)) + + _, err = tx.InsertAllUsersGroup(ctx, organization.ID) + if err != nil { + return xerrors.Errorf("create %q group: %w", database.AllUsersGroup, err) + } } params := database.InsertUserParams{ diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index d4c22aba4c0d8..c5bc98666fb67 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -360,7 +360,6 @@ func TestWorkspaceApplicationAuth(t *testing.T) { ResourceType: "application_connect", OwnerID: "me", OrganizationID: firstUser.OrganizationID.String(), - ResourceID: uuid.NewString(), }, Action: "create", }, 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/features.go b/codersdk/features.go index fe8673ef028fd..d34addc1ed571 100644 --- a/codersdk/features.go +++ b/codersdk/features.go @@ -20,6 +20,7 @@ const ( FeatureBrowserOnly = "browser_only" FeatureSCIM = "scim" FeatureWorkspaceQuota = "workspace_quota" + FeatureRBAC = "rbac" ) var FeatureNames = []string{ @@ -28,6 +29,7 @@ var FeatureNames = []string{ FeatureBrowserOnly, FeatureSCIM, FeatureWorkspaceQuota, + FeatureRBAC, } type Feature struct { diff --git a/codersdk/groups.go b/codersdk/groups.go new file mode 100644 index 0000000000000..b4b9759a0295d --- /dev/null +++ b/codersdk/groups.go @@ -0,0 +1,113 @@ +package codersdk + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/google/uuid" + "golang.org/x/xerrors" +) + +type CreateGroupRequest struct { + Name string `json:"name"` +} + +type Group struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + OrganizationID uuid.UUID `json:"organization_id"` + Members []User `json:"members"` +} + +func (c *Client) CreateGroup(ctx context.Context, orgID uuid.UUID, req CreateGroupRequest) (Group, error) { + res, err := c.Request(ctx, http.MethodPost, + fmt.Sprintf("/api/v2/organizations/%s/groups", orgID.String()), + req, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusCreated { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +func (c *Client) GroupsByOrganization(ctx context.Context, orgID uuid.UUID) ([]Group, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/organizations/%s/groups", orgID.String()), + nil, + ) + if err != nil { + return nil, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + + var groups []Group + return groups, json.NewDecoder(res.Body).Decode(&groups) +} + +func (c *Client) Group(ctx context.Context, group uuid.UUID) (Group, error) { + res, err := c.Request(ctx, http.MethodGet, + fmt.Sprintf("/api/v2/groups/%s", group.String()), + nil, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +type PatchGroupRequest struct { + AddUsers []string `json:"add_users"` + RemoveUsers []string `json:"remove_users"` + Name string `json:"name"` +} + +func (c *Client) PatchGroup(ctx context.Context, group uuid.UUID, req PatchGroupRequest) (Group, error) { + res, err := c.Request(ctx, http.MethodPatch, + fmt.Sprintf("/api/v2/groups/%s", group.String()), + req, + ) + if err != nil { + return Group{}, xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return Group{}, readBodyAsError(res) + } + var resp Group + return resp, json.NewDecoder(res.Body).Decode(&resp) +} + +func (c *Client) DeleteGroup(ctx context.Context, group uuid.UUID) error { + res, err := c.Request(ctx, http.MethodDelete, + fmt.Sprintf("/api/v2/groups/%s", group.String()), + nil, + ) + if err != nil { + return xerrors.Errorf("make request: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return readBodyAsError(res) + } + return nil +} diff --git a/codersdk/templates.go b/codersdk/templates.go index 3af058cc19719..b339d8eaddcb2 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -36,6 +36,34 @@ type UpdateActiveTemplateVersion struct { ID uuid.UUID `json:"id" validate:"required"` } +type TemplateRole string + +const ( + TemplateRoleAdmin TemplateRole = "admin" + TemplateRoleView TemplateRole = "view" + TemplateRoleDeleted TemplateRole = "" +) + +type TemplateACL struct { + Users []TemplateUser `json:"users"` + Groups []TemplateGroup `json:"group"` +} + +type TemplateGroup struct { + Group + Role TemplateRole `json:"role"` +} + +type TemplateUser struct { + User + Role TemplateRole `json:"role"` +} + +type UpdateTemplateACL struct { + UserPerms map[string]TemplateRole `json:"user_perms,omitempty"` + GroupPerms map[string]TemplateRole `json:"group_perms,omitempty"` +} + type UpdateTemplateMeta struct { Name string `json:"name,omitempty" validate:"omitempty,username"` Description string `json:"description,omitempty"` @@ -86,6 +114,31 @@ func (c *Client) UpdateTemplateMeta(ctx context.Context, templateID uuid.UUID, r return updated, json.NewDecoder(res.Body).Decode(&updated) } +func (c *Client) UpdateTemplateACL(ctx context.Context, templateID uuid.UUID, req UpdateTemplateACL) error { + res, err := c.Request(ctx, http.MethodPatch, fmt.Sprintf("/api/v2/templates/%s/acl", templateID), req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return readBodyAsError(res) + } + return nil +} + +func (c *Client) TemplateACL(ctx context.Context, templateID uuid.UUID) (TemplateACL, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/acl", templateID), nil) + if err != nil { + return TemplateACL{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return TemplateACL{}, readBodyAsError(res) + } + var acl TemplateACL + return acl, json.NewDecoder(res.Body).Decode(&acl) +} + // 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 62bb463f43292..0a0ebeca7304f 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/enterprise/cli/features_test.go b/enterprise/cli/features_test.go index f5e7b1ff3520a..798d0b54c3db9 100644 --- a/enterprise/cli/features_test.go +++ b/enterprise/cli/features_test.go @@ -57,7 +57,7 @@ func TestFeaturesList(t *testing.T) { var entitlements codersdk.Entitlements err := json.Unmarshal(buf.Bytes(), &entitlements) require.NoError(t, err, "unmarshal JSON output") - assert.Len(t, entitlements.Features, 5) + assert.Len(t, entitlements.Features, 6) assert.Empty(t, entitlements.Warnings) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureUserLimit].Entitlement) @@ -67,6 +67,8 @@ func TestFeaturesList(t *testing.T) { entitlements.Features[codersdk.FeatureBrowserOnly].Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureWorkspaceQuota].Entitlement) + assert.Equal(t, codersdk.EntitlementNotEntitled, + entitlements.Features[codersdk.FeatureRBAC].Entitlement) assert.Equal(t, codersdk.EntitlementNotEntitled, entitlements.Features[codersdk.FeatureSCIM].Entitlement) assert.False(t, entitlements.HasLicense) diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 3c2c373e09ded..1546a7be601e5 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -22,6 +22,7 @@ func server() *cobra.Command { BrowserOnly: dflags.BrowserOnly.Value, SCIMAPIKey: []byte(dflags.SCIMAuthHeader.Value), UserWorkspaceQuota: dflags.UserWorkspaceQuota.Value, + RBACEnabled: true, Options: options, } api, err := coderd.New(ctx, o) diff --git a/enterprise/coderd/authorize_test.go b/enterprise/coderd/authorize_test.go new file mode 100644 index 0000000000000..c770eff417541 --- /dev/null +++ b/enterprise/coderd/authorize_test.go @@ -0,0 +1,111 @@ +package coderd_test + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" +) + +func TestCheckACLPermissions(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + adminClient := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + }) + // Create adminClient, member, and org adminClient + adminUser := coderdtest.CreateFirstUser(t, adminClient) + _ = coderdenttest.AddLicense(t, adminClient, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + memberClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID) + memberUser, err := memberClient.User(ctx, codersdk.Me) + require.NoError(t, err) + orgAdminClient := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID, rbac.RoleOrgAdmin(adminUser.OrganizationID)) + orgAdminUser, err := orgAdminClient.User(ctx, codersdk.Me) + require.NoError(t, err) + + version := coderdtest.CreateTemplateVersion(t, adminClient, adminUser.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, adminClient, version.ID) + template := coderdtest.CreateTemplate(t, adminClient, adminUser.OrganizationID, version.ID) + + err = adminClient.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + memberUser.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + const ( + updateSpecificTemplate = "read-specific-template" + ) + params := map[string]codersdk.AuthorizationCheck{ + updateSpecificTemplate: { + Object: codersdk.AuthorizationObject{ + ResourceType: rbac.ResourceTemplate.Type, + ResourceID: template.ID.String(), + }, + Action: "write", + }, + } + + testCases := []struct { + Name string + Client *codersdk.Client + UserID uuid.UUID + Check codersdk.AuthorizationResponse + }{ + { + Name: "Admin", + Client: adminClient, + UserID: adminUser.UserID, + Check: map[string]bool{ + updateSpecificTemplate: true, + }, + }, + { + Name: "OrgAdmin", + Client: orgAdminClient, + UserID: orgAdminUser.ID, + Check: map[string]bool{ + updateSpecificTemplate: true, + }, + }, + { + Name: "Member", + Client: memberClient, + UserID: memberUser.ID, + Check: map[string]bool{ + updateSpecificTemplate: true, + }, + }, + } + + for _, c := range testCases { + c := c + + t.Run("CheckAuthorization/"+c.Name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + resp, err := c.Client.CheckAuthorization(ctx, codersdk.AuthorizationRequest{Checks: params}) + require.NoError(t, err, "check perms") + require.Equal(t, c.Check, resp) + }) + } +} diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 11cceef98f0db..00bd1b7bb2120 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -17,6 +17,7 @@ import ( agplaudit "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/workspacequota" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/audit" @@ -58,6 +59,36 @@ func New(ctx context.Context, options *Options) (*API, error) { r.Get("/", api.licenses) r.Delete("/{id}", api.deleteLicense) }) + r.Route("/organizations/{organization}/groups", func(r chi.Router) { + r.Use( + apiKeyMiddleware, + httpmw.ExtractOrganizationParam(api.Database), + ) + r.Post("/", api.postGroupByOrganization) + r.Get("/", api.groups) + }) + + r.Route("/templates/{template}/acl", func(r chi.Router) { + r.Use( + api.rbacEnabledMW, + apiKeyMiddleware, + httpmw.ExtractTemplateParam(api.Database), + ) + r.Get("/", api.templateACL) + r.Patch("/", api.patchTemplateACL) + }) + + r.Route("/groups/{group}", func(r chi.Router) { + r.Use( + api.rbacEnabledMW, + apiKeyMiddleware, + httpmw.ExtractGroupParam(api.Database), + ) + r.Get("/", api.group) + r.Patch("/", api.patchGroup) + r.Delete("/", api.deleteGroup) + }) + r.Route("/workspace-quota", func(r chi.Router) { r.Use(apiKeyMiddleware) r.Route("/{user}", func(r chi.Router) { @@ -92,6 +123,7 @@ func New(ctx context.Context, options *Options) (*API, error) { type Options struct { *coderd.Options + RBACEnabled bool AuditLogging bool // Whether to block non-browser connections. BrowserOnly bool @@ -125,6 +157,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureBrowserOnly: api.BrowserOnly, codersdk.FeatureSCIM: len(api.SCIMAPIKey) != 0, codersdk.FeatureWorkspaceQuota: api.UserWorkspaceQuota != 0, + codersdk.FeatureRBAC: api.RBACEnabled, }) if err != nil { return err @@ -244,3 +277,7 @@ func (api *API) runEntitlementsLoop(ctx context.Context) { } } } + +func (api *API) Authorize(r *http.Request, action rbac.Action, object rbac.Objecter) bool { + return api.AGPL.HTTPAuth.Authorize(r, action, object) +} diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index da397bc39df5e..fc44107e69574 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -41,8 +41,9 @@ func TestEntitlements(t *testing.T) { }) _ = coderdtest.CreateFirstUser(t, client) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ - UserLimit: 100, - AuditLog: true, + UserLimit: 100, + AuditLog: true, + RBACEnabled: true, }) res, err := client.Entitlements(context.Background()) require.NoError(t, err) diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 90d09fd5c9c85..914146bc91b0a 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -62,6 +62,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c } srv, cancelFunc, oop := coderdtest.NewOptions(t, options.Options) coderAPI, err := coderd.New(context.Background(), &coderd.Options{ + RBACEnabled: true, AuditLogging: options.AuditLogging, BrowserOnly: options.BrowserOnly, SCIMAPIKey: options.SCIMAPIKey, @@ -76,6 +77,7 @@ func NewWithAPI(t *testing.T, options *Options) (*codersdk.Client, io.Closer, *c if options.IncludeProvisionerDaemon { provisionerCloser = coderdtest.NewProvisionerDaemon(t, coderAPI.AGPL) } + t.Cleanup(func() { cancelFunc() _ = provisionerCloser.Close() @@ -96,6 +98,7 @@ type LicenseOptions struct { BrowserOnly bool SCIM bool WorkspaceQuota bool + RBACEnabled bool } // AddLicense generates a new license with the options provided and inserts it. @@ -132,6 +135,11 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { workspaceQuota = 1 } + rbac := int64(0) + if options.RBACEnabled { + rbac = 1 + } + c := &license.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Issuer: "test@testing.test", @@ -151,6 +159,7 @@ func GenerateLicense(t *testing.T, options LicenseOptions) string { BrowserOnly: browserOnly, SCIM: scim, WorkspaceQuota: workspaceQuota, + RBAC: rbac, }, } tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, c) diff --git a/enterprise/coderd/coderdenttest/coderdenttest_test.go b/enterprise/coderd/coderdenttest/coderdenttest_test.go index 4d5b391abf601..cd31f4a07fafc 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest_test.go +++ b/enterprise/coderd/coderdenttest/coderdenttest_test.go @@ -6,9 +6,13 @@ import ( "net/http" "testing" + "github.com/stretchr/testify/require" + "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" ) func TestNew(t *testing.T) { @@ -26,10 +30,20 @@ func TestAuthorizeAllEndpoints(t *testing.T) { IncludeProvisionerDaemon: true, }, }) + ctx, _ := testutil.Context(t) admin := coderdtest.CreateFirstUser(t, client) - license := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{}) - a := coderdtest.NewAuthTester(context.Background(), t, client, api.AGPL, admin) + license := coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + group, err := client.CreateGroup(ctx, admin.OrganizationID, codersdk.CreateGroupRequest{ + Name: "testgroup", + }) + require.NoError(t, err) + + groupObj := rbac.ResourceGroup.InOrg(admin.OrganizationID) + a := coderdtest.NewAuthTester(ctx, t, client, api.AGPL, admin) a.URLParams["licenses/{id}"] = fmt.Sprintf("licenses/%d", license.ID) + a.URLParams["groups/{group}"] = fmt.Sprintf("groups/%s", group.ID.String()) skipRoutes, assertRoute := coderdtest.AGPLRoutes(a) assertRoute["GET:/api/v2/entitlements"] = coderdtest.RouteCheck{ @@ -48,6 +62,31 @@ func TestAuthorizeAllEndpoints(t *testing.T) { AssertAction: rbac.ActionDelete, AssertObject: rbac.ResourceLicense, } + assertRoute["GET:/api/v2/templates/{template}/acl"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionRead, + AssertObject: rbac.ResourceTemplate, + } + assertRoute["PATCH:/api/v2/templates/{template}/acl"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionCreate, + AssertObject: rbac.ResourceTemplate, + } + assertRoute["GET:/api/v2/organizations/{organization}/groups"] = coderdtest.RouteCheck{ + StatusCode: http.StatusOK, + AssertAction: rbac.ActionRead, + AssertObject: groupObj, + } + assertRoute["PATCH:/api/v2/groups/{group}"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionRead, + AssertObject: groupObj, + } + assertRoute["PATCH:/api/v2/groups/{group}"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionUpdate, + AssertObject: groupObj, + } + assertRoute["DELETE:/api/v2/groups/{group}"] = coderdtest.RouteCheck{ + AssertAction: rbac.ActionDelete, + AssertObject: groupObj, + } a.Test(context.Background(), assertRoute, skipRoutes) } diff --git a/enterprise/coderd/groups.go b/enterprise/coderd/groups.go new file mode 100644 index 0000000000000..4c81c4a5efaf9 --- /dev/null +++ b/enterprise/coderd/groups.go @@ -0,0 +1,318 @@ +package coderd + +import ( + "database/sql" + "fmt" + "net/http" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" +) + +func (api *API) postGroupByOrganization(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + org = httpmw.OrganizationParam(r) + ) + + if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceGroup) { + http.NotFound(rw, r) + return + } + + var req codersdk.CreateGroupRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.Name == database.AllUsersGroup { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("%q is a reserved keyword and cannot be used for a group name.", database.AllUsersGroup), + }) + return + } + + group, err := api.Database.InsertGroup(ctx, database.InsertGroupParams{ + ID: uuid.New(), + Name: req.Name, + OrganizationID: org.ID, + }) + if database.IsUniqueViolation(err) { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("Group with name %q already exists.", req.Name), + }) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusCreated, convertGroup(group, nil)) +} + +func (api *API) patchGroup(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + group = httpmw.GroupParam(r) + ) + + if !api.Authorize(r, rbac.ActionUpdate, group) { + http.NotFound(rw, r) + return + } + + var req codersdk.PatchGroupRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.Name != "" && req.Name == database.AllUsersGroup { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("%q is a reserved group name!", database.AllUsersGroup), + }) + return + } + + users := make([]string, 0, len(req.AddUsers)+len(req.RemoveUsers)) + users = append(users, req.AddUsers...) + users = append(users, req.RemoveUsers...) + + for _, id := range users { + if _, err := uuid.Parse(id); err != nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("ID %q must be a valid user UUID.", id), + }) + return + } + // TODO: It would be nice to enforce this at the schema level + // but unfortunately our org_members table does not have an ID. + _, err := api.Database.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{ + OrganizationID: group.OrganizationID, + UserID: uuid.MustParse(id), + }) + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: fmt.Sprintf("User %q must be a member of organization %q", id, group.ID), + }) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + } + if req.Name != "" { + _, err := api.Database.GetGroupByOrgAndName(ctx, database.GetGroupByOrgAndNameParams{ + OrganizationID: group.OrganizationID, + Name: req.Name, + }) + if err == nil { + httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ + Message: fmt.Sprintf("A group with name %q already exists.", req.Name), + }) + return + } + } + + err := api.Database.InTx(func(tx database.Store) error { + if req.Name != "" { + var err error + group, err = tx.UpdateGroupByID(ctx, database.UpdateGroupByIDParams{ + ID: group.ID, + Name: req.Name, + }) + if err != nil { + return xerrors.Errorf("update group by ID: %w", err) + } + } + for _, id := range req.AddUsers { + err := tx.InsertGroupMember(ctx, database.InsertGroupMemberParams{ + GroupID: group.ID, + UserID: uuid.MustParse(id), + }) + if err != nil { + return xerrors.Errorf("insert group member %q: %w", id, err) + } + } + for _, id := range req.RemoveUsers { + err := tx.DeleteGroupMember(ctx, uuid.MustParse(id)) + if err != nil { + return xerrors.Errorf("insert group member %q: %w", id, err) + } + } + return nil + }) + if database.IsUniqueViolation(err) { + httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: "Cannot add the same user to a group twice!", + Detail: err.Error(), + }) + return + } + if xerrors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{ + Message: "Failed to add or remove non-existent group member", + Detail: err.Error(), + }) + return + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + members, err := api.Database.GetGroupMembers(ctx, group.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertGroup(group, members)) +} + +func (api *API) deleteGroup(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + group = httpmw.GroupParam(r) + ) + + if !api.Authorize(r, rbac.ActionDelete, group) { + httpapi.ResourceNotFound(rw) + return + } + + if group.Name == database.AllUsersGroup { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("%q is a reserved group and cannot be deleted!", database.AllUsersGroup), + }) + return + } + + err := api.Database.DeleteGroupByID(ctx, group.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: "Successfully deleted group!", + }) +} + +func (api *API) group(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + group = httpmw.GroupParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, group) { + httpapi.ResourceNotFound(rw) + return + } + + users, err := api.Database.GetGroupMembers(ctx, group.ID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertGroup(group, users)) +} + +func (api *API) groups(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + org = httpmw.OrganizationParam(r) + ) + + groups, err := api.Database.GetGroupsByOrganizationID(ctx, org.ID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + // Filter groups based on rbac permissions + groups, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, rbac.ActionRead, groups) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching groups.", + Detail: err.Error(), + }) + return + } + + resp := make([]codersdk.Group, 0, len(groups)) + for _, group := range groups { + members, err := api.Database.GetGroupMembers(ctx, group.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + resp = append(resp, convertGroup(group, members)) + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +func convertGroup(g database.Group, users []database.User) codersdk.Group { + // It's ridiculous to query all the orgs of a user here + // especially since as of the writing of this comment there + // is only one org. So we pretend everyone is only part of + // the group's organization. + orgs := make(map[uuid.UUID][]uuid.UUID) + for _, user := range users { + orgs[user.ID] = []uuid.UUID{g.OrganizationID} + } + return codersdk.Group{ + ID: g.ID, + Name: g.Name, + OrganizationID: g.OrganizationID, + Members: convertUsers(users, orgs), + } +} + +func convertUser(user database.User, organizationIDs []uuid.UUID) codersdk.User { + convertedUser := codersdk.User{ + ID: user.ID, + Email: user.Email, + CreatedAt: user.CreatedAt, + LastSeenAt: user.LastSeenAt, + Username: user.Username, + Status: codersdk.UserStatus(user.Status), + OrganizationIDs: organizationIDs, + Roles: make([]codersdk.Role, 0, len(user.RBACRoles)), + AvatarURL: user.AvatarURL.String, + } + + for _, roleName := range user.RBACRoles { + rbacRole, _ := rbac.RoleByName(roleName) + convertedUser.Roles = append(convertedUser.Roles, convertRole(rbacRole)) + } + + return convertedUser +} + +func convertUsers(users []database.User, organizationIDsByUserID map[uuid.UUID][]uuid.UUID) []codersdk.User { + converted := make([]codersdk.User, 0, len(users)) + for _, u := range users { + userOrganizationIDs := organizationIDsByUserID[u.ID] + converted = append(converted, convertUser(u, userOrganizationIDs)) + } + return converted +} + +func convertRole(role rbac.Role) codersdk.Role { + return codersdk.Role{ + DisplayName: role.DisplayName, + Name: role.Name, + } +} diff --git a/enterprise/coderd/groups_test.go b/enterprise/coderd/groups_test.go new file mode 100644 index 0000000000000..384419c2eb83f --- /dev/null +++ b/enterprise/coderd/groups_test.go @@ -0,0 +1,504 @@ +package coderd_test + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" +) + +func TestCreateGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + require.Equal(t, "hi", group.Name) + require.Empty(t, group.Members) + require.NotEqual(t, uuid.Nil.String(), group.ID.String()) + }) + + t.Run("Conflict", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + _, err = client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusConflict, cerr.StatusCode()) + }) + + t.Run("allUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + _, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: database.AllUsersGroup, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) +} + +func TestPatchGroup(t *testing.T) { + t.Parallel() + + t.Run("Name", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: "bye", + }) + require.NoError(t, err) + require.Equal(t, "bye", group.Name) + }) + + t.Run("AddUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user2) + require.Contains(t, group.Members, user3) + }) + + t.Run("RemoveUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user4 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String(), user4.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user2) + require.Contains(t, group.Members, user3) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + RemoveUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + require.NotContains(t, group.Members, user2) + require.NotContains(t, group.Members, user3) + require.Contains(t, group.Members, user4) + }) + + t.Run("UserNotExist", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{uuid.NewString()}, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusPreconditionFailed, cerr.StatusCode()) + }) + + t.Run("MalformedUUID", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{"yeet"}, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) + + t.Run("AddDuplicateUser", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user2.ID.String()}, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + + require.Equal(t, http.StatusPreconditionFailed, cerr.StatusCode()) + }) + + t.Run("allUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + Name: database.AllUsersGroup, + }) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) +} + +// TODO: test auth. +func TestGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + ggroup, err := client.Group(ctx, group.ID) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) + + t.Run("WithUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user2) + require.Contains(t, group.Members, user3) + + ggroup, err := client.Group(ctx, group.ID) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) + + t.Run("RegularUserReadGroup", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + ggroup, err := client1.Group(ctx, group.ID) + require.NoError(t, err) + require.Equal(t, group, ggroup) + }) + + t.Run("FilterDeletedUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String(), user2.ID.String()}, + }) + require.NoError(t, err) + require.Contains(t, group.Members, user1) + require.Contains(t, group.Members, user2) + + err = client.DeleteUser(ctx, user1.ID) + require.NoError(t, err) + + group, err = client.Group(ctx, group.ID) + require.NoError(t, err) + require.NotContains(t, group.Members, user1) + }) + + t.Run("FilterSuspendedUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String(), user2.ID.String()}, + }) + require.NoError(t, err) + require.Len(t, group.Members, 2) + require.Contains(t, group.Members, user1) + require.Contains(t, group.Members, user2) + + user1, err = client.UpdateUserStatus(ctx, user1.ID.String(), codersdk.UserStatusSuspended) + require.NoError(t, err) + + group, err = client.Group(ctx, group.ID) + require.NoError(t, err) + require.Len(t, group.Members, 1) + require.NotContains(t, group.Members, user1) + require.Contains(t, group.Members, user2) + }) +} + +// TODO: test auth. +func TestGroups(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user4 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user5 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + ctx, _ := testutil.Context(t) + group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + group2, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hey", + }) + require.NoError(t, err) + + group1, err = client.PatchGroup(ctx, group1.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user2.ID.String(), user3.ID.String()}, + }) + require.NoError(t, err) + + group2, err = client.PatchGroup(ctx, group2.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user4.ID.String(), user5.ID.String()}, + }) + require.NoError(t, err) + + groups, err := client.GroupsByOrganization(ctx, user.OrganizationID) + require.NoError(t, err) + require.Len(t, groups, 2) + require.Contains(t, groups, group1) + require.Contains(t, groups, group2) + }) +} + +func TestDeleteGroup(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + group1, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "hi", + }) + require.NoError(t, err) + + err = client.DeleteGroup(ctx, group1.ID) + require.NoError(t, err) + + _, err = client.Group(ctx, group1.ID) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + }) + + t.Run("allUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + ctx, _ := testutil.Context(t) + err := client.DeleteGroup(ctx, user.OrganizationID) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusBadRequest, cerr.StatusCode()) + }) +} diff --git a/enterprise/coderd/license/license.go b/enterprise/coderd/license/license.go index 55a62eee17eee..cd272abb87a9b 100644 --- a/enterprise/coderd/license/license.go +++ b/enterprise/coderd/license/license.go @@ -96,6 +96,12 @@ func Entitlements(ctx context.Context, db database.Store, logger slog.Logger, ke Enabled: enablements[codersdk.FeatureWorkspaceQuota], } } + if claims.Features.RBAC > 0 { + entitlements.Features[codersdk.FeatureRBAC] = codersdk.Feature{ + Entitlement: entitlement, + Enabled: enablements[codersdk.FeatureRBAC], + } + } if claims.AllFeatures { allFeatures = true } @@ -170,6 +176,7 @@ type Features struct { BrowserOnly int64 `json:"browser_only"` SCIM int64 `json:"scim"` WorkspaceQuota int64 `json:"workspace_quota"` + RBAC int64 `json:"rbac"` } type Claims struct { diff --git a/enterprise/coderd/license/license_test.go b/enterprise/coderd/license/license_test.go index 85958fbf4f60d..3e79235586d25 100644 --- a/enterprise/coderd/license/license_test.go +++ b/enterprise/coderd/license/license_test.go @@ -24,6 +24,7 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureBrowserOnly: true, codersdk.FeatureSCIM: true, codersdk.FeatureWorkspaceQuota: true, + codersdk.FeatureRBAC: true, } t.Run("Defaults", func(t *testing.T) { @@ -64,6 +65,7 @@ func TestEntitlements(t *testing.T) { BrowserOnly: true, SCIM: true, WorkspaceQuota: true, + RBACEnabled: true, }), Exp: time.Now().Add(time.Hour), }) @@ -85,6 +87,7 @@ func TestEntitlements(t *testing.T) { BrowserOnly: true, SCIM: true, WorkspaceQuota: true, + RBACEnabled: true, GraceAt: time.Now().Add(-time.Hour), ExpiresAt: time.Now().Add(time.Hour), }), diff --git a/enterprise/coderd/licenses_test.go b/enterprise/coderd/licenses_test.go index 59d36cc9157a6..48fcb5d42f4a8 100644 --- a/enterprise/coderd/licenses_test.go +++ b/enterprise/coderd/licenses_test.go @@ -82,6 +82,7 @@ func TestGetLicense(t *testing.T) { AuditLog: true, SCIM: true, BrowserOnly: true, + RBACEnabled: true, }) coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ @@ -91,6 +92,7 @@ func TestGetLicense(t *testing.T) { BrowserOnly: true, Trial: true, UserLimit: 200, + RBACEnabled: false, }) licenses, err := client.Licenses(ctx) @@ -104,6 +106,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureSCIM: json.Number("1"), codersdk.FeatureBrowserOnly: json.Number("1"), codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureRBAC: json.Number("1"), }, licenses[0].Claims["features"]) assert.Equal(t, int32(2), licenses[1].ID) assert.Equal(t, "testing2", licenses[1].Claims["account_id"]) @@ -114,6 +117,7 @@ func TestGetLicense(t *testing.T) { codersdk.FeatureSCIM: json.Number("1"), codersdk.FeatureBrowserOnly: json.Number("1"), codersdk.FeatureWorkspaceQuota: json.Number("0"), + codersdk.FeatureRBAC: json.Number("0"), }, licenses[1].Claims["features"]) }) } diff --git a/enterprise/coderd/templates.go b/enterprise/coderd/templates.go new file mode 100644 index 0000000000000..23bb6520b39c8 --- /dev/null +++ b/enterprise/coderd/templates.go @@ -0,0 +1,262 @@ +package coderd + +import ( + "context" + "database/sql" + "fmt" + "net/http" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/coderd/rbac" + "github.com/coder/coder/codersdk" +) + +func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + template := httpmw.TemplateParam(r) + if !api.Authorize(r, rbac.ActionRead, template) { + httpapi.ResourceNotFound(rw) + return + } + + users, err := api.Database.GetTemplateUserRoles(ctx, template.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + dbGroups, err := api.Database.GetTemplateGroupRoles(ctx, template.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + dbGroups, err = coderd.AuthorizeFilter(api.AGPL.HTTPAuth, r, rbac.ActionRead, dbGroups) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching users.", + 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 && !xerrors.Is(err, sql.ErrNoRows) { + httpapi.InternalServerError(rw, err) + return + } + + organizationIDsByUserID := map[uuid.UUID][]uuid.UUID{} + for _, organizationIDsByMemberIDsRow := range orgIDsByMemberIDsRows { + organizationIDsByUserID[organizationIDsByMemberIDsRow.UserID] = organizationIDsByMemberIDsRow.OrganizationIDs + } + + groups := make([]codersdk.TemplateGroup, 0, len(dbGroups)) + for _, group := range dbGroups { + var members []database.User + + if group.Name == database.AllUsersGroup { + members, err = api.Database.GetAllOrganizationMembers(ctx, group.OrganizationID) + } else { + members, err = api.Database.GetGroupMembers(ctx, group.ID) + } + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + groups = append(groups, codersdk.TemplateGroup{ + Group: convertGroup(group.Group, members), + Role: convertToTemplateRole(group.Actions), + }) + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.TemplateACL{ + Users: convertTemplateUsers(users, organizationIDsByUserID), + Groups: groups, + }) +} + +func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + template = httpmw.TemplateParam(r) + ) + + // Only users who are able to create templates (aka template admins) + // are able to control permissions. + if !api.Authorize(r, rbac.ActionCreate, template) { + httpapi.ResourceNotFound(rw) + return + } + + var req codersdk.UpdateTemplateACL + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + validErrs := validateTemplateACLPerms(ctx, api.Database, req.UserPerms, "user_perms", true) + validErrs = append(validErrs, + validateTemplateACLPerms(ctx, api.Database, req.GroupPerms, "group_perms", false)...) + + if len(validErrs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request to update template metadata!", + Validations: validErrs, + }) + return + } + + err := api.Database.InTx(func(tx database.Store) error { + if len(req.UserPerms) > 0 { + userACL := template.UserACL() + for id, role := range req.UserPerms { + // A user with an empty string implies + // deletion. + if role == "" { + delete(userACL, id) + continue + } + userACL[id] = convertSDKTemplateRole(role) + } + + err := tx.UpdateTemplateUserACLByID(r.Context(), template.ID, userACL) + if err != nil { + return xerrors.Errorf("update template user ACL: %w", err) + } + } + + if len(req.GroupPerms) > 0 { + groupACL := template.GroupACL() + for id, role := range req.GroupPerms { + // An id with an empty string implies + // deletion. + if role == "" { + delete(groupACL, id) + continue + } + groupACL[id] = convertSDKTemplateRole(role) + } + + err := tx.UpdateTemplateGroupACLByID(ctx, template.ID, groupACL) + if err != nil { + return xerrors.Errorf("update template user ACL: %w", err) + } + } + return nil + }) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ + Message: "Successfully updated template ACL list.", + }) +} + +// nolint TODO fix stupid flag. +func validateTemplateACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.TemplateRole, field string, isUser bool) []codersdk.ValidationError { + var validErrs []codersdk.ValidationError + for k, v := range perms { + if err := validateTemplateRole(v); err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: err.Error()}) + continue + } + + id, err := uuid.Parse(k) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: "ID " + k + "must be a valid UUID."}) + continue + } + + if isUser { + // This could get slow if we get a ton of user perm updates. + _, err = db.GetUserByID(ctx, id) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", k, err.Error())}) + continue + } + } else { + // This could get slow if we get a ton of group perm updates. + _, err = db.GetGroupByID(ctx, id) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: field, Detail: fmt.Sprintf("Failed to find resource with ID %q: %v", k, err.Error())}) + continue + } + } + } + + return validErrs +} + +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: convertToTemplateRole(tu.Actions), + }) + } + + return users +} + +func validateTemplateRole(role codersdk.TemplateRole) error { + actions := convertSDKTemplateRole(role) + if actions == nil && role != codersdk.TemplateRoleDeleted { + return xerrors.Errorf("role %q is not a valid Template role", role) + } + + return nil +} + +func convertToTemplateRole(actions []rbac.Action) codersdk.TemplateRole { + switch { + case len(actions) == 1 && actions[0] == rbac.ActionRead: + return codersdk.TemplateRoleView + case len(actions) == 1 && actions[0] == rbac.WildcardSymbol: + return codersdk.TemplateRoleAdmin + } + + return "" +} + +func convertSDKTemplateRole(role codersdk.TemplateRole) []rbac.Action { + switch role { + case codersdk.TemplateRoleAdmin: + return []rbac.Action{rbac.WildcardSymbol} + case codersdk.TemplateRoleView: + return []rbac.Action{rbac.ActionRead} + } + + return nil +} + +// TODO reduce the duplication across all of these. +func (api *API) rbacEnabledMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + api.entitlementsMu.RLock() + rbac := api.entitlements.Features[codersdk.FeatureRBAC].Enabled + api.entitlementsMu.RUnlock() + + if !rbac { + httpapi.RouteNotFound(rw) + return + } + + next.ServeHTTP(rw, r) + }) +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go new file mode 100644 index 0000000000000..c0998c29e37f5 --- /dev/null +++ b/enterprise/coderd/templates_test.go @@ -0,0 +1,707 @@ +package coderd_test + +import ( + "context" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/provisioner/echo" + "github.com/coder/coder/testutil" +) + +func TestTemplateACL(t *testing.T) { + t.Parallel() + + t.Run("UserRoles", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleView, + user3.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + templateUser2 := codersdk.TemplateUser{ + User: user2, + Role: codersdk.TemplateRoleView, + } + + templateUser3 := codersdk.TemplateUser{ + User: user3, + Role: codersdk.TemplateRoleAdmin, + } + + require.Len(t, acl.Users, 2) + require.Contains(t, acl.Users, templateUser2) + require.Contains(t, acl.Users, templateUser3) + }) + + t.Run("allUsersGroup", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 1) + require.Len(t, acl.Groups[0].Members, 2) + require.Contains(t, acl.Groups[0].Members, user1) + require.Len(t, acl.Users, 0) + }) + + t.Run("NoGroups", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 1) + require.Len(t, acl.Users, 0) + + // User should be able to read template due to allUsers group. + _, err = client1.Template(ctx, template.ID) + require.NoError(t, err) + + allUsers := acl.Groups[0] + + err = client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + GroupPerms: map[string]codersdk.TemplateRole{ + allUsers.ID.String(): codersdk.TemplateRoleDeleted, + }, + }) + require.NoError(t, err) + + acl, err = client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 0) + require.Len(t, acl.Users, 0) + + // User should not be able to read template due to allUsers group being deleted. + _, err = client1.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 we do not return deleted users. + t.Run("FilterDeletedUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user1.ID.String(): codersdk.TemplateRoleView, + }, + }) + require.NoError(t, err) + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + require.Contains(t, acl.Users, codersdk.TemplateUser{ + User: user1, + Role: codersdk.TemplateRoleView, + }) + + err = client.DeleteUser(ctx, user1.ID) + require.NoError(t, err) + + acl, err = client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + require.Len(t, acl.Users, 0, "deleted users should be filtered") + }) + + // Test that we do not return suspended users. + t.Run("FilterSuspendedUsers", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user1.ID.String(): codersdk.TemplateRoleView, + }, + }) + require.NoError(t, err) + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + require.Contains(t, acl.Users, codersdk.TemplateUser{ + User: user1, + Role: codersdk.TemplateRoleView, + }) + + _, err = client.UpdateUserStatus(ctx, user1.ID.String(), codersdk.UserStatusSuspended) + require.NoError(t, err) + + acl, err = client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + require.Len(t, acl.Users, 0, "suspended users should be filtered") + }) + + // Test that we do not return deleted groups. + t.Run("FilterDeletedGroups", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, _ := testutil.Context(t) + + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "test", + }) + require.NoError(t, err) + + err = client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + GroupPerms: map[string]codersdk.TemplateRole{ + group.ID.String(): codersdk.TemplateRoleView, + }, + }) + require.NoError(t, err) + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + // Length should be 2 for test group and the implicit allUsers group. + require.Len(t, acl.Groups, 2) + + require.Contains(t, acl.Groups, codersdk.TemplateGroup{ + Group: group, + Role: codersdk.TemplateRoleView, + }) + + err = client.DeleteGroup(ctx, group.ID) + require.NoError(t, err) + + acl, err = client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + // Length should be 1 for the allUsers group. + require.Len(t, acl.Groups, 1) + require.NotContains(t, acl.Groups, codersdk.TemplateGroup{ + Group: group, + Role: codersdk.TemplateRoleView, + }) + }) + + t.Run("AdminCanPushVersions", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user1.ID.String(): codersdk.TemplateRoleView, + }, + }) + require.NoError(t, err) + + data, err := echo.Tar(nil) + require.NoError(t, err) + file, err := client1.Upload(context.Background(), codersdk.ContentTypeTar, data) + require.NoError(t, err) + + _, err = client1.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: "testme", + TemplateID: template.ID, + StorageSource: file.Hash, + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeEcho, + }) + require.Error(t, err) + + err = client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user1.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + _, err = client1.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: "testme", + TemplateID: template.ID, + StorageSource: file.Hash, + StorageMethod: codersdk.ProvisionerStorageMethodFile, + Provisioner: codersdk.ProvisionerTypeEcho, + }) + require.NoError(t, err) + }) +} + +func TestUpdateTemplateACL(t *testing.T) { + t.Parallel() + + t.Run("UserPerms", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleView, + user3.ID.String(): codersdk.TemplateRoleAdmin, + }, + }) + require.NoError(t, err) + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + templateUser2 := codersdk.TemplateUser{ + User: user2, + Role: codersdk.TemplateRoleView, + } + + templateUser3 := codersdk.TemplateUser{ + User: user3, + Role: codersdk.TemplateRoleAdmin, + } + + require.Len(t, acl.Users, 2) + require.Contains(t, acl.Users, templateUser2) + require.Contains(t, acl.Users, templateUser3) + }) + + t.Run("DeleteUser", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleView, + user3.ID.String(): codersdk.TemplateRoleAdmin, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + err := client.UpdateTemplateACL(ctx, template.ID, req) + require.NoError(t, err) + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + require.Contains(t, acl.Users, codersdk.TemplateUser{ + User: user2, + Role: codersdk.TemplateRoleView, + }) + require.Contains(t, acl.Users, codersdk.TemplateUser{ + User: user3, + Role: codersdk.TemplateRoleAdmin, + }) + + req = codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleAdmin, + user3.ID.String(): codersdk.TemplateRoleDeleted, + }, + } + + err = client.UpdateTemplateACL(ctx, template.ID, req) + require.NoError(t, err) + + acl, err = client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Contains(t, acl.Users, codersdk.TemplateUser{ + User: user2, + Role: codersdk.TemplateRoleAdmin, + }) + + require.NotContains(t, acl.Users, codersdk.TemplateUser{ + User: user3, + Role: codersdk.TemplateRoleAdmin, + }) + }) + + t.Run("InvalidUUID", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + "hi": "admin", + }, + } + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(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 := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + uuid.NewString(): "admin", + }, + } + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(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 := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + _, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): "updater", + }, + } + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(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 := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleView, + }, + } + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(ctx, template.ID, req) + require.NoError(t, err) + + req = codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleAdmin, + }, + } + + err = client2.UpdateTemplateACL(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 := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + client2, user2 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + _, user3 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + req := codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user2.ID.String(): codersdk.TemplateRoleAdmin, + }, + } + + ctx, _ := testutil.Context(t) + + err := client.UpdateTemplateACL(ctx, template.ID, req) + require.NoError(t, err) + + req = codersdk.UpdateTemplateACL{ + UserPerms: map[string]codersdk.TemplateRole{ + user3.ID.String(): codersdk.TemplateRoleView, + }, + } + + err = client2.UpdateTemplateACL(ctx, template.ID, req) + require.NoError(t, err) + + acl, err := client2.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Contains(t, acl.Users, codersdk.TemplateUser{ + User: user3, + Role: codersdk.TemplateRoleView, + }) + }) + + t.Run("allUsersGroup", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 1) + require.Len(t, acl.Users, 0) + }) + + t.Run("CustomGroupHasAccess", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, _ := testutil.Context(t) + + // Create a group to add to the template. + group, err := client.CreateGroup(ctx, user.OrganizationID, codersdk.CreateGroupRequest{ + Name: "test", + }) + require.NoError(t, err) + + // Check that the only current group is the allUsers group. + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + require.Len(t, acl.Groups, 1) + + // Update the template to only allow access to the 'test' group. + err = client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + GroupPerms: map[string]codersdk.TemplateRole{ + // The allUsers group shares the same ID as the organization. + user.OrganizationID.String(): codersdk.TemplateRoleDeleted, + group.ID.String(): codersdk.TemplateRoleView, + }, + }) + require.NoError(t, err) + + // Get the ACL list for the template and assert the test group is + // present. + acl, err = client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 1) + require.Len(t, acl.Users, 0) + require.Equal(t, group.ID, acl.Groups[0].ID) + + // Try to get the template as the regular user. This should + // fail since we haven't been added to the template yet. + _, err = client1.Template(ctx, template.ID) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + + // Patch the group to add the regular user. + group, err = client.PatchGroup(ctx, group.ID, codersdk.PatchGroupRequest{ + AddUsers: []string{user1.ID.String()}, + }) + require.NoError(t, err) + require.Len(t, group.Members, 1) + require.Equal(t, user1.ID, group.Members[0].ID) + + // Fetching the template should succeed since our group has view access. + _, err = client1.Template(ctx, template.ID) + require.NoError(t, err) + }) + + t.Run("NoAccess", func(t *testing.T) { + t.Parallel() + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + client1, _ := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 1) + require.Len(t, acl.Users, 0) + + // User should be able to read template due to allUsers group. + _, err = client1.Template(ctx, template.ID) + require.NoError(t, err) + + allUsers := acl.Groups[0] + + err = client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + GroupPerms: map[string]codersdk.TemplateRole{ + allUsers.ID.String(): codersdk.TemplateRoleDeleted, + }, + }) + require.NoError(t, err) + + acl, err = client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 0) + require.Len(t, acl.Users, 0) + + // User should not be able to read template due to allUsers group being deleted. + _, err = client1.Template(ctx, template.ID) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + }) +} diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go new file mode 100644 index 0000000000000..1b5d74124fa34 --- /dev/null +++ b/enterprise/coderd/workspaces_test.go @@ -0,0 +1,69 @@ +package coderd_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/util/ptr" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/testutil" +) + +func TestCreateWorkspace(t *testing.T) { + t.Parallel() + + // Test that a user cannot indirectly access + // a template they do not have access to. + t.Run("Unauthorized", func(t *testing.T) { + t.Parallel() + + client := coderdenttest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + _ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{ + RBACEnabled: true, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + acl, err := client.TemplateACL(ctx, template.ID) + require.NoError(t, err) + + require.Len(t, acl.Groups, 1) + require.Len(t, acl.Users, 0) + + err = client.UpdateTemplateACL(ctx, template.ID, codersdk.UpdateTemplateACL{ + GroupPerms: map[string]codersdk.TemplateRole{ + acl.Groups[0].ID.String(): codersdk.TemplateRoleDeleted, + }, + }) + require.NoError(t, err) + + client1, user1 := coderdtest.CreateAnotherUserWithUser(t, client, user.OrganizationID) + + _, err = client1.Template(ctx, template.ID) + require.Error(t, err) + cerr, ok := codersdk.AsError(err) + require.True(t, ok) + require.Equal(t, http.StatusNotFound, cerr.StatusCode()) + + req := codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "testme", + AutostartSchedule: ptr.Ref("CRON_TZ=US/Central 30 9 * * 1-5"), + TTLMillis: ptr.Ref((8 * time.Hour).Milliseconds()), + } + + _, err = client1.CreateWorkspace(ctx, user.OrganizationID, user1.ID.String(), req) + require.Error(t, err) + }) +} diff --git a/go.mod b/go.mod index 36379f3e99796..9834e27e5f39c 100644 --- a/go.mod +++ b/go.mod @@ -156,7 +156,9 @@ require ( tailscale.com v1.30.0 ) -require github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 // indirect +require github.com/jmoiron/sqlx v1.3.5 + +require github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 require ( filippo.io/edwards25519 v1.0.0-rc.1 // indirect diff --git a/go.sum b/go.sum index ab899f0900601..13fdc5724f6b6 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= @@ -1101,6 +1102,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= @@ -1277,6 +1280,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 8891238439fb3..ea078c17c65c3 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -2,8 +2,11 @@ import { useSelector } from "@xstate/react" import { FeatureNames } from "api/types" import { FullScreenLoader } from "components/Loader/FullScreenLoader" import { RequirePermission } from "components/RequirePermission/RequirePermission" +import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" +import { UsersLayout } from "components/UsersLayout/UsersLayout" import IndexPage from "pages" import AuditPage from "pages/AuditPage/AuditPage" +import GroupsPage from "pages/GroupsPage/GroupsPage" import LoginPage from "pages/LoginPage/LoginPage" import { SetupPage } from "pages/SetupPage/SetupPage" import { TemplateSettingsPage } from "pages/TemplateSettingsPage/TemplateSettingsPage" @@ -47,10 +50,23 @@ const WorkspaceSchedulePage = lazy( () => import("./pages/WorkspaceSchedulePage/WorkspaceSchedulePage"), ) const TerminalPage = lazy(() => import("./pages/TerminalPage/TerminalPage")) +const TemplatePermissionsPage = lazy( + () => + import( + "./pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage" + ), +) +const TemplateSummaryPage = lazy( + () => import("./pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage"), +) const CreateWorkspacePage = lazy( () => import("./pages/CreateWorkspacePage/CreateWorkspacePage"), ) -const TemplatePage = lazy(() => import("./pages/TemplatePage/TemplatePage")) +const CreateGroupPage = lazy(() => import("./pages/GroupsPage/CreateGroupPage")) +const GroupPage = lazy(() => import("./pages/GroupsPage/GroupPage")) +const SettingsGroupPage = lazy( + () => import("./pages/GroupsPage/SettingsGroupPage"), +) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -110,7 +126,19 @@ export const AppRouter: FC = () => { index element={ - + + + + + } + /> + + + + } /> @@ -138,7 +166,9 @@ export const AppRouter: FC = () => { index element={ - + + + } /> @@ -152,6 +182,43 @@ export const AppRouter: FC = () => { /> + + + + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + => { + const response = await axios.get(`/api/v2/templates/${templateId}/acl`) + return response.data +} + +export const updateTemplateACL = async ( + templateId: string, + data: TypesGen.UpdateTemplateACL, +): Promise => { + const response = await axios.patch( + `/api/v2/templates/${templateId}/acl`, + data, + ) + return response.data +} + export const getApplicationsHost = async (): Promise => { const response = await axios.get(`/api/v2/applications/host`) return response.data } +export const getGroups = async ( + organizationId: string, +): Promise => { + const response = await axios.get( + `/api/v2/organizations/${organizationId}/groups`, + ) + return response.data +} + +export const createGroup = async ( + organizationId: string, + data: TypesGen.CreateGroupRequest, +): Promise => { + const response = await axios.post( + `/api/v2/organizations/${organizationId}/groups`, + data, + ) + return response.data +} + +export const getGroup = async (groupId: string): Promise => { + const response = await axios.get(`/api/v2/groups/${groupId}`) + return response.data +} + +export const patchGroup = async ( + groupId: string, + data: TypesGen.PatchGroupRequest, +): Promise => { + const response = await axios.patch(`/api/v2/groups/${groupId}`, data) + return response.data +} + +export const deleteGroup = async (groupId: string): Promise => { + await axios.delete(`/api/v2/groups/${groupId}`) +} + export const getWorkspaceQuota = async ( userID: string, ): Promise => { diff --git a/site/src/api/types.ts b/site/src/api/types.ts index 420f805ccd85d..5fdabd35be45f 100644 --- a/site/src/api/types.ts +++ b/site/src/api/types.ts @@ -22,4 +22,5 @@ export enum FeatureNames { BrowserOnly = "browser_only", SCIM = "scim", WorkspaceQuota = "workspace_quota", + RBAC = "rbac", } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c5188be5367be..6ad32fecce4e1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -164,6 +164,11 @@ export interface CreateFirstUserResponse { readonly organization_id: string } +// From codersdk/groups.go +export interface CreateGroupRequest { + readonly name: string +} + // From codersdk/users.go export interface CreateOrganizationRequest { readonly name: string @@ -355,6 +360,14 @@ export interface GitSSHKey { readonly public_key: string } +// From codersdk/groups.go +export interface Group { + readonly id: string + readonly name: string + readonly organization_id: string + readonly members: User[] +} + // From codersdk/workspaceapps.go export interface Healthcheck { readonly url: string @@ -462,6 +475,13 @@ export interface ParameterSchema { readonly validation_contains?: string[] } +// From codersdk/groups.go +export interface PatchGroupRequest { + readonly add_users: string[] + readonly remove_users: string[] + readonly name: string +} + // From codersdk/provisionerdaemons.go export interface ProvisionerDaemon { readonly id: string @@ -562,11 +582,27 @@ export interface Template { readonly created_by_name: string } +// From codersdk/templates.go +export interface TemplateACL { + readonly users: TemplateUser[] + readonly group: TemplateGroup[] +} + // From codersdk/templates.go export interface TemplateDAUsResponse { readonly entries: DAUEntry[] } +// From codersdk/templates.go +export interface TemplateGroup extends Group { + readonly role: TemplateRole +} + +// From codersdk/templates.go +export interface TemplateUser extends User { + readonly role: TemplateRole +} + // From codersdk/templateversions.go export interface TemplateVersion { readonly id: string @@ -596,6 +632,12 @@ export interface UpdateRoles { readonly roles: string[] } +// From codersdk/templates.go +export interface UpdateTemplateACL { + readonly user_perms?: Record + readonly group_perms?: Record +} + // From codersdk/templates.go export interface UpdateTemplateMeta { readonly name?: string @@ -867,6 +909,9 @@ export type ResourceType = // From codersdk/sse.go export type ServerSentEventType = "data" | "error" | "ping" +// From codersdk/templates.go +export type TemplateRole = "" | "admin" | "view" + // From codersdk/users.go export type UserStatus = "active" | "suspended" diff --git a/site/src/components/DropdownButton/DropdownButton.stories.tsx b/site/src/components/DropdownButton/DropdownButton.stories.tsx index ba40c4166930d..ab2815a236dbc 100644 --- a/site/src/components/DropdownButton/DropdownButton.stories.tsx +++ b/site/src/components/DropdownButton/DropdownButton.stories.tsx @@ -35,7 +35,7 @@ WithDropdown.args = { export const WithCancel = Template.bind({}) WithCancel.args = { - primaryAction: , + primaryAction: , secondaryActions: [], canCancel: true, handleCancel: action("cancel"), diff --git a/site/src/components/Paywall/Paywall.tsx b/site/src/components/Paywall/Paywall.tsx new file mode 100644 index 0000000000000..87e2b9fdffbf6 --- /dev/null +++ b/site/src/components/Paywall/Paywall.tsx @@ -0,0 +1,67 @@ +import Box from "@material-ui/core/Box" +import { makeStyles } from "@material-ui/core/styles" +import Typography from "@material-ui/core/Typography" +import { FC, ReactNode } from "react" +import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" + +export interface PaywallProps { + message: string + description?: string | React.ReactNode + cta?: ReactNode +} + +export const Paywall: FC> = (props) => { + const { message, description, cta } = props + const styles = useStyles() + + return ( + +
+ + {message} + + {description && ( + + {description} + + )} +
+ {cta} +
+ ) +} + +const useStyles = makeStyles( + (theme) => ({ + root: { + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + textAlign: "center", + minHeight: 300, + padding: theme.spacing(3), + fontFamily: MONOSPACE_FONT_FAMILY, + backgroundColor: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + }, + header: { + marginBottom: theme.spacing(3), + }, + title: { + fontWeight: 600, + fontFamily: "inherit", + }, + description: { + marginTop: theme.spacing(1), + fontFamily: "inherit", + maxWidth: 420, + }, + }), + { name: "Paywall" }, +) diff --git a/site/src/components/TemplateLayout/TemplateLayout.tsx b/site/src/components/TemplateLayout/TemplateLayout.tsx new file mode 100644 index 0000000000000..cf83b30f9c287 --- /dev/null +++ b/site/src/components/TemplateLayout/TemplateLayout.tsx @@ -0,0 +1,293 @@ +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 { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" +import { DeleteButton } from "components/DropdownButton/ActionCtas" +import { DropdownButton } from "components/DropdownButton/DropdownButton" +import { Loader } from "components/Loader/Loader" +import { + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "components/PageHeader/PageHeader" +import { useOrganizationId } from "hooks/useOrganizationId" +import { createContext, FC, PropsWithChildren, useContext } from "react" +import { + Link as RouterLink, + Navigate, + NavLink, + 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 { + TemplateContext, + templateMachine, +} from "xServices/template/templateXService" +import { Margins } from "../../components/Margins/Margins" +import { Stack } from "../../components/Stack/Stack" +import { Permissions } from "xServices/auth/authXService" + +const Language = { + settingsButton: "Settings", + createButton: "Create workspace", + noDescription: "", +} + +const useTemplateName = () => { + const { template } = useParams() + + if (!template) { + throw new Error("No template found in the URL") + } + + return template +} + +type TemplateLayoutContextValue = { + context: TemplateContext + permissions: Permissions +} + +const TemplateLayoutContext = createContext< + TemplateLayoutContextValue | undefined +>(undefined) + +export const useTemplateLayoutContext = (): TemplateLayoutContextValue => { + const context = useContext(TemplateLayoutContext) + if (!context) { + throw new Error( + "useTemplateLayoutContext only can be used inside of TemplateLayout", + ) + } + return context +} + +export const TemplateLayout: FC = ({ children }) => { + const styles = useStyles() + const organizationId = useOrganizationId() + const templateName = useTemplateName() + const [templateState, templateSend] = useMachine(templateMachine, { + context: { + templateName, + organizationId, + }, + }) + const { + template, + activeTemplateVersion, + templateResources, + templateDAUs, + permissions: templatePermissions, + } = templateState.context + const xServices = useContext(XServiceContext) + const permissions = useSelector(xServices.authXService, selectPermissions) + const isLoading = + !template || + !activeTemplateVersion || + !templateResources || + !permissions || + !templateDAUs || + !templatePermissions + + if (isLoading) { + return + } + + if (templateState.matches("deleted")) { + return + } + + const hasIcon = template.icon && template.icon !== "" + + const createWorkspaceButton = (className?: string) => ( + + + + ) + + const handleDeleteTemplate = () => { + templateSend("DELETE") + } + + return ( + <> + + + + + + + + + ), + }, + ]} + 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, + ]) + } + > + Permissions + + + +
+ + + + {children} + + + + { + templateSend("CONFIRM_DELETE") + }} + onCancel={() => { + templateSend("CANCEL_DELETE") + }} + entity="template" + name={template.name} + /> + + ) +} + +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/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index ec41f50acbf99..bd9c05d7816ea 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -7,6 +7,7 @@ import { User } from "api/typesGenerated" import { AvatarData } from "components/AvatarData/AvatarData" import debounce from "just-debounce-it" import { ChangeEvent, FC, useEffect, useState } from "react" +import { combineClasses } from "util/combineClasses" import { searchUserMachine } from "xServices/users/searchUserXService" import { AutocompleteAvatar } from "./AutocompleteAvatar" @@ -16,12 +17,14 @@ export type UserAutocompleteProps = { label?: string inputMargin?: "none" | "dense" | "normal" inputStyles?: string + className?: string showAvatar?: boolean } export const UserAutocomplete: FC = ({ value, onChange, + className, label, inputMargin, inputStyles, @@ -31,7 +34,6 @@ export const UserAutocomplete: FC = ({ const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) const [searchState, sendSearch] = useMachine(searchUserMachine) const { searchResults } = searchState.context - const [selectedValue, setSelectedValue] = useState(value || null) // seed list of options on the first page load if a user pases in a value // since some organizations have long lists of users, we do not load all options on page load. @@ -51,7 +53,7 @@ export const UserAutocomplete: FC = ({ return ( { @@ -65,7 +67,6 @@ export const UserAutocomplete: FC = ({ sendSearch("CLEAR_RESULTS") } - setSelectedValue(newValue) onChange(newValue) }} getOptionSelected={(option: User, value: User) => @@ -90,7 +91,7 @@ export const UserAutocomplete: FC = ({ )} options={searchResults} loading={searchState.matches("searching")} - className={styles.autocomplete} + className={combineClasses([styles.autocomplete, className])} renderInput={(params) => ( = ({ ...params.InputProps, onChange: handleFilterChange, startAdornment: ( - <> - {showAvatar && selectedValue && ( - - )} - + <>{showAvatar && value && } ), endAdornment: ( <> @@ -156,3 +153,28 @@ export const useStyles = makeStyles((theme) => { }, } }) + +export const UserAutocompleteInline: React.FC = ( + props, +) => { + const style = useInlineStyle() + + return +} + +export const useInlineStyle = makeStyles(() => { + return { + inline: { + width: "300px", + + "& .MuiFormControl-root": { + margin: 0, + }, + + "& .MuiInputBase-root": { + // Match button small height + height: 36, + }, + }, + } +}) diff --git a/site/src/components/UserAvatar/UserAvatar.tsx b/site/src/components/UserAvatar/UserAvatar.tsx index f4416251218f3..a238db47c4f72 100644 --- a/site/src/components/UserAvatar/UserAvatar.tsx +++ b/site/src/components/UserAvatar/UserAvatar.tsx @@ -14,7 +14,7 @@ export const UserAvatar: FC = ({ avatarURL, }) => { return ( - + {avatarURL ? ( {`${username}'s ) : ( diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx new file mode 100644 index 0000000000000..9e3f75672274c --- /dev/null +++ b/site/src/components/UserOrGroupAutocomplete/UserOrGroupAutocomplete.tsx @@ -0,0 +1,148 @@ +import CircularProgress from "@material-ui/core/CircularProgress" +import { makeStyles } from "@material-ui/core/styles" +import TextField from "@material-ui/core/TextField" +import Autocomplete from "@material-ui/lab/Autocomplete" +import { useMachine } from "@xstate/react" +import { Group, User } from "api/typesGenerated" +import { AvatarData } from "components/AvatarData/AvatarData" +import debounce from "just-debounce-it" +import { ChangeEvent, useState } from "react" +import { searchUsersAndGroupsMachine } from "xServices/template/searchUsersAndGroupsXService" + +export type UserOrGroupAutocompleteValue = User | Group | null + +const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => { + return value !== null && "members" in value +} + +export type UserOrGroupAutocompleteProps = { + value: UserOrGroupAutocompleteValue + onChange: (value: UserOrGroupAutocompleteValue) => void + organizationId: string + exclude: UserOrGroupAutocompleteValue[] +} + +export const UserOrGroupAutocomplete: React.FC< + UserOrGroupAutocompleteProps +> = ({ value, onChange, organizationId, exclude }) => { + const styles = useStyles() + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) + const [searchState, sendSearch] = useMachine(searchUsersAndGroupsMachine, { + context: { + userResults: [], + groupResults: [], + organizationId, + }, + }) + const { userResults, groupResults } = searchState.context + const options = [...groupResults, ...userResults].filter((result) => { + const excludeIds = exclude.map((optionToExclude) => optionToExclude?.id) + return !excludeIds.includes(result.id) + }) + + const handleFilterChange = debounce( + (event: ChangeEvent) => { + sendSearch("SEARCH", { query: event.target.value }) + }, + 500, + ) + + return ( + { + setIsAutocompleteOpen(true) + }} + onClose={() => { + setIsAutocompleteOpen(false) + }} + onChange={(_, newValue) => { + if (newValue === null) { + sendSearch("CLEAR_RESULTS") + } + + onChange(newValue) + }} + getOptionSelected={(option, value) => option.id === value.id} + getOptionLabel={(option) => + isGroup(option) ? option.name : option.email + } + renderOption={(option) => { + const isOptionGroup = isGroup(option) + + return ( + + ) : null + } + /> + ) + }} + options={options} + loading={searchState.matches("searching")} + className={styles.autocomplete} + renderInput={(params) => ( + + {searchState.matches("searching") ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ) +} + +export const useStyles = makeStyles((theme) => { + return { + autocomplete: { + width: "300px", + + "& .MuiFormControl-root": { + width: "100%", + }, + + "& .MuiInputBase-root": { + width: "100%", + // Match button small height + height: 36, + }, + + "& input": { + fontSize: 14, + padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`, + }, + }, + + avatar: { + width: theme.spacing(4.5), + height: theme.spacing(4.5), + borderRadius: "100%", + }, + } +}) diff --git a/site/src/components/UsersLayout/UsersLayout.tsx b/site/src/components/UsersLayout/UsersLayout.tsx new file mode 100644 index 0000000000000..c2d778c96c2fe --- /dev/null +++ b/site/src/components/UsersLayout/UsersLayout.tsx @@ -0,0 +1,123 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import { makeStyles } from "@material-ui/core/styles" +import GroupAdd from "@material-ui/icons/GroupAddOutlined" +import PersonAdd from "@material-ui/icons/PersonAddOutlined" +import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader" +import { useFeatureVisibility } from "hooks/useFeatureVisibility" +import { usePermissions } from "hooks/usePermissions" +import { FC, PropsWithChildren } from "react" +import { Link as RouterLink, NavLink, useNavigate } from "react-router-dom" +import { combineClasses } from "util/combineClasses" +import { Margins } from "../../components/Margins/Margins" +import { Stack } from "../../components/Stack/Stack" + +export const UsersLayout: FC = ({ children }) => { + const styles = useStyles() + const { createUser: canCreateUser, createGroup: canCreateGroup } = + usePermissions() + const navigate = useNavigate() + const { rbac: isRBACEnabled } = useFeatureVisibility() + + return ( + <> + + + {canCreateUser && ( + + )} + {canCreateGroup && isRBACEnabled && ( + + + + )} + + } + > + Users + + + +
+ + + + combineClasses([ + styles.tabItem, + isActive ? styles.tabItemActive : undefined, + ]) + } + > + Users + + + combineClasses([ + styles.tabItem, + isActive ? styles.tabItemActive : undefined, + ]) + } + > + Groups + + + +
+ + {children} + + ) +} + +export const useStyles = makeStyles((theme) => { + return { + 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/useFeatureVisibility.ts b/site/src/hooks/useFeatureVisibility.ts new file mode 100644 index 0000000000000..75533272a1728 --- /dev/null +++ b/site/src/hooks/useFeatureVisibility.ts @@ -0,0 +1,9 @@ +import { useSelector } from "@xstate/react" +import { useContext } from "react" +import { selectFeatureVisibility } from "xServices/entitlements/entitlementsSelectors" +import { XServiceContext } from "xServices/StateContext" + +export const useFeatureVisibility = (): Record => { + const xServices = useContext(XServiceContext) + return useSelector(xServices.entitlementsXService, selectFeatureVisibility) +} diff --git a/site/src/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/hooks/usePermissions.ts b/site/src/hooks/usePermissions.ts new file mode 100644 index 0000000000000..cd0ec0546046b --- /dev/null +++ b/site/src/hooks/usePermissions.ts @@ -0,0 +1,14 @@ +import { useActor } from "@xstate/react" +import { useContext } from "react" +import { AuthContext } from "xServices/auth/authXService" +import { XServiceContext } from "xServices/StateContext" + +export const usePermissions = (): NonNullable => { + const xServices = useContext(XServiceContext) + const [authState, _] = useActor(xServices.authXService) + const { permissions } = authState.context + if (!permissions) { + throw new Error("Permissions are not loaded yet.") + } + return permissions +} diff --git a/site/src/pages/GroupsPage/CreateGroupPage.tsx b/site/src/pages/GroupsPage/CreateGroupPage.tsx new file mode 100644 index 0000000000000..e5b67c8d4ff29 --- /dev/null +++ b/site/src/pages/GroupsPage/CreateGroupPage.tsx @@ -0,0 +1,43 @@ +import { useMachine } from "@xstate/react" +import { useOrganizationId } from "hooks/useOrganizationId" +import React from "react" +import { Helmet } from "react-helmet-async" +import { useNavigate } from "react-router-dom" +import { pageTitle } from "util/page" +import { createGroupMachine } from "xServices/groups/createGroupXService" +import CreateGroupPageView from "./CreateGroupPageView" + +export const CreateGroupPage: React.FC = () => { + const navigate = useNavigate() + const organizationId = useOrganizationId() + const [createState, sendCreateEvent] = useMachine(createGroupMachine, { + context: { + organizationId, + }, + actions: { + onCreate: (_, { data }) => { + navigate(`/groups/${data.id}`) + }, + }, + }) + const { createGroupFormErrors } = createState.context + + return ( + <> + + {pageTitle("Create Group")} + + { + sendCreateEvent({ + type: "CREATE", + data, + }) + }} + formErrors={createGroupFormErrors} + isLoading={createState.matches("creatingGroup")} + /> + + ) +} +export default CreateGroupPage diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx new file mode 100644 index 0000000000000..b728a2c54540f --- /dev/null +++ b/site/src/pages/GroupsPage/CreateGroupPageView.stories.tsx @@ -0,0 +1,17 @@ +import { Story } from "@storybook/react" +import { + CreateGroupPageView, + CreateGroupPageViewProps, +} from "./CreateGroupPageView" + +export default { + title: "pages/CreateGroupPageView", + component: CreateGroupPageView, +} + +const Template: Story = ( + args: CreateGroupPageViewProps, +) => + +export const Example = Template.bind({}) +Example.args = {} diff --git a/site/src/pages/GroupsPage/CreateGroupPageView.tsx b/site/src/pages/GroupsPage/CreateGroupPageView.tsx new file mode 100644 index 0000000000000..8fd863815c59f --- /dev/null +++ b/site/src/pages/GroupsPage/CreateGroupPageView.tsx @@ -0,0 +1,57 @@ +import TextField from "@material-ui/core/TextField" +import { CreateGroupRequest } from "api/typesGenerated" +import { FormFooter } from "components/FormFooter/FormFooter" +import { FullPageForm } from "components/FullPageForm/FullPageForm" +import { Margins } from "components/Margins/Margins" +import { useFormik } from "formik" +import React from "react" +import { useNavigate } from "react-router-dom" +import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils" +import * as Yup from "yup" + +const validationSchema = Yup.object({ + name: nameValidator("Name"), +}) + +export type CreateGroupPageViewProps = { + onSubmit: (data: CreateGroupRequest) => void + formErrors: unknown | undefined + isLoading: boolean +} + +export const CreateGroupPageView: React.FC = ({ + onSubmit, + formErrors, + isLoading, +}) => { + const navigate = useNavigate() + const form = useFormik({ + initialValues: { + name: "", + }, + validationSchema, + onSubmit, + }) + const getFieldHelpers = getFormHelpers(form, formErrors) + const onCancel = () => navigate("/groups") + + return ( + + +
+ + + +
+
+ ) +} +export default CreateGroupPageView diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx new file mode 100644 index 0000000000000..0411e35747b0d --- /dev/null +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -0,0 +1,227 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +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 DeleteOutline from "@material-ui/icons/DeleteOutline" +import PersonAdd from "@material-ui/icons/PersonAdd" +import SettingsOutlined from "@material-ui/icons/SettingsOutlined" +import { useMachine } from "@xstate/react" +import { User } from "api/typesGenerated" +import { AvatarData } from "components/AvatarData/AvatarData" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" +import { EmptyState } from "components/EmptyState/EmptyState" +import { Loader } from "components/Loader/Loader" +import { LoadingButton } from "components/LoadingButton/LoadingButton" +import { Margins } from "components/Margins/Margins" +import { + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "components/PageHeader/PageHeader" +import { Stack } from "components/Stack/Stack" +import { TableRowMenu } from "components/TableRowMenu/TableRowMenu" +import { UserAutocompleteInline } from "components/UserAutocomplete/UserAutocomplete" +import { useState } from "react" +import { Helmet } from "react-helmet-async" +import { Link as RouterLink, useNavigate, useParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { groupMachine } from "xServices/groups/groupXService" +import { Maybe } from "components/Conditionals/Maybe" + +const AddGroupMember: React.FC<{ + isLoading: boolean + onSubmit: (user: User, reset: () => void) => void +}> = ({ isLoading, onSubmit }) => { + const [selectedUser, setSelectedUser] = useState(null) + + const resetValues = () => { + setSelectedUser(null) + } + + return ( +
{ + e.preventDefault() + + if (selectedUser) { + onSubmit(selectedUser, resetValues) + } + }} + > + + { + setSelectedUser(newValue) + }} + /> + + } + loading={isLoading} + > + Add user + + +
+ ) +} + +export const GroupPage: React.FC = () => { + const { groupId } = useParams() + if (!groupId) { + throw new Error("groupId is not defined.") + } + + const navigate = useNavigate() + const [state, send] = useMachine(groupMachine, { + context: { + groupId, + }, + actions: { + redirectToGroups: () => { + navigate("/groups") + }, + }, + }) + const { group, permissions } = state.context + const isLoading = group === undefined || permissions === undefined + const canUpdateGroup = permissions ? permissions.canUpdateGroup : false + + return ( + <> + + {pageTitle(group?.name ?? "Loading...")} + + + + + + + + + + + + + + + } + > + {group?.name} + + {group?.members.length} members + + + + + + { + send({ + type: "ADD_MEMBER", + userId: user.id, + callback: reset, + }) + }} + /> + + + + + + User + + + + + + + + + + + + + + + + {group?.members.map((member) => ( + + + + + + + { + send({ + type: "REMOVE_MEMBER", + userId: member.id, + }) + }, + }, + ]} + /> + + + + ))} + + + +
+
+
+
+
+
+ + {group && ( + { + send("CONFIRM_DELETE") + }} + onCancel={() => { + send("CANCEL_DELETE") + }} + /> + )} + + ) +} + +export default GroupPage diff --git a/site/src/pages/GroupsPage/GroupsPage.tsx b/site/src/pages/GroupsPage/GroupsPage.tsx new file mode 100644 index 0000000000000..a66b0c122575e --- /dev/null +++ b/site/src/pages/GroupsPage/GroupsPage.tsx @@ -0,0 +1,37 @@ +import { useMachine } from "@xstate/react" +import { useFeatureVisibility } from "hooks/useFeatureVisibility" +import { useOrganizationId } from "hooks/useOrganizationId" +import { usePermissions } from "hooks/usePermissions" +import React from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "util/page" +import { groupsMachine } from "xServices/groups/groupsXService" +import GroupsPageView from "./GroupsPageView" + +export const GroupsPage: React.FC = () => { + const organizationId = useOrganizationId() + const [state] = useMachine(groupsMachine, { + context: { + organizationId, + }, + }) + const { groups } = state.context + const { createGroup: canCreateGroup } = usePermissions() + const { rbac: isRBACEnabled } = useFeatureVisibility() + + return ( + <> + + {pageTitle("Groups")} + + + + + ) +} + +export default GroupsPage diff --git a/site/src/pages/GroupsPage/GroupsPageView.stories.tsx b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx new file mode 100644 index 0000000000000..1207cdae02978 --- /dev/null +++ b/site/src/pages/GroupsPage/GroupsPageView.stories.tsx @@ -0,0 +1,40 @@ +import { Story } from "@storybook/react" +import { MockGroup } from "testHelpers/entities" +import { GroupsPageView, GroupsPageViewProps } from "./GroupsPageView" + +export default { + title: "pages/GroupsPageView", + component: GroupsPageView, +} + +const Template: Story = (args: GroupsPageViewProps) => ( + +) + +export const NotEnabled = Template.bind({}) +NotEnabled.args = { + groups: [MockGroup], + canCreateGroup: true, + isRBACEnabled: false, +} + +export const WithGroups = Template.bind({}) +WithGroups.args = { + groups: [MockGroup], + canCreateGroup: true, + isRBACEnabled: true, +} + +export const EmptyGroup = Template.bind({}) +EmptyGroup.args = { + groups: [], + canCreateGroup: false, + isRBACEnabled: true, +} + +export const EmptyGroupWithPermission = Template.bind({}) +EmptyGroupWithPermission.args = { + groups: [], + canCreateGroup: true, + isRBACEnabled: true, +} diff --git a/site/src/pages/GroupsPage/GroupsPageView.tsx b/site/src/pages/GroupsPage/GroupsPageView.tsx new file mode 100644 index 0000000000000..fb25c93940c94 --- /dev/null +++ b/site/src/pages/GroupsPage/GroupsPageView.tsx @@ -0,0 +1,204 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import { makeStyles } from "@material-ui/core/styles" +import Table from "@material-ui/core/Table" +import TableBody from "@material-ui/core/TableBody" +import TableCell from "@material-ui/core/TableCell" +import TableContainer from "@material-ui/core/TableContainer" +import TableHead from "@material-ui/core/TableHead" +import TableRow from "@material-ui/core/TableRow" +import ArrowRightAltOutlined from "@material-ui/icons/ArrowRightAltOutlined" +import AddCircleOutline from "@material-ui/icons/AddCircleOutline" +import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import AvatarGroup from "@material-ui/lab/AvatarGroup" +import { AvatarData } from "components/AvatarData/AvatarData" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { EmptyState } from "components/EmptyState/EmptyState" +import { Stack } from "components/Stack/Stack" +import { TableLoader } from "components/TableLoader/TableLoader" +import { UserAvatar } from "components/UserAvatar/UserAvatar" +import React from "react" +import { Link as RouterLink, useNavigate } from "react-router-dom" +import { Paywall } from "components/Paywall/Paywall" +import { Group } from "api/typesGenerated" + +export type GroupsPageViewProps = { + groups: Group[] | undefined + canCreateGroup: boolean + isRBACEnabled: boolean +} + +export const GroupsPageView: React.FC = ({ + groups, + canCreateGroup, + isRBACEnabled, +}) => { + const isLoading = Boolean(groups === undefined) + const isEmpty = Boolean(groups && groups.length === 0) + const navigate = useNavigate() + const styles = useStyles() + + return ( + <> + + + + + + + + Read the docs + + + } + /> + + + + + + + Name + Users + + + + + + + + + + + + + + + + ) + } + /> + + + + + + {groups?.map((group) => { + const groupPageLink = `/groups/${group.id}` + + return ( + { + navigate(groupPageLink) + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + navigate(groupPageLink) + } + }} + className={styles.clickableTableRow} + > + + + + + + {group.members.length === 0 && "-"} + + {group.members.map((member) => ( + + ))} + + + + +
+ +
+
+
+ ) + })} +
+
+
+
+
+
+
+ + ) +} + +const useStyles = makeStyles((theme) => ({ + clickableTableRow: { + cursor: "pointer", + + "&:hover td": { + backgroundColor: theme.palette.action.hover, + }, + + "&:focus": { + outline: `1px solid ${theme.palette.secondary.dark}`, + }, + + "& .MuiTableCell-root:last-child": { + paddingRight: theme.spacing(2), + }, + }, + arrowRight: { + color: theme.palette.text.secondary, + width: 20, + height: 20, + }, + arrowCell: { + display: "flex", + }, +})) + +export default GroupsPageView diff --git a/site/src/pages/GroupsPage/SettingsGroupPage.tsx b/site/src/pages/GroupsPage/SettingsGroupPage.tsx new file mode 100644 index 0000000000000..4460292d0d840 --- /dev/null +++ b/site/src/pages/GroupsPage/SettingsGroupPage.tsx @@ -0,0 +1,50 @@ +import { useMachine } from "@xstate/react" +import React from "react" +import { Helmet } from "react-helmet-async" +import { useNavigate, useParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { editGroupMachine } from "xServices/groups/editGroupXService" +import SettingsGroupPageView from "./SettingsGroupPageView" + +export const SettingsGroupPage: React.FC = () => { + const { groupId } = useParams() + if (!groupId) { + throw new Error("Group ID not defined.") + } + + const navigate = useNavigate() + + const navigateToGroup = () => { + navigate(`/groups/${groupId}`) + } + + const [editState, sendEditEvent] = useMachine(editGroupMachine, { + context: { + groupId, + }, + actions: { + onUpdate: navigateToGroup, + }, + }) + const { updateGroupFormErrors, group } = editState.context + + return ( + <> + + {pageTitle("Settings Group")} + + + { + sendEditEvent({ type: "UPDATE", data }) + }} + group={group} + formErrors={updateGroupFormErrors} + isLoading={editState.matches("loading")} + isUpdating={editState.matches("updating")} + /> + + ) +} +export default SettingsGroupPage diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx new file mode 100644 index 0000000000000..1cde5c8ff80ad --- /dev/null +++ b/site/src/pages/GroupsPage/SettingsGroupPageView.stories.tsx @@ -0,0 +1,21 @@ +import { Story } from "@storybook/react" +import { MockGroup } from "testHelpers/entities" +import { + SettingsGroupPageView, + SettingsGroupPageViewProps, +} from "./SettingsGroupPageView" + +export default { + title: "pages/SettingsGroupPageView", + component: SettingsGroupPageView, +} + +const Template: Story = ( + args: SettingsGroupPageViewProps, +) => + +export const Example = Template.bind({}) +Example.args = { + group: MockGroup, + isLoading: false, +} diff --git a/site/src/pages/GroupsPage/SettingsGroupPageView.tsx b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx new file mode 100644 index 0000000000000..c1b63915345ad --- /dev/null +++ b/site/src/pages/GroupsPage/SettingsGroupPageView.tsx @@ -0,0 +1,93 @@ +import TextField from "@material-ui/core/TextField" +import { Group } from "api/typesGenerated" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { FormFooter } from "components/FormFooter/FormFooter" +import { FullPageForm } from "components/FullPageForm/FullPageForm" +import { FullScreenLoader } from "components/Loader/FullScreenLoader" +import { Margins } from "components/Margins/Margins" +import { useFormik } from "formik" +import React from "react" +import { getFormHelpers, nameValidator, onChangeTrimmed } from "util/formUtils" +import * as Yup from "yup" + +type FormData = { + name: string +} + +const validationSchema = Yup.object({ + name: nameValidator("Name"), +}) + +const UpdateGroupForm: React.FC<{ + group: Group + errors: unknown + onSubmit: (data: FormData) => void + onCancel: () => void + isLoading: boolean +}> = ({ group, errors, onSubmit, onCancel, isLoading }) => { + const form = useFormik({ + initialValues: { + name: group.name, + }, + validationSchema, + onSubmit, + }) + const getFieldHelpers = getFormHelpers(form, errors) + + return ( + +
+ + + +
+ ) +} + +export type SettingsGroupPageViewProps = { + onCancel: () => void + onSubmit: (data: FormData) => void + group: Group | undefined + formErrors: unknown + isLoading: boolean + isUpdating: boolean +} + +export const SettingsGroupPageView: React.FC = ({ + onCancel, + onSubmit, + group, + formErrors, + isLoading, + isUpdating, +}) => { + return ( + + + + + + + + + + + + ) +} + +export default SettingsGroupPageView diff --git a/site/src/pages/TemplatePage/TemplatePage.tsx b/site/src/pages/TemplatePage/TemplatePage.tsx deleted file mode 100644 index 22660826d06e9..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePage.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { makeStyles } from "@material-ui/core/styles" -import { useMachine, useSelector } from "@xstate/react" -import { AlertBanner } from "components/AlertBanner/AlertBanner" -import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" -import { Margins } from "components/Margins/Margins" -import { FC, useContext } from "react" -import { Helmet } from "react-helmet-async" -import { Navigate, useParams } from "react-router-dom" -import { selectPermissions } from "xServices/auth/authSelectors" -import { XServiceContext } from "xServices/StateContext" -import { Loader } from "../../components/Loader/Loader" -import { useOrganizationId } from "../../hooks/useOrganizationId" -import { pageTitle } from "../../util/page" -import { templateMachine } from "../../xServices/template/templateXService" -import { TemplatePageView } from "./TemplatePageView" - -const useTemplateName = () => { - const { template } = useParams() - - if (!template) { - throw new Error("No template found in the URL") - } - - return template -} - -export const TemplatePage: FC> = () => { - const styles = useStyles() - const organizationId = useOrganizationId() - const templateName = useTemplateName() - const [templateState, templateSend] = useMachine(templateMachine, { - context: { - templateName, - organizationId, - }, - }) - - const { - template, - activeTemplateVersion, - templateResources, - templateVersions, - deleteTemplateError, - templateDAUs, - getTemplateError, - } = templateState.context - const xServices = useContext(XServiceContext) - const permissions = useSelector(xServices.authXService, selectPermissions) - const isLoading = - !template || - !activeTemplateVersion || - !templateResources || - !permissions || - !templateDAUs - - const handleDeleteTemplate = () => { - templateSend("DELETE") - } - - if (templateState.matches("error") && Boolean(getTemplateError)) { - return ( - -
- -
-
- ) - } - - if (isLoading) { - return - } - - if (templateState.matches("deleted")) { - return - } - - return ( - <> - - {pageTitle(`${template.name} ยท Template`)} - - - - { - templateSend("CONFIRM_DELETE") - }} - onCancel={() => { - templateSend("CANCEL_DELETE") - }} - /> - - ) -} - -const useStyles = makeStyles((theme) => ({ - errorBox: { - padding: theme.spacing(3), - }, -})) - -export default TemplatePage diff --git a/site/src/pages/TemplatePage/TemplatePageView.tsx b/site/src/pages/TemplatePage/TemplatePageView.tsx deleted file mode 100644 index 57da6565b2fb3..0000000000000 --- a/site/src/pages/TemplatePage/TemplatePageView.tsx +++ /dev/null @@ -1,214 +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 { AlertBanner } from "components/AlertBanner/AlertBanner" -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< - React.PropsWithChildren -> = ({ - template, - activeTemplateVersion, - templateResources, - templateVersions, - templateDAUs, - handleDeleteTemplate, - deleteTemplateError, - canDeleteTemplate, -}) => { - const styles = useStyles() - const readme = frontMatter(activeTemplateVersion.readme) - const hasIcon = template.icon && template.icon !== "" - - const deleteError = Boolean(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/TemplatePermissionsPage/TemplatePermissionsPage.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx new file mode 100644 index 0000000000000..9851f1c13e9cc --- /dev/null +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx @@ -0,0 +1,102 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import ArrowRightAltOutlined from "@material-ui/icons/ArrowRightAltOutlined" +import { useMachine } from "@xstate/react" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { Paywall } from "components/Paywall/Paywall" +import { Stack } from "components/Stack/Stack" +import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout" +import { useFeatureVisibility } from "hooks/useFeatureVisibility" +import { useOrganizationId } from "hooks/useOrganizationId" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "util/page" +import { templateACLMachine } from "xServices/template/templateACLXService" +import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView" + +export const TemplatePermissionsPage: FC< + React.PropsWithChildren +> = () => { + const organizationId = useOrganizationId() + const { context } = useTemplateLayoutContext() + const { template, permissions } = context + if (!template || !permissions) { + throw new Error( + "This page should not be displayed until template or permissions being loaded.", + ) + } + const { rbac: isRBACEnabled } = useFeatureVisibility() + const [state, send] = useMachine(templateACLMachine, { + context: { templateId: template.id }, + }) + const { templateACL, userToBeUpdated, groupToBeUpdated } = state.context + + return ( + <> + + {pageTitle(`${template.name} ยท Permissions`)} + + + + + + + + + Read the docs + + + } + /> + + + { + send("ADD_USER", { user, role, onDone: reset }) + }} + isAddingUser={state.matches("addingUser")} + onUpdateUser={(user, role) => { + send("UPDATE_USER_ROLE", { user, role }) + }} + updatingUser={userToBeUpdated} + onRemoveUser={(user) => { + send("REMOVE_USER", { user }) + }} + onAddGroup={(group, role, reset) => { + send("ADD_GROUP", { group, role, onDone: reset }) + }} + isAddingGroup={state.matches("addingGroup")} + onUpdateGroup={(group, role) => { + send("UPDATE_GROUP_ROLE", { group, role }) + }} + updatingGroup={groupToBeUpdated} + onRemoveGroup={(group) => { + send("REMOVE_GROUP", { group }) + }} + /> + + + + ) +} + +export default TemplatePermissionsPage diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx new file mode 100644 index 0000000000000..9e8d8c19cda85 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.stories.tsx @@ -0,0 +1,38 @@ +import { Story } from "@storybook/react" +import { + MockOrganization, + MockTemplateACL, + MockTemplateACLEmpty, +} from "testHelpers/entities" +import { + TemplatePermissionsPageView, + TemplatePermissionsPageViewProps, +} from "./TemplatePermissionsPageView" + +export default { + title: "pages/TemplatePermissionsPageView", + component: TemplatePermissionsPageView, +} + +const Template: Story = ( + args: TemplatePermissionsPageViewProps, +) => + +export const Empty = Template.bind({}) +Empty.args = { + templateACL: MockTemplateACLEmpty, + canUpdatePermissions: false, +} + +export const WithTemplateACL = Template.bind({}) +WithTemplateACL.args = { + templateACL: MockTemplateACL, + canUpdatePermissions: false, +} + +export const WithUpdatePermissions = Template.bind({}) +WithUpdatePermissions.args = { + templateACL: MockTemplateACL, + canUpdatePermissions: true, + organizationId: MockOrganization.id, +} diff --git a/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx new file mode 100644 index 0000000000000..a423d503ea2e2 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -0,0 +1,367 @@ +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 PersonAdd from "@material-ui/icons/PersonAdd" +import { + Group, + TemplateACL, + TemplateGroup, + TemplateRole, + TemplateUser, +} from "api/typesGenerated" +import { AvatarData } from "components/AvatarData/AvatarData" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { EmptyState } from "components/EmptyState/EmptyState" +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 { + UserOrGroupAutocomplete, + UserOrGroupAutocompleteValue, +} from "components/UserOrGroupAutocomplete/UserOrGroupAutocomplete" +import { FC, useState } from "react" +import { Maybe } from "components/Conditionals/Maybe" + +type AddTemplateUserOrGroupProps = { + organizationId: string + isLoading: boolean + templateACL: TemplateACL | undefined + onSubmit: ( + userOrGroup: TemplateUser | TemplateGroup, + role: TemplateRole, + reset: () => void, + ) => void +} + +const AddTemplateUserOrGroup: React.FC = ({ + isLoading, + onSubmit, + organizationId, + templateACL, +}) => { + const styles = useStyles() + const [selectedOption, setSelectedOption] = + useState(null) + const [selectedRole, setSelectedRole] = useState("view") + const excludeFromAutocomplete = templateACL + ? [...templateACL.group, ...templateACL.users] + : [] + + const resetValues = () => { + setSelectedOption(null) + setSelectedRole("view") + } + + return ( +
{ + e.preventDefault() + + if (selectedOption && selectedRole) { + onSubmit( + { + ...selectedOption, + role: selectedRole, + }, + selectedRole, + resetValues, + ) + } + }} + > + + { + setSelectedOption(newValue) + }} + /> + + + + } + loading={isLoading} + > + Add member + + +
+ ) +} + +export interface TemplatePermissionsPageViewProps { + templateACL: TemplateACL | undefined + organizationId: string + canUpdatePermissions: boolean + // User + onAddUser: (user: TemplateUser, role: TemplateRole, reset: () => void) => void + isAddingUser: boolean + onUpdateUser: (user: TemplateUser, role: TemplateRole) => void + updatingUser: TemplateUser | undefined + onRemoveUser: (user: TemplateUser) => void + // Group + onAddGroup: ( + group: TemplateGroup, + role: TemplateRole, + reset: () => void, + ) => void + isAddingGroup: boolean + onUpdateGroup: (group: TemplateGroup, role: TemplateRole) => void + updatingGroup: TemplateGroup | undefined + onRemoveGroup: (group: Group) => void +} + +export const TemplatePermissionsPageView: FC< + React.PropsWithChildren +> = ({ + templateACL, + canUpdatePermissions, + organizationId, + // User + onAddUser, + isAddingUser, + updatingUser, + onUpdateUser, + onRemoveUser, + // Group + onAddGroup, + isAddingGroup, + updatingGroup, + onUpdateGroup, + onRemoveGroup, +}) => { + const styles = useStyles() + const isEmpty = Boolean( + templateACL && + templateACL.users.length === 0 && + templateACL.group.length === 0, + ) + + return ( + + + + "members" in value + ? onAddGroup(value, role, resetAutocomplete) + : onAddUser(value, role, resetAutocomplete) + } + /> + + + + + + Member + Role + + + + + + + + + + + + + + + + + {templateACL?.group.map((group) => ( + + + + + + + + + + +
{group.role}
+
+
+
+ + + + onRemoveGroup(group), + }, + ]} + /> + + +
+ ))} + + {templateACL?.users.map((user) => ( + + + + ) : null + } + /> + + + + + + + +
{user.role}
+
+
+
+ + + + onRemoveUser(user), + }, + ]} + /> + + +
+ ))} +
+
+
+
+
+
+ ) +} + +export const useStyles = makeStyles((theme) => { + return { + 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), + }, + }, + + role: { + textTransform: "capitalize", + }, + } +}) 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 352db1fdf8a9b..d747a7be8f4ef 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 { CategoryScale, Chart as ChartJS, @@ -25,7 +25,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 79% rename from site/src/pages/TemplatePage/TemplatePage.test.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx index 99cb2ea71930a..fd2945506365a 100644 --- a/site/src/pages/TemplatePage/TemplatePage.test.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.test.tsx @@ -1,16 +1,17 @@ import { fireEvent, screen } from "@testing-library/react" +import { TemplateLayout } from "components/TemplateLayout/TemplateLayout" import { rest } from "msw" import { ResizeObserver } from "resize-observer" -import { server } from "testHelpers/server" -import * as CreateDayString from "util/createDayString" import { MockMemberPermissions, MockTemplate, MockTemplateVersion, 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()) @@ -18,26 +19,31 @@ Object.defineProperty(window, "ResizeObserver", { value: ResizeObserver, }) -describe("TemplatePage", () => { +const renderPage = () => + renderWithAuth( + + + , + { + route: `/templates/${MockTemplate.id}`, + path: "/templates/:template", + }, + ) + +describe("TemplateSummaryPage", () => { it("shows the template name, readme and resources", async () => { // Mocking the dayjs module within the createDayString file const mock = jest.spyOn(CreateDayString, "createDayString") mock.mockImplementation(() => "a minute ago") - renderWithAuth(, { - route: `/templates/${MockTemplate.id}`, - path: "/templates/:template", - }) + renderPage() await screen.findByText(MockTemplate.name) screen.getByTestId("markdown") screen.getByText(MockWorkspaceResource.name) screen.queryAllByText(`${MockTemplateVersion.name}`).length }) it("allows an admin to delete a template", async () => { - renderWithAuth(, { - route: `/templates/${MockTemplate.id}`, - path: "/templates/:template", - }) + renderPage() const dropdownButton = await screen.findByLabelText("open-dropdown") fireEvent.click(dropdownButton) const deleteButton = await screen.findByText("Delete") @@ -50,10 +56,7 @@ describe("TemplatePage", () => { return res(ctx.status(200), ctx.json(MockMemberPermissions)) }), ) - renderWithAuth(, { - route: `/templates/${MockTemplate.id}`, - path: "/templates/:template", - }) + renderPage() const dropdownButton = screen.queryByLabelText("open-dropdown") expect(dropdownButton).toBe(null) }) diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx new file mode 100644 index 0000000000000..6a5718e613742 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPage.tsx @@ -0,0 +1,41 @@ +import { useTemplateLayoutContext } from "components/TemplateLayout/TemplateLayout" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { pageTitle } from "util/page" +import { TemplateSummaryPageView } from "./TemplateSummaryPageView" + +export const TemplateSummaryPage: FC = () => { + const { context } = useTemplateLayoutContext() + const { + template, + activeTemplateVersion, + templateResources, + templateVersions, + deleteTemplateError, + templateDAUs, + } = context + + if (!template || !activeTemplateVersion || !templateResources) { + throw new Error( + "This page should not be displayed until template, activeTemplateVersion or templateResources being loaded.", + ) + } + + return ( + <> + + {pageTitle(`${template.name} ยท Template`)} + + + + ) +} + +export default TemplateSummaryPage diff --git a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx similarity index 78% rename from site/src/pages/TemplatePage/TemplatePageView.stories.tsx rename to site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx index 378a5ce1d5e81..f3f348695ddea 100644 --- a/site/src/pages/TemplatePage/TemplatePageView.stories.tsx +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.stories.tsx @@ -1,14 +1,17 @@ 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({}) diff --git a/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx new file mode 100644 index 0000000000000..b80593f3f523a --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateSummaryPage/TemplateSummaryPageView.tsx @@ -0,0 +1,90 @@ +import { makeStyles } from "@material-ui/core/styles" +import { + Template, + TemplateDAUsResponse, + TemplateVersion, + WorkspaceResource, +} from "api/typesGenerated" +import { AlertBanner } from "components/AlertBanner/AlertBanner" +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< + React.PropsWithChildren +> = ({ + 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/pages/UsersPage/UsersPage.test.tsx b/site/src/pages/UsersPage/UsersPage.test.tsx index 18b1705b0fe00..4052c1bd65c5b 100644 --- a/site/src/pages/UsersPage/UsersPage.test.tsx +++ b/site/src/pages/UsersPage/UsersPage.test.tsx @@ -14,16 +14,23 @@ import { MockAuditorRole, MockUser, MockUser2, - render, + renderWithAuth, SuspendedMockUser, } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" -import { permissionsToCheck } from "../../xServices/auth/authXService" import { Language as UsersPageLanguage, UsersPage } from "./UsersPage" -import { Language as UsersViewLanguage } from "./UsersPageView" const { t } = i18n +const renderPage = () => { + return renderWithAuth( + <> + + + , + ) +} + const suspendUser = async (setupActionSpies: () => void) => { const user = userEvent.setup() // Get the first user in the table @@ -186,52 +193,15 @@ const updateUserRole = async (setupActionSpies: () => void, role: Role) => { describe("UsersPage", () => { it("shows users", async () => { - render() + renderPage() const users = await screen.findAllByText(/.*@coder.com/) expect(users.length).toEqual(3) }) - it("shows 'Create user' button to an authorized user", async () => { - render() - const createUserButton = await screen.findByText( - UsersViewLanguage.createButton, - ) - // wait for users page to finish loading - await screen.findAllByLabelText("more") - expect(createUserButton).toBeDefined() - }) - - it("does not show 'Create user' button to unauthorized user", async () => { - server.use( - rest.post("/api/v2/authcheck", async (req, res, ctx) => { - const permissions = Object.keys(permissionsToCheck) - const response = permissions.reduce((obj, permission) => { - return { - ...obj, - [permission]: true, - createUser: false, - } - }, {}) - - return res(ctx.status(200), ctx.json(response)) - }), - ) - render() - const createUserButton = screen.queryByText(UsersViewLanguage.createButton) - // wait for users page to finish loading - await screen.findAllByLabelText("more") - expect(createUserButton).toBeNull() - }) - describe("suspend user", () => { describe("when it is success", () => { it("shows a success message and refresh the page", async () => { - render( - <> - - - , - ) + renderPage() await suspendUser(() => { jest.spyOn(API, "suspendUser").mockResolvedValueOnce(MockUser) @@ -253,12 +223,7 @@ describe("UsersPage", () => { }) describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await suspendUser(() => { jest.spyOn(API, "suspendUser").mockRejectedValueOnce({}) @@ -277,12 +242,7 @@ describe("UsersPage", () => { describe("delete user", () => { describe("when it is success", () => { it("shows a success message and refresh the page", async () => { - render( - <> - - - , - ) + renderPage() await deleteUser(() => { jest.spyOn(API, "deleteUser").mockResolvedValueOnce(undefined) @@ -307,12 +267,7 @@ describe("UsersPage", () => { }) describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await deleteUser(() => { jest.spyOn(API, "deleteUser").mockRejectedValueOnce({}) @@ -331,12 +286,7 @@ describe("UsersPage", () => { describe("activate user", () => { describe("when user is successfully activated", () => { it("shows a success message and refreshes the page", async () => { - render( - <> - - - , - ) + renderPage() await activateUser(() => { jest @@ -359,12 +309,7 @@ describe("UsersPage", () => { }) describe("when activation fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await activateUser(() => { jest.spyOn(API, "activateUser").mockRejectedValueOnce({}) @@ -383,12 +328,7 @@ describe("UsersPage", () => { describe("reset user password", () => { describe("when it is success", () => { it("shows a success message", async () => { - render( - <> - - - , - ) + renderPage() await resetUserPassword(() => { jest.spyOn(API, "updateUserPassword").mockResolvedValueOnce(undefined) @@ -407,12 +347,7 @@ describe("UsersPage", () => { }) describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await resetUserPassword(() => { jest.spyOn(API, "updateUserPassword").mockRejectedValueOnce({}) @@ -434,12 +369,7 @@ describe("UsersPage", () => { describe("Update user role", () => { describe("when it is success", () => { it("updates the roles", async () => { - render( - <> - - - , - ) + renderPage() const { rolesMenuTrigger } = await updateUserRole(() => { jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({ @@ -465,12 +395,7 @@ describe("UsersPage", () => { describe("when it fails", () => { it("shows an error message", async () => { - render( - <> - - - , - ) + renderPage() await updateUserRole(() => { jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({}) @@ -492,12 +417,7 @@ describe("UsersPage", () => { ) }) it("shows an error from the backend", async () => { - render( - <> - - - , - ) + renderPage() server.use( rest.put(`/api/v2/users/${MockUser.id}/roles`, (req, res, ctx) => { diff --git a/site/src/pages/UsersPage/UsersPage.tsx b/site/src/pages/UsersPage/UsersPage.tsx index 59d24af33f7c9..ce28b51032295 100644 --- a/site/src/pages/UsersPage/UsersPage.tsx +++ b/site/src/pages/UsersPage/UsersPage.tsx @@ -1,5 +1,6 @@ import { useActor, useMachine } from "@xstate/react" import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog" +import { usePermissions } from "hooks/usePermissions" import { FC, ReactNode, useContext, useEffect } from "react" import { Helmet } from "react-helmet-async" import { useNavigate } from "react-router" @@ -44,21 +45,14 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { const userToBeDeleted = users?.find((u) => u.id === userIdToDelete) const userToBeActivated = users?.find((u) => u.id === userIdToActivate) const userToResetPassword = users?.find((u) => u.id === userIdToResetPassword) - - const [authState, _] = useActor(xServices.authXService) - const { permissions } = authState.context - const canEditUsers = permissions && permissions.updateUsers - const canCreateUser = permissions && permissions.createUser - + const { updateUsers: canEditUsers } = usePermissions() const [rolesState, rolesSend] = useActor(xServices.siteRolesXService) const { roles } = rolesState.context // Is loading if - // - permissions are loading or // - users are loading or // - the user can edit the users but the roles are loading const isLoading = - authState.matches("gettingPermissions") || usersState.matches("gettingUsers") || (canEditUsers && rolesState.matches("gettingRoles")) @@ -88,9 +82,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { { - navigate("/users/create") - }} onListWorkspaces={(user) => { navigate( "/workspaces?filter=" + @@ -120,7 +111,6 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => { isUpdatingUserRoles={usersState.matches("updatingUserRoles")} isLoading={isLoading} canEditUsers={canEditUsers} - canCreateUser={canCreateUser} filter={usersState.context.filter} onFilter={(query) => { searchParams.set("filter", query) diff --git a/site/src/pages/UsersPage/UsersPageView.tsx b/site/src/pages/UsersPage/UsersPageView.tsx index 65915b727918c..94e84d9b4cf79 100644 --- a/site/src/pages/UsersPage/UsersPageView.tsx +++ b/site/src/pages/UsersPage/UsersPageView.tsx @@ -1,19 +1,10 @@ -import Button from "@material-ui/core/Button" -import AddCircleOutline from "@material-ui/icons/AddCircleOutline" import { FC } from "react" import * as TypesGen from "../../api/typesGenerated" -import { Margins } from "../../components/Margins/Margins" -import { - PageHeader, - PageHeaderTitle, -} from "../../components/PageHeader/PageHeader" import { SearchBarWithFilter } from "../../components/SearchBarWithFilter/SearchBarWithFilter" import { UsersTable } from "../../components/UsersTable/UsersTable" import { userFilterQuery } from "../../util/filters" export const Language = { - pageTitle: "Users", - createButton: "New user", activeUsersFilterName: "Active users", allUsersFilterName: "All users", } @@ -24,9 +15,7 @@ export interface UsersPageViewProps { error?: unknown isUpdatingUserRoles?: boolean canEditUsers?: boolean - canCreateUser?: boolean isLoading?: boolean - openUserCreationDialog: () => void onSuspendUser: (user: TypesGen.User) => void onDeleteUser: (user: TypesGen.User) => void onListWorkspaces: (user: TypesGen.User) => void @@ -42,7 +31,6 @@ export interface UsersPageViewProps { export const UsersPageView: FC> = ({ users, roles, - openUserCreationDialog, onSuspendUser, onDeleteUser, onListWorkspaces, @@ -52,7 +40,6 @@ export const UsersPageView: FC> = ({ error, isUpdatingUserRoles, canEditUsers, - canCreateUser, isLoading, filter, onFilter, @@ -63,22 +50,7 @@ export const UsersPageView: FC> = ({ ] return ( - - } - > - {Language.createButton} - - ) : undefined - } - > - {Language.pageTitle} - - + <> > = ({ canEditUsers={canEditUsers} isLoading={isLoading} /> - + ) } diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 1433f0fca8002..c633b3e8fb88d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -909,3 +909,20 @@ export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { user_workspace_count: 0, user_workspace_limit: 100, } + +export const MockGroup: TypesGen.Group = { + id: "fbd2116a-8961-4954-87ae-e4575bd29ce0", + name: "Front-End", + organization_id: MockOrganization.id, + members: [MockUser, MockUser2], +} + +export const MockTemplateACL: TypesGen.TemplateACL = { + group: [{ ...MockGroup, role: "admin" }], + users: [{ ...MockUser, role: "view" }], +} + +export const MockTemplateACLEmpty: TypesGen.TemplateACL = { + group: [], + users: [], +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 54de671d74f05..fd6903cad88a7 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -3,6 +3,7 @@ import { WorkspaceBuildTransition } from "../api/types" import { CreateWorkspaceBuildRequest } from "../api/typesGenerated" import { permissionsToCheck } from "../xServices/auth/authXService" import * as M from "./entities" +import { MockGroup } from "./entities" export const handlers = [ rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => { @@ -104,7 +105,10 @@ export const handlers = [ return res(ctx.status(200), ctx.json(M.MockSiteRoles)) }), rest.post("/api/v2/authcheck", async (req, res, ctx) => { - const permissions = Object.keys(permissionsToCheck) + const permissions = [ + ...Object.keys(permissionsToCheck), + "canUpdateTemplate", + ] const response = permissions.reduce((obj, permission) => { return { ...obj, @@ -215,4 +219,28 @@ export const handlers = [ rest.get("/api/v2/applications/host", (req, res, ctx) => { return res(ctx.status(200), ctx.json({ host: "dev.coder.com" })) }), + + // Groups + rest.get("/api/v2/organizations/:organizationId/groups", (req, res, ctx) => { + return res(ctx.status(200), ctx.json([MockGroup])) + }), + + rest.post( + "/api/v2/organizations/:organizationId/groups", + async (req, res, ctx) => { + return res(ctx.status(201), ctx.json(M.MockGroup)) + }, + ), + + rest.get("/api/v2/groups/:groupId", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockGroup)) + }), + + rest.patch("/api/v2/groups/:groupId", (req, res, ctx) => { + return res(ctx.status(200), ctx.json(MockGroup)) + }), + + rest.delete("/api/v2/groups/:groupId", (req, res, ctx) => { + return res(ctx.status(204)) + }), ] diff --git a/site/src/xServices/auth/authXService.ts b/site/src/xServices/auth/authXService.ts index 4739c43bc4b0a..d2cba576370b3 100644 --- a/site/src/xServices/auth/authXService.ts +++ b/site/src/xServices/auth/authXService.ts @@ -16,6 +16,7 @@ export const checks = { createTemplates: "createTemplates", deleteTemplates: "deleteTemplates", viewAuditLog: "viewAuditLog", + createGroup: "createGroup", } as const export const permissionsToCheck = { @@ -55,9 +56,15 @@ export const permissionsToCheck = { }, action: "read", }, + [checks.createGroup]: { + object: { + resource_type: "group", + }, + action: "create", + }, } as const -type Permissions = Record +export type Permissions = Record export interface AuthContext { getUserError?: Error | unknown diff --git a/site/src/xServices/groups/createGroupXService.ts b/site/src/xServices/groups/createGroupXService.ts new file mode 100644 index 0000000000000..7faf4ee6fb9ad --- /dev/null +++ b/site/src/xServices/groups/createGroupXService.ts @@ -0,0 +1,81 @@ +import { createGroup } from "api/api" +import { + ApiError, + getErrorMessage, + hasApiFieldErrors, + isApiError, + mapApiErrorToFieldErrors, +} from "api/errors" +import { CreateGroupRequest, Group } from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" +import { createMachine } from "xstate" + +export const createGroupMachine = createMachine( + { + id: "createGroupMachine", + schema: { + context: {} as { + organizationId: string + createGroupFormErrors?: unknown + }, + services: {} as { + createGroup: { + data: Group + } + }, + events: {} as { + type: "CREATE" + data: CreateGroupRequest + }, + }, + tsTypes: {} as import("./createGroupXService.typegen").Typegen0, + initial: "idle", + states: { + idle: { + on: { + CREATE: { + target: "creatingGroup", + }, + }, + }, + creatingGroup: { + invoke: { + src: "createGroup", + onDone: { + target: "idle", + actions: ["onCreate"], + }, + onError: [ + { + target: "idle", + cond: "hasFieldErrors", + actions: ["assignCreateGroupFormErrors"], + }, + { + target: "idle", + actions: ["displayCreateGroupError"], + }, + ], + }, + }, + }, + }, + { + guards: { + hasFieldErrors: (_, event) => + isApiError(event.data) && hasApiFieldErrors(event.data), + }, + services: { + createGroup: ({ organizationId }, { data }) => + createGroup(organizationId, data), + }, + actions: { + displayCreateGroupError: (_, { data }) => { + const message = getErrorMessage(data, "Error on creating the group.") + displayError(message) + }, + assignCreateGroupFormErrors: (_, event) => + mapApiErrorToFieldErrors((event.data as ApiError).response.data), + }, + }, +) diff --git a/site/src/xServices/groups/editGroupXService.ts b/site/src/xServices/groups/editGroupXService.ts new file mode 100644 index 0000000000000..78d4b1a14370b --- /dev/null +++ b/site/src/xServices/groups/editGroupXService.ts @@ -0,0 +1,115 @@ +import { getGroup, patchGroup } from "api/api" +import { + ApiError, + getErrorMessage, + hasApiFieldErrors, + isApiError, + mapApiErrorToFieldErrors, +} from "api/errors" +import { Group } from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" +import { assign, createMachine } from "xstate" + +export const editGroupMachine = createMachine( + { + id: "editGroup", + schema: { + context: {} as { + groupId: string + group?: Group + updateGroupFormErrors?: unknown + }, + services: {} as { + loadGroup: { + data: Group + } + updateGroup: { + data: Group + } + }, + events: {} as { + type: "UPDATE" + data: { name: string } + }, + }, + tsTypes: {} as import("./editGroupXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + invoke: { + src: "loadGroup", + onDone: { + actions: ["assignGroup"], + target: "idle", + }, + onError: { + actions: ["displayLoadGroupError"], + target: "idle", + }, + }, + }, + idle: { + on: { + UPDATE: { + target: "updating", + }, + }, + }, + updating: { + invoke: { + src: "updateGroup", + onDone: { + actions: ["onUpdate"], + }, + onError: [ + { + target: "idle", + cond: "hasFieldErrors", + actions: ["assignUpdateGroupFormErrors"], + }, + { + target: "idle", + actions: ["displayUpdateGroupError"], + }, + ], + }, + }, + }, + }, + { + guards: { + hasFieldErrors: (_, event) => + isApiError(event.data) && hasApiFieldErrors(event.data), + }, + services: { + loadGroup: ({ groupId }) => getGroup(groupId), + + updateGroup: ({ group }, { data }) => { + if (!group) { + throw new Error("Group not defined.") + } + + return patchGroup(group.id, { + ...data, + add_users: [], + remove_users: [], + }) + }, + }, + actions: { + assignGroup: assign({ + group: (_, { data }) => data, + }), + displayLoadGroupError: (_, { data }) => { + const message = getErrorMessage(data, "Failed to the group.") + displayError(message) + }, + displayUpdateGroupError: (_, { data }) => { + const message = getErrorMessage(data, "Failed to update the group.") + displayError(message) + }, + assignUpdateGroupFormErrors: (_, event) => + mapApiErrorToFieldErrors((event.data as ApiError).response.data), + }, + }, +) diff --git a/site/src/xServices/groups/groupXService.ts b/site/src/xServices/groups/groupXService.ts new file mode 100644 index 0000000000000..66d9b482d1022 --- /dev/null +++ b/site/src/xServices/groups/groupXService.ts @@ -0,0 +1,270 @@ +import { deleteGroup, getGroup, patchGroup, checkAuthorization } from "api/api" +import { getErrorMessage } from "api/errors" +import { AuthorizationResponse, Group } from "api/typesGenerated" +import { displayError, displaySuccess } from "components/GlobalSnackbar/utils" +import { assign, createMachine } from "xstate" + +export const groupMachine = createMachine( + { + id: "group", + schema: { + context: {} as { + groupId: string + group?: Group + permissions?: AuthorizationResponse + addMemberCallback?: () => void + removingMember?: string + }, + services: {} as { + loadGroup: { + data: Group + } + loadPermissions: { + data: AuthorizationResponse + } + addMember: { + data: Group + } + removeMember: { + data: Group + } + deleteGroup: { + data: unknown + } + }, + events: {} as + | { + type: "ADD_MEMBER" + userId: string + callback: () => void + } + | { + type: "REMOVE_MEMBER" + userId: string + } + | { + type: "DELETE" + } + | { + type: "CONFIRM_DELETE" + } + | { + type: "CANCEL_DELETE" + }, + }, + tsTypes: {} as import("./groupXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + type: "parallel", + states: { + data: { + initial: "loading", + states: { + loading: { + invoke: { + src: "loadGroup", + onDone: { + actions: ["assignGroup"], + target: "success", + }, + onError: { + actions: ["displayLoadGroupError"], + }, + }, + }, + success: { + type: "final", + }, + }, + }, + permissions: { + initial: "loading", + states: { + loading: { + invoke: { + src: "loadPermissions", + onDone: { + actions: ["assignPermissions"], + target: "success", + }, + onError: { + actions: ["displayLoadPermissionsError"], + }, + }, + }, + success: { + type: "final", + }, + }, + }, + }, + onDone: "idle", + }, + idle: { + on: { + ADD_MEMBER: { + target: "addingMember", + actions: ["assignAddMemberCallback"], + }, + REMOVE_MEMBER: { + target: "removingMember", + actions: ["removeUserFromMembers"], + }, + DELETE: { + target: "confirmingDelete", + }, + }, + }, + addingMember: { + invoke: { + src: "addMember", + onDone: { + actions: ["assignGroup", "callAddMemberCallback"], + target: "idle", + }, + onError: { + target: "idle", + actions: ["displayAddMemberError"], + }, + }, + }, + removingMember: { + invoke: { + src: "removeMember", + onDone: { + actions: ["assignGroup", "displayRemoveMemberSuccess"], + target: "idle", + }, + onError: { + target: "idle", + actions: ["displayRemoveMemberError"], + }, + }, + }, + confirmingDelete: { + on: { + CONFIRM_DELETE: "deleting", + CANCEL_DELETE: "idle", + }, + }, + deleting: { + invoke: { + src: "deleteGroup", + onDone: { + actions: ["redirectToGroups"], + }, + onError: { + actions: ["displayDeleteGroupError"], + }, + }, + }, + }, + }, + { + services: { + loadGroup: ({ groupId }) => getGroup(groupId), + loadPermissions: ({ groupId }) => + checkAuthorization({ + checks: { + canUpdateGroup: { + object: { + resource_type: "group", + resource_id: groupId, + }, + action: "update", + }, + }, + }), + addMember: ({ group }, { userId }) => { + if (!group) { + throw new Error("Group not defined.") + } + + return patchGroup(group.id, { + name: "", + add_users: [userId], + remove_users: [], + }) + }, + removeMember: ({ group }, { userId }) => { + if (!group) { + throw new Error("Group not defined.") + } + + return patchGroup(group.id, { + name: "", + add_users: [], + remove_users: [userId], + }) + }, + deleteGroup: ({ group }) => { + if (!group) { + throw new Error("Group not defined.") + } + + return deleteGroup(group.id) + }, + }, + actions: { + assignGroup: assign({ + group: (_, { data }) => data, + }), + assignAddMemberCallback: assign({ + addMemberCallback: (_, { callback }) => callback, + }), + displayLoadGroupError: (_, { data }) => { + const message = getErrorMessage(data, "Failed to load the group.") + displayError(message) + }, + displayAddMemberError: (_, { data }) => { + const message = getErrorMessage( + data, + "Failed to add member to the group.", + ) + displayError(message) + }, + callAddMemberCallback: ({ addMemberCallback }) => { + if (addMemberCallback) { + addMemberCallback() + } + }, + // Optimistically remove the user from members + removeUserFromMembers: assign({ + group: ({ group }, { userId }) => { + if (!group) { + throw new Error("Group is not defined.") + } + + return { + ...group, + members: group.members.filter( + (currentMember) => currentMember.id !== userId, + ), + } + }, + }), + displayRemoveMemberError: (_, { data }) => { + const message = getErrorMessage( + data, + "Failed to remove member from the group.", + ) + displayError(message) + }, + displayRemoveMemberSuccess: () => { + displaySuccess("Member removed successfully.") + }, + displayDeleteGroupError: (_, { data }) => { + const message = getErrorMessage(data, "Failed to delete group.") + displayError(message) + }, + assignPermissions: assign({ + permissions: (_, { data }) => data, + }), + displayLoadPermissionsError: (_, { data }) => { + const message = getErrorMessage(data, "Failed to load the permissions.") + displayError(message) + }, + }, + }, +) diff --git a/site/src/xServices/groups/groupsXService.ts b/site/src/xServices/groups/groupsXService.ts new file mode 100644 index 0000000000000..451bd85fc534f --- /dev/null +++ b/site/src/xServices/groups/groupsXService.ts @@ -0,0 +1,55 @@ +import { getGroups } from "api/api" +import { getErrorMessage } from "api/errors" +import { Group } from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" +import { assign, createMachine } from "xstate" + +export const groupsMachine = createMachine( + { + id: "groupsMachine", + predictableActionArguments: true, + schema: { + context: {} as { + organizationId: string + groups?: Group[] + }, + services: {} as { + loadGroups: { + data: Group[] + } + }, + }, + tsTypes: {} as import("./groupsXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + invoke: { + src: "loadGroups", + onDone: { + actions: ["assignGroups"], + target: "idle", + }, + onError: { + target: "idle", + actions: ["displayLoadingGroupsError"], + }, + }, + }, + idle: {}, + }, + }, + { + services: { + loadGroups: ({ organizationId }) => getGroups(organizationId), + }, + actions: { + assignGroups: assign({ + groups: (_, { data }) => data, + }), + displayLoadingGroupsError: (_, { data }) => { + const message = getErrorMessage(data, "Error on loading groups.") + displayError(message) + }, + }, + }, +) diff --git a/site/src/xServices/template/searchUsersAndGroupsXService.ts b/site/src/xServices/template/searchUsersAndGroupsXService.ts new file mode 100644 index 0000000000000..eccfdb9099eea --- /dev/null +++ b/site/src/xServices/template/searchUsersAndGroupsXService.ts @@ -0,0 +1,81 @@ +import { getGroups, getUsers } from "api/api" +import { Group, User } from "api/typesGenerated" +import { queryToFilter } from "util/filters" +import { assign, createMachine } from "xstate" + +export type SearchUsersAndGroupsEvent = + | { type: "SEARCH"; query: string } + | { type: "CLEAR_RESULTS" } + +export const searchUsersAndGroupsMachine = createMachine( + { + id: "searchUsersAndGroups", + predictableActionArguments: true, + schema: { + context: {} as { + organizationId: string + userResults: User[] + groupResults: Group[] + }, + events: {} as SearchUsersAndGroupsEvent, + services: {} as { + search: { + data: { + users: User[] + groups: Group[] + } + } + }, + }, + tsTypes: {} as import("./searchUsersAndGroupsXService.typegen").Typegen0, + initial: "idle", + states: { + idle: { + on: { + SEARCH: { + target: "searching", + cond: "queryHasMinLength", + }, + CLEAR_RESULTS: { + actions: ["clearResults"], + target: "idle", + }, + }, + }, + searching: { + invoke: { + src: "search", + onDone: { + target: "idle", + actions: ["assignSearchResults"], + }, + }, + }, + }, + }, + { + services: { + search: async ({ organizationId }, { query }) => { + const [users, groups] = await Promise.all([ + getUsers(queryToFilter(query)), + getGroups(organizationId), + ]) + + return { users, groups } + }, + }, + actions: { + assignSearchResults: assign({ + userResults: (_, { data }) => data.users, + groupResults: (_, { data }) => data.groups, + }), + clearResults: assign({ + userResults: (_) => [], + groupResults: (_) => [], + }), + }, + guards: { + queryHasMinLength: (_, { query }) => query.length >= 3, + }, + }, +) diff --git a/site/src/xServices/template/templateACLXService.ts b/site/src/xServices/template/templateACLXService.ts new file mode 100644 index 0000000000000..4ff85299c692d --- /dev/null +++ b/site/src/xServices/template/templateACLXService.ts @@ -0,0 +1,366 @@ +import { getTemplateACL, updateTemplateACL } from "api/api" +import { + TemplateACL, + TemplateGroup, + TemplateRole, + TemplateUser, +} from "api/typesGenerated" +import { displaySuccess } from "components/GlobalSnackbar/utils" +import { assign, createMachine } from "xstate" + +export const templateACLMachine = createMachine( + { + schema: { + context: {} as { + templateId: string + templateACL?: TemplateACL + // User + userToBeAdded?: TemplateUser + userToBeUpdated?: TemplateUser + addUserCallback?: () => void + // Group + groupToBeAdded?: TemplateGroup + groupToBeUpdated?: TemplateGroup + addGroupCallback?: () => void + }, + services: {} as { + loadTemplateACL: { + data: TemplateACL + } + // User + addUser: { + data: unknown + } + updateUser: { + data: unknown + } + // Group + addGroup: { + data: unknown + } + updateGroup: { + data: unknown + } + }, + events: {} as // User + | { + type: "ADD_USER" + user: TemplateUser + role: TemplateRole + onDone: () => void + } + | { + type: "UPDATE_USER_ROLE" + user: TemplateUser + role: TemplateRole + } + | { + type: "REMOVE_USER" + user: TemplateUser + } + // Group + | { + type: "ADD_GROUP" + group: TemplateGroup + role: TemplateRole + onDone: () => void + } + | { + type: "UPDATE_GROUP_ROLE" + group: TemplateGroup + role: TemplateRole + } + | { + type: "REMOVE_GROUP" + group: TemplateGroup + }, + }, + tsTypes: {} as import("./templateACLXService.typegen").Typegen0, + id: "templateACL", + initial: "loading", + states: { + loading: { + invoke: { + src: "loadTemplateACL", + onDone: { + actions: ["assignTemplateACL"], + target: "idle", + }, + }, + }, + idle: { + on: { + // User + ADD_USER: { target: "addingUser", actions: ["assignUserToBeAdded"] }, + UPDATE_USER_ROLE: { + target: "updatingUser", + actions: ["assignUserToBeUpdated"], + }, + REMOVE_USER: { + target: "removingUser", + actions: ["removeUserFromTemplateACL"], + }, + // Group + ADD_GROUP: { + target: "addingGroup", + actions: ["assignGroupToBeAdded"], + }, + UPDATE_GROUP_ROLE: { + target: "updatingGroup", + actions: ["assignGroupToBeUpdated"], + }, + REMOVE_GROUP: { + target: "removingGroup", + actions: ["removeGroupFromTemplateACL"], + }, + }, + }, + // User + addingUser: { + invoke: { + src: "addUser", + onDone: { + target: "idle", + actions: ["addUserToTemplateACL", "runAddUserCallback"], + }, + }, + }, + updatingUser: { + invoke: { + src: "updateUser", + onDone: { + target: "idle", + actions: [ + "updateUserOnTemplateACL", + "clearUserToBeUpdated", + "displayUpdateUserSuccessMessage", + ], + }, + }, + }, + removingUser: { + invoke: { + src: "removeUser", + onDone: { + target: "idle", + actions: ["displayRemoveUserSuccessMessage"], + }, + }, + }, + // Group + addingGroup: { + invoke: { + src: "addGroup", + onDone: { + target: "idle", + actions: ["addGroupToTemplateACL", "runAddGroupCallback"], + }, + }, + }, + updatingGroup: { + invoke: { + src: "updateGroup", + onDone: { + target: "idle", + actions: [ + "updateGroupOnTemplateACL", + "clearGroupToBeUpdated", + "displayUpdateGroupSuccessMessage", + ], + }, + }, + }, + removingGroup: { + invoke: { + src: "removeGroup", + onDone: { + target: "idle", + actions: ["displayRemoveGroupSuccessMessage"], + }, + }, + }, + }, + }, + { + services: { + loadTemplateACL: ({ templateId }) => getTemplateACL(templateId), + // User + addUser: ({ templateId }, { user, role }) => + updateTemplateACL(templateId, { + user_perms: { + [user.id]: role, + }, + }), + updateUser: ({ templateId }, { user, role }) => + updateTemplateACL(templateId, { + user_perms: { + [user.id]: role, + }, + }), + removeUser: ({ templateId }, { user }) => + updateTemplateACL(templateId, { + user_perms: { + [user.id]: "", + }, + }), + // Group + addGroup: ({ templateId }, { group, role }) => + updateTemplateACL(templateId, { + group_perms: { + [group.id]: role, + }, + }), + updateGroup: ({ templateId }, { group, role }) => + updateTemplateACL(templateId, { + group_perms: { + [group.id]: role, + }, + }), + removeGroup: ({ templateId }, { group }) => + updateTemplateACL(templateId, { + group_perms: { + [group.id]: "", + }, + }), + }, + actions: { + assignTemplateACL: assign({ + templateACL: (_, { data }) => data, + }), + // User + assignUserToBeAdded: assign({ + userToBeAdded: (_, { user, role }) => ({ ...user, role }), + addUserCallback: (_, { onDone }) => onDone, + }), + addUserToTemplateACL: assign({ + templateACL: ({ templateACL, userToBeAdded }) => { + if (!userToBeAdded) { + throw new Error("No user to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + users: [...templateACL.users, userToBeAdded], + } + }, + }), + runAddUserCallback: ({ addUserCallback }) => { + if (addUserCallback) { + addUserCallback() + } + }, + assignUserToBeUpdated: assign({ + userToBeUpdated: (_, { user, role }) => ({ ...user, role }), + }), + updateUserOnTemplateACL: assign({ + templateACL: ({ templateACL, userToBeUpdated }) => { + if (!userToBeUpdated) { + throw new Error("No user to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + users: templateACL.users.map((oldTemplateUser) => { + return oldTemplateUser.id === userToBeUpdated.id + ? userToBeUpdated + : oldTemplateUser + }), + } + }, + }), + clearUserToBeUpdated: assign({ + userToBeUpdated: (_) => undefined, + }), + displayUpdateUserSuccessMessage: () => { + displaySuccess("User role update successfully!") + }, + removeUserFromTemplateACL: assign({ + templateACL: ({ templateACL }, { user }) => { + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + users: templateACL.users.filter((oldTemplateUser) => { + return oldTemplateUser.id !== user.id + }), + } + }, + }), + displayRemoveUserSuccessMessage: () => { + displaySuccess("User removed successfully!") + }, + // Group + assignGroupToBeAdded: assign({ + groupToBeAdded: (_, { group, role }) => ({ ...group, role }), + addGroupCallback: (_, { onDone }) => onDone, + }), + addGroupToTemplateACL: assign({ + templateACL: ({ templateACL, groupToBeAdded }) => { + if (!groupToBeAdded) { + throw new Error("No group to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + group: [...templateACL.group, groupToBeAdded], + } + }, + }), + runAddGroupCallback: ({ addGroupCallback }) => { + if (addGroupCallback) { + addGroupCallback() + } + }, + assignGroupToBeUpdated: assign({ + groupToBeUpdated: (_, { group, role }) => ({ ...group, role }), + }), + updateGroupOnTemplateACL: assign({ + templateACL: ({ templateACL, groupToBeUpdated }) => { + if (!groupToBeUpdated) { + throw new Error("No group to be added") + } + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + group: templateACL.group.map((oldTemplateGroup) => { + return oldTemplateGroup.id === groupToBeUpdated.id + ? groupToBeUpdated + : oldTemplateGroup + }), + } + }, + }), + clearGroupToBeUpdated: assign({ + groupToBeUpdated: (_) => undefined, + }), + displayUpdateGroupSuccessMessage: () => { + displaySuccess("Group role update successfully!") + }, + removeGroupFromTemplateACL: assign({ + templateACL: ({ templateACL }, { group }) => { + if (!templateACL) { + throw new Error("Template ACL is not loaded yet") + } + return { + ...templateACL, + group: templateACL.group.filter((oldTemplateGroup) => { + return oldTemplateGroup.id !== group.id + }), + } + }, + }), + displayRemoveGroupSuccessMessage: () => { + displaySuccess("Group removed successfully!") + }, + }, + }, +) diff --git a/site/src/xServices/template/templateXService.ts b/site/src/xServices/template/templateXService.ts index 531bd20bae189..0b8aea7f35179 100644 --- a/site/src/xServices/template/templateXService.ts +++ b/site/src/xServices/template/templateXService.ts @@ -2,6 +2,7 @@ import { displaySuccess } from "components/GlobalSnackbar/utils" import { t } from "i18next" import { assign, createMachine } from "xstate" import { + checkAuthorization, deleteTemplate, getTemplateByName, getTemplateDAUs, @@ -10,20 +11,22 @@ import { getTemplateVersions, } from "../../api/api" import { + AuthorizationResponse, Template, TemplateDAUsResponse, TemplateVersion, WorkspaceResource, } from "../../api/typesGenerated" -interface TemplateContext { +export interface TemplateContext { organizationId: string templateName: string template?: Template activeTemplateVersion?: TemplateVersion templateResources?: WorkspaceResource[] templateVersions?: TemplateVersion[] - templateDAUs: TemplateDAUsResponse + templateDAUs?: TemplateDAUsResponse + permissions?: AuthorizationResponse deleteTemplateError?: Error | unknown getTemplateError?: Error | unknown } @@ -33,6 +36,16 @@ type TemplateEvent = | { type: "CONFIRM_DELETE" } | { type: "CANCEL_DELETE" } +const getPermissionsToCheck = (templateId: string) => ({ + canUpdateTemplate: { + object: { + resource_type: "template", + resource_id: templateId, + }, + action: "update", + }, +}) + export const templateMachine = /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOhgBdyCoAVMVABwBt1ywBiCAe0JIIDcuAazAk0WPIVIUq+WvWaswCAV0ytcPANoAGALqJQDLrFxUehkAA9EAJgDsOkgGZbO2wE5bANg8BGABZnHR0AgBoQAE9EPx0-Eg9EpIAOe28-D28ggF9siPEcAmI+fDNcdCYASXwAMy4SLCp+MDpGFjYANTAAJ1MeMjBKagBBTCaWhXawLt7NfE4eUVURMQxCqRKyiuq6hrHcZtbFTp6+-AGhuVHxo6mZs5V8QXVzfF0DJBBjU1fLGwQAgF4t4AKzeWzOTIhEEhZIRaIIZI6EEkEIhZyg2xuEF+XL5NaSYoELZVWr1NhtJQAJTgXAArt1MHALrJ5JS2DTYPTGXAFrxlqICoTSMSqNsySQKccwJzuUzYCzqLdqbSGfLHs8NNp9JZvmULJ9-kDkiRks4QR50SDkh5nH5nPCYjpXKi0SDnObbeaAniQEKiiLSmLSbspXdTnMFTIlZMlPdI3ylk9hIKCQHNsGduTYydZjwo4NWcrc2dYBq1Fq3jrPnrfobEAFQqbQt5kiCcf4go6ECD7Ca0XFMskAmksr7-RtReUQ1xEyRYOQlKsJOmp+K6rqTPr8H9ELaTclkg5wQEzRidB5u-Y0q6QgFbAEsuCMuO0xsmFx0BBIOwACIAUQAGX-Gh-03H45ksBFrycGE7zcW1bD8e0In+Pw3G8EggT8M0-Fbc8PGSV8Vw2TAeBqXBulQahfzAJhBg4ABhAB5AA5AAxSoqQAWQAfQA4DQPA7ddwQZCMVRUF7Bw3svXsEFuz8MFMNtC8bRtHQzWHYj1mKMjako6i5Fo+i2HYRjhlYxigP4oCQLAmstzrUA0OcaSSHsZwbSUjwAl83zwiiGJGw8LCwTtU8vGQnI8j9N9im-UzqDnAUSEShjizAYTnOsRB7BHEglLwoqskCWxFJ8ewPJ0bx8tsC1IQhZwdOFNK6MGZKem6LhuhIY46iotrTImdkssciCDRcvcbVRJrwr8fLXHsRTYRIOCau8WqYRxIjfXwLhv3gT4J2KaM5Ey7LIPrAFyqChBLVvEJPGk2w21PFrVyDacsz2G4c2mCN+jOqBrgOEbpXjSavicq6prEhar1tR7IXSaSfVik7AxJH7GjBzLIfOWA6UweUjqMGGof+TzbEKsEMiRdwYUhbtkhw5HnHvXx0g8D7Jy+9d6lxw5-oJy7Kb3B07vsJDkekjwcSyWxeaJfmZ0lf7ZTVZlgcyzWeTJ6GJp3a7kOWu7YjcR6wQCEFAQfbxlaxzMJTDFUuS1hUiZJuADdrWHcoQVtMJxdtWfvaL0hWm2rfcRXHxBR2M2+l2NdVfWxeNuHbW7Xz+xCWJkm8ZE3LbRO1zV12S0jRVzpFwH8F9inM4D03u2Ux6sWvQj2wTjH4qd5PQzrvMG-nYnSYz0TQRNG3gnUyE+zNNv-EevDrUya9mr7kiVexlPRoJxujdE7O7r8gIO+dXtUkyMvVazSfrt8bt7xpgcFttC1nXsROPy-SBH5w1iO6MKwRghAkbH2BSUtHBrTRPeC8rhxKJ30hRKiNF2psEAS3ZCNMkR4R8MOWqfloEIntCvDmnoRzyUIvLRO6VWTYP+F4TCNt0g22REhV6pCYgEJIPVDENsPBpA9GkehmCAHjREtdVmmFPCKy2u6RwcJzaQicMOb0wQ3ALVxNvXSRAmExBUWQvOA5baqXlrbXIuQgA */ createMachine( @@ -62,6 +75,9 @@ export const templateMachine = getTemplateDAUs: { data: TemplateDAUsResponse } + getTemplatePermissions: { + data: AuthorizationResponse + } }, }, initial: "gettingTemplate", @@ -162,6 +178,23 @@ export const templateMachine = }, }, }, + templatePermissions: { + initial: "gettingTemplatePermissions", + states: { + gettingTemplatePermissions: { + invoke: { + src: "getTemplatePermissions", + onDone: { + target: "success", + actions: "assignPermissions", + }, + }, + }, + success: { + type: "final", + }, + }, + }, }, onDone: { target: "loaded", @@ -263,6 +296,14 @@ export const templateMachine = } return getTemplateDAUs(ctx.template.id) }, + getTemplatePermissions: (ctx) => { + if (!ctx.template) { + throw new Error("Template not loaded") + } + return checkAuthorization({ + checks: getPermissionsToCheck(ctx.template.id), + }) + }, }, actions: { assignTemplate: assign({ @@ -283,6 +324,9 @@ export const templateMachine = assignTemplateDAUs: assign({ templateDAUs: (_, event) => event.data, }), + assignPermissions: assign({ + permissions: (_, event) => event.data, + }), assignDeleteTemplateError: assign({ deleteTemplateError: (_, event) => event.data, }), diff --git a/t b/t new file mode 100644 index 0000000000000..a5221f80459be --- /dev/null +++ b/t @@ -0,0 +1,38 @@ +usage: git tag [-a | -s | -u ] [-f] [-m | -F ] + [] + or: git tag -d ... + or: git tag -l [-n[]] [--contains ] [--no-contains ] [--points-at ] + [--format=] [--[no-]merged []] [...] + or: git tag -v [--format=] ... + + -l, --list list tag names + -n[] print lines of each tag message + -d, --delete delete tags + -v, --verify verify tags + +Tag creation options + -a, --annotate annotated tag, needs a message + -m, --message + tag message + -F, --file read message from file + -e, --edit force edit of tag message + -s, --sign annotated and GPG-signed tag + --cleanup how to strip spaces and #comments from message + -u, --local-user + use another key to sign the tag + -f, --force replace the tag if exists + --create-reflog create a reflog + +Tag listing options + --column[=