feat: add template RBAC/groups (#4235)

This commit is contained in:
Jon Ayers 2022-10-10 15:37:06 -05:00 committed by GitHub
parent 2687e3db49
commit 3120c94c22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
122 changed files with 8088 additions and 1062 deletions

View File

@ -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 ""
}

View File

@ -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
}

View File

@ -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,
},
},
}

View File

@ -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,

View File

@ -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 {

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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 {

View File

@ -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
}

View File

@ -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"`

View File

@ -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)

View File

@ -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,

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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);

View File

@ -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
}

View File

@ -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))

View File

@ -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/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/tabbed/pqtype"
"github.com/coder/coder/coderd/database"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"github.com/coder/coder/coderd/database/databasefake"
"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)

View File

@ -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))
})
}
}

View File

@ -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)
})
}

View File

@ -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))
})
}

View File

@ -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 {

View File

@ -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:

View File

@ -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)
}

View File

@ -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())
}

View File

@ -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,
})
}

View File

@ -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 {

View File

@ -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,
}
}

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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(&rego.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(&rego.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(&rego.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(&rego.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(&rego.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: &rego.PartialQueries{
Queries: astQueries,
Support: []*ast.Module{},
},
preparedQueries: prepareQueries,
input: nil,
alwaysTrue: false,
}
}

View File

@ -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{},

View File

@ -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
})

View File

@ -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
}

View File

@ -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) {

View File

@ -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{

View File

@ -360,7 +360,6 @@ func TestWorkspaceApplicationAuth(t *testing.T) {
ResourceType: "application_connect",
OwnerID: "me",
OrganizationID: firstUser.OrganizationID.String(),
ResourceID: uuid.NewString(),
},
Action: "create",
},

View File

@ -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)
}

View File

@ -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 {

113
codersdk/groups.go Normal file
View File

@ -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
}

View File

@ -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 {

View File

@ -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)

View File

@ -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"}},
},
},
})

View File

@ -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,

View File

@ -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)

View File

@ -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)

View File

@ -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)
})
}
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)
}

318
enterprise/coderd/groups.go Normal file
View File

@ -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,
}
}

View File

@ -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())
})
}

View File

@ -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 {

View File

@ -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),
}),

View File

@ -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"])
})
}

View File

@ -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)
})
}

View File

@ -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())
})
}

View File

@ -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)
})
}

4
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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={
<AuthAndFrame>
<TemplatePage />
<TemplateLayout>
<TemplateSummaryPage />
</TemplateLayout>
</AuthAndFrame>
}
/>
<Route
path="permissions"
element={
<AuthAndFrame>
<TemplateLayout>
<TemplatePermissionsPage />
</TemplateLayout>
</AuthAndFrame>
}
/>
@ -138,7 +166,9 @@ export const AppRouter: FC = () => {
index
element={
<AuthAndFrame>
<UsersPage />
<UsersLayout>
<UsersPage />
</UsersLayout>
</AuthAndFrame>
}
/>
@ -152,6 +182,43 @@ export const AppRouter: FC = () => {
/>
</Route>
<Route path="/groups">
<Route
index
element={
<AuthAndFrame>
<UsersLayout>
<GroupsPage />
</UsersLayout>
</AuthAndFrame>
}
/>
<Route
path="create"
element={
<RequireAuth>
<CreateGroupPage />
</RequireAuth>
}
/>
<Route
path=":groupId"
element={
<AuthAndFrame>
<GroupPage />
</AuthAndFrame>
}
/>
<Route
path=":groupId/settings"
element={
<RequireAuth>
<SettingsGroupPage />
</RequireAuth>
}
/>
</Route>
<Route path="/audit">
<Route
index

View File

@ -564,12 +564,67 @@ export const getTemplateDAUs = async (
return response.data
}
export const getTemplateACL = async (
templateId: string,
): Promise<TypesGen.TemplateACL> => {
const response = await axios.get(`/api/v2/templates/${templateId}/acl`)
return response.data
}
export const updateTemplateACL = async (
templateId: string,
data: TypesGen.UpdateTemplateACL,
): Promise<TypesGen.TemplateACL> => {
const response = await axios.patch(
`/api/v2/templates/${templateId}/acl`,
data,
)
return response.data
}
export const getApplicationsHost =
async (): Promise<TypesGen.GetAppHostResponse> => {
const response = await axios.get(`/api/v2/applications/host`)
return response.data
}
export const getGroups = async (
organizationId: string,
): Promise<TypesGen.Group[]> => {
const response = await axios.get(
`/api/v2/organizations/${organizationId}/groups`,
)
return response.data
}
export const createGroup = async (
organizationId: string,
data: TypesGen.CreateGroupRequest,
): Promise<TypesGen.Group> => {
const response = await axios.post(
`/api/v2/organizations/${organizationId}/groups`,
data,
)
return response.data
}
export const getGroup = async (groupId: string): Promise<TypesGen.Group> => {
const response = await axios.get(`/api/v2/groups/${groupId}`)
return response.data
}
export const patchGroup = async (
groupId: string,
data: TypesGen.PatchGroupRequest,
): Promise<TypesGen.Group> => {
const response = await axios.patch(`/api/v2/groups/${groupId}`, data)
return response.data
}
export const deleteGroup = async (groupId: string): Promise<void> => {
await axios.delete(`/api/v2/groups/${groupId}`)
}
export const getWorkspaceQuota = async (
userID: string,
): Promise<TypesGen.WorkspaceQuota> => {

View File

@ -22,4 +22,5 @@ export enum FeatureNames {
BrowserOnly = "browser_only",
SCIM = "scim",
WorkspaceQuota = "workspace_quota",
RBAC = "rbac",
}

View File

@ -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<string, TemplateRole>
readonly group_perms?: Record<string, TemplateRole>
}
// 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"

View File

@ -35,7 +35,7 @@ WithDropdown.args = {
export const WithCancel = Template.bind({})
WithCancel.args = {
primaryAction: <DisabledButton workspaceStatus="deleting" />,
primaryAction: <DisabledButton label="deleting" />,
secondaryActions: [],
canCancel: true,
handleCancel: action("cancel"),

View File

@ -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<React.PropsWithChildren<PaywallProps>> = (props) => {
const { message, description, cta } = props
const styles = useStyles()
return (
<Box className={styles.root}>
<div className={styles.header}>
<Typography variant="h5" className={styles.title}>
{message}
</Typography>
{description && (
<Typography
variant="body2"
color="textSecondary"
className={styles.description}
>
{description}
</Typography>
)}
</div>
{cta}
</Box>
)
}
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" },
)

View File

@ -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<PropsWithChildren> = ({ 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 <Loader />
}
if (templateState.matches("deleted")) {
return <Navigate to="/templates" />
}
const hasIcon = template.icon && template.icon !== ""
const createWorkspaceButton = (className?: string) => (
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/workspace`}
>
<Button className={className ?? ""} startIcon={<AddCircleOutline />}>
{Language.createButton}
</Button>
</Link>
)
const handleDeleteTemplate = () => {
templateSend("DELETE")
}
return (
<>
<Margins>
<PageHeader
actions={
<ChooseOne>
<Cond condition={templatePermissions.canUpdateTemplate}>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/settings`}
>
<Button variant="outlined" startIcon={<SettingsOutlined />}>
{Language.settingsButton}
</Button>
</Link>
<DropdownButton
primaryAction={createWorkspaceButton(styles.actionButton)}
secondaryActions={[
{
action: "delete",
button: (
<DeleteButton handleAction={handleDeleteTemplate} />
),
},
]}
canCancel={false}
/>
</Cond>
<Cond>{createWorkspaceButton()}</Cond>
</ChooseOne>
}
>
<Stack direction="row" spacing={3} className={styles.pageTitle}>
<div>
{hasIcon ? (
<div className={styles.iconWrapper}>
<img src={template.icon} alt="" />
</div>
) : (
<Avatar className={styles.avatar}>
{firstLetter(template.name)}
</Avatar>
)}
</div>
<div>
<PageHeaderTitle>{template.name}</PageHeaderTitle>
<PageHeaderSubtitle condensed>
{template.description === ""
? Language.noDescription
: template.description}
</PageHeaderSubtitle>
</div>
</Stack>
</PageHeader>
</Margins>
<div className={styles.tabs}>
<Margins>
<Stack direction="row" spacing={0.25}>
<NavLink
end
to={`/templates/${template.name}`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Summary
</NavLink>
<NavLink
to={`/templates/${template.name}/permissions`}
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Permissions
</NavLink>
</Stack>
</Margins>
</div>
<Margins>
<TemplateLayoutContext.Provider
value={{ permissions, context: templateState.context }}
>
{children}
</TemplateLayoutContext.Provider>
</Margins>
<DeleteDialog
isOpen={templateState.matches("confirmingDelete")}
confirmLoading={templateState.matches("deleting")}
onConfirm={() => {
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",
},
},
}
})

View File

@ -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<UserAutocompleteProps> = ({
value,
onChange,
className,
label,
inputMargin,
inputStyles,
@ -31,7 +34,6 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
const [searchState, sendSearch] = useMachine(searchUserMachine)
const { searchResults } = searchState.context
const [selectedValue, setSelectedValue] = useState<User | null>(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<UserAutocompleteProps> = ({
return (
<Autocomplete
value={selectedValue}
value={value}
id="user-autocomplete"
open={isAutocompleteOpen}
onOpen={() => {
@ -65,7 +67,6 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
sendSearch("CLEAR_RESULTS")
}
setSelectedValue(newValue)
onChange(newValue)
}}
getOptionSelected={(option: User, value: User) =>
@ -90,7 +91,7 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
)}
options={searchResults}
loading={searchState.matches("searching")}
className={styles.autocomplete}
className={combineClasses([styles.autocomplete, className])}
renderInput={(params) => (
<TextField
{...params}
@ -103,11 +104,7 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
...params.InputProps,
onChange: handleFilterChange,
startAdornment: (
<>
{showAvatar && selectedValue && (
<AutocompleteAvatar user={selectedValue} />
)}
</>
<>{showAvatar && value && <AutocompleteAvatar user={value} />}</>
),
endAdornment: (
<>
@ -156,3 +153,28 @@ export const useStyles = makeStyles<Theme, styleProps>((theme) => {
},
}
})
export const UserAutocompleteInline: React.FC<UserAutocompleteProps> = (
props,
) => {
const style = useInlineStyle()
return <UserAutocomplete {...props} className={style.inline} />
}
export const useInlineStyle = makeStyles(() => {
return {
inline: {
width: "300px",
"& .MuiFormControl-root": {
margin: 0,
},
"& .MuiInputBase-root": {
// Match button small height
height: 36,
},
},
}
})

View File

@ -14,7 +14,7 @@ export const UserAvatar: FC<UserAvatarProps> = ({
avatarURL,
}) => {
return (
<Avatar className={className}>
<Avatar className={className} title={username}>
{avatarURL ? (
<img alt={`${username}'s Avatar`} src={avatarURL} width="100%" />
) : (

View File

@ -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<HTMLInputElement>) => {
sendSearch("SEARCH", { query: event.target.value })
},
500,
)
return (
<Autocomplete
value={value}
id="user-or-group-autocomplete"
open={isAutocompleteOpen}
onOpen={() => {
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 (
<AvatarData
title={isOptionGroup ? option.name : option.username}
subtitle={
isOptionGroup ? `${option.members.length} members` : option.email
}
highlightTitle
avatar={
!isOptionGroup && option.avatar_url ? (
<img
className={styles.avatar}
alt={`${option.username}'s Avatar`}
src={option.avatar_url}
/>
) : null
}
/>
)
}}
options={options}
loading={searchState.matches("searching")}
className={styles.autocomplete}
renderInput={(params) => (
<TextField
{...params}
margin="none"
variant="outlined"
placeholder="Search for user or group"
InputProps={{
...params.InputProps,
onChange: handleFilterChange,
endAdornment: (
<>
{searchState.matches("searching") ? (
<CircularProgress size={16} />
) : 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%",
},
}
})

View File

@ -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<PropsWithChildren> = ({ children }) => {
const styles = useStyles()
const { createUser: canCreateUser, createGroup: canCreateGroup } =
usePermissions()
const navigate = useNavigate()
const { rbac: isRBACEnabled } = useFeatureVisibility()
return (
<>
<Margins>
<PageHeader
actions={
<>
{canCreateUser && (
<Button
onClick={() => {
navigate("/users/create")
}}
startIcon={<PersonAdd />}
>
Create user
</Button>
)}
{canCreateGroup && isRBACEnabled && (
<Link
underline="none"
component={RouterLink}
to="/groups/create"
>
<Button startIcon={<GroupAdd />}>Create group</Button>
</Link>
)}
</>
}
>
<PageHeaderTitle>Users</PageHeaderTitle>
</PageHeader>
</Margins>
<div className={styles.tabs}>
<Margins>
<Stack direction="row" spacing={0.25}>
<NavLink
end
to="/users"
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Users
</NavLink>
<NavLink
to="/groups"
className={({ isActive }) =>
combineClasses([
styles.tabItem,
isActive ? styles.tabItemActive : undefined,
])
}
>
Groups
</NavLink>
</Stack>
</Margins>
</div>
<Margins>{children}</Margins>
</>
)
}
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",
},
},
}
})

View File

@ -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<string, boolean> => {
const xServices = useContext(XServiceContext)
return useSelector(xServices.entitlementsXService, selectFeatureVisibility)
}

16
site/src/hooks/useMe.ts Normal file
View File

@ -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
}

View File

@ -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<AuthContext["permissions"]> => {
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
}

View File

@ -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 (
<>
<Helmet>
<title>{pageTitle("Create Group")}</title>
</Helmet>
<CreateGroupPageView
onSubmit={(data) => {
sendCreateEvent({
type: "CREATE",
data,
})
}}
formErrors={createGroupFormErrors}
isLoading={createState.matches("creatingGroup")}
/>
</>
)
}
export default CreateGroupPage

View File

@ -0,0 +1,17 @@
import { Story } from "@storybook/react"
import {
CreateGroupPageView,
CreateGroupPageViewProps,
} from "./CreateGroupPageView"
export default {
title: "pages/CreateGroupPageView",
component: CreateGroupPageView,
}
const Template: Story<CreateGroupPageViewProps> = (
args: CreateGroupPageViewProps,
) => <CreateGroupPageView {...args} />
export const Example = Template.bind({})
Example.args = {}

View File

@ -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<CreateGroupPageViewProps> = ({
onSubmit,
formErrors,
isLoading,
}) => {
const navigate = useNavigate()
const form = useFormik<CreateGroupRequest>({
initialValues: {
name: "",
},
validationSchema,
onSubmit,
})
const getFieldHelpers = getFormHelpers<CreateGroupRequest>(form, formErrors)
const onCancel = () => navigate("/groups")
return (
<Margins>
<FullPageForm title="Create group" onCancel={onCancel}>
<form onSubmit={form.handleSubmit}>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoComplete="name"
autoFocus
fullWidth
label="Name"
variant="outlined"
/>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</form>
</FullPageForm>
</Margins>
)
}
export default CreateGroupPageView

View File

@ -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<User | null>(null)
const resetValues = () => {
setSelectedUser(null)
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
if (selectedUser) {
onSubmit(selectedUser, resetValues)
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserAutocompleteInline
value={selectedUser}
onChange={(newValue) => {
setSelectedUser(newValue)
}}
/>
<LoadingButton
disabled={!selectedUser}
type="submit"
size="small"
startIcon={<PersonAdd />}
loading={isLoading}
>
Add user
</LoadingButton>
</Stack>
</form>
)
}
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 (
<>
<Helmet>
<title>{pageTitle(group?.name ?? "Loading...")}</title>
</Helmet>
<ChooseOne>
<Cond condition={isLoading}>
<Loader />
</Cond>
<Cond>
<Margins>
<PageHeader
actions={
<Maybe condition={canUpdateGroup}>
<Link to="settings" underline="none" component={RouterLink}>
<Button startIcon={<SettingsOutlined />}>Settings</Button>
</Link>
<Button
onClick={() => {
send("DELETE")
}}
startIcon={<DeleteOutline />}
>
Delete
</Button>
</Maybe>
}
>
<PageHeaderTitle>{group?.name}</PageHeaderTitle>
<PageHeaderSubtitle>
{group?.members.length} members
</PageHeaderSubtitle>
</PageHeader>
<Stack spacing={2.5}>
<Maybe condition={canUpdateGroup}>
<AddGroupMember
isLoading={state.matches("addingMember")}
onSubmit={(user, reset) => {
send({
type: "ADD_MEMBER",
userId: user.id,
callback: reset,
})
}}
/>
</Maybe>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="99%">User</TableCell>
<TableCell width="1%"></TableCell>
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={Boolean(group?.members.length === 0)}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
</TableRow>
</Cond>
<Cond>
{group?.members.map((member) => (
<TableRow key={member.id}>
<TableCell width="99%">
<AvatarData
title={member.username}
subtitle={member.email}
highlightTitle
/>
</TableCell>
<TableCell width="1%">
<Maybe condition={canUpdateGroup}>
<TableRowMenu
data={member}
menuItems={[
{
label: "Remove",
onClick: () => {
send({
type: "REMOVE_MEMBER",
userId: member.id,
})
},
},
]}
/>
</Maybe>
</TableCell>
</TableRow>
))}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</Stack>
</Margins>
</Cond>
</ChooseOne>
{group && (
<DeleteDialog
isOpen={state.matches("confirmingDelete")}
confirmLoading={state.matches("deleting")}
name={group.name}
entity="group"
onConfirm={() => {
send("CONFIRM_DELETE")
}}
onCancel={() => {
send("CANCEL_DELETE")
}}
/>
)}
</>
)
}
export default GroupPage

View File

@ -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 (
<>
<Helmet>
<title>{pageTitle("Groups")}</title>
</Helmet>
<GroupsPageView
groups={groups}
canCreateGroup={canCreateGroup}
isRBACEnabled={isRBACEnabled}
/>
</>
)
}
export default GroupsPage

View File

@ -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<GroupsPageViewProps> = (args: GroupsPageViewProps) => (
<GroupsPageView {...args} />
)
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,
}

View File

@ -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<GroupsPageViewProps> = ({
groups,
canCreateGroup,
isRBACEnabled,
}) => {
const isLoading = Boolean(groups === undefined)
const isEmpty = Boolean(groups && groups.length === 0)
const navigate = useNavigate()
const styles = useStyles()
return (
<>
<ChooseOne>
<Cond condition={!isRBACEnabled}>
<Paywall
message="User groups"
description="Organize the users into groups and manage their permissions. To use this feature, you have to upgrade your account."
cta={
<Stack direction="row" alignItems="center">
<Link
underline="none"
href="https://coder.com/docs/coder-oss/latest/admin/upgrade"
target="_blank"
rel="noreferrer"
>
<Button size="small" startIcon={<ArrowRightAltOutlined />}>
See how to upgrade
</Button>
</Link>
<Link
underline="none"
href="https://coder.com/docs/coder-oss/latest/admin/upgrade"
target="_blank"
rel="noreferrer"
>
Read the docs
</Link>
</Stack>
}
/>
</Cond>
<Cond>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="50%">Name</TableCell>
<TableCell width="49%">Users</TableCell>
<TableCell width="1%"></TableCell>
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No groups yet"
description={
canCreateGroup
? "Create your first group"
: "You don't have permission to create a group"
}
cta={
canCreateGroup && (
<Link
underline="none"
component={RouterLink}
to="/groups/create"
>
<Button startIcon={<AddCircleOutline />}>
Create group
</Button>
</Link>
)
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>
{groups?.map((group) => {
const groupPageLink = `/groups/${group.id}`
return (
<TableRow
hover
key={group.id}
data-testid={`group-${group.id}`}
tabIndex={0}
onClick={() => {
navigate(groupPageLink)
}}
onKeyDown={(event) => {
if (event.key === "Enter") {
navigate(groupPageLink)
}
}}
className={styles.clickableTableRow}
>
<TableCell>
<AvatarData
title={group.name}
subtitle={`${group.members.length} members`}
highlightTitle
/>
</TableCell>
<TableCell>
{group.members.length === 0 && "-"}
<AvatarGroup>
{group.members.map((member) => (
<UserAvatar
key={member.username}
username={member.username}
avatarURL={member.avatar_url}
/>
))}
</AvatarGroup>
</TableCell>
<TableCell>
<div className={styles.arrowCell}>
<KeyboardArrowRight
className={styles.arrowRight}
/>
</div>
</TableCell>
</TableRow>
)
})}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</Cond>
</ChooseOne>
</>
)
}
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

View File

@ -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 (
<>
<Helmet>
<title>{pageTitle("Settings Group")}</title>
</Helmet>
<SettingsGroupPageView
onCancel={navigateToGroup}
onSubmit={(data) => {
sendEditEvent({ type: "UPDATE", data })
}}
group={group}
formErrors={updateGroupFormErrors}
isLoading={editState.matches("loading")}
isUpdating={editState.matches("updating")}
/>
</>
)
}
export default SettingsGroupPage

View File

@ -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<SettingsGroupPageViewProps> = (
args: SettingsGroupPageViewProps,
) => <SettingsGroupPageView {...args} />
export const Example = Template.bind({})
Example.args = {
group: MockGroup,
isLoading: false,
}

View File

@ -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<FormData>({
initialValues: {
name: group.name,
},
validationSchema,
onSubmit,
})
const getFieldHelpers = getFormHelpers<FormData>(form, errors)
return (
<FullPageForm title="Group settings" onCancel={onCancel}>
<form onSubmit={form.handleSubmit}>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoComplete="name"
autoFocus
fullWidth
label="Name"
variant="outlined"
/>
<FormFooter onCancel={onCancel} isLoading={isLoading} />
</form>
</FullPageForm>
)
}
export type SettingsGroupPageViewProps = {
onCancel: () => void
onSubmit: (data: FormData) => void
group: Group | undefined
formErrors: unknown
isLoading: boolean
isUpdating: boolean
}
export const SettingsGroupPageView: React.FC<SettingsGroupPageViewProps> = ({
onCancel,
onSubmit,
group,
formErrors,
isLoading,
isUpdating,
}) => {
return (
<ChooseOne>
<Cond condition={isLoading}>
<FullScreenLoader />
</Cond>
<Cond>
<Margins>
<UpdateGroupForm
group={group as Group}
onCancel={onCancel}
errors={formErrors}
isLoading={isUpdating}
onSubmit={onSubmit}
/>
</Margins>
</Cond>
</ChooseOne>
)
}
export default SettingsGroupPageView

View File

@ -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<React.PropsWithChildren<unknown>> = () => {
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 (
<Margins>
<div className={styles.errorBox}>
<AlertBanner severity="error" error={getTemplateError} />
</div>
</Margins>
)
}
if (isLoading) {
return <Loader />
}
if (templateState.matches("deleted")) {
return <Navigate to="/templates" />
}
return (
<>
<Helmet>
<title>{pageTitle(`${template.name} · Template`)}</title>
</Helmet>
<TemplatePageView
template={template}
activeTemplateVersion={activeTemplateVersion}
templateResources={templateResources}
templateVersions={templateVersions}
templateDAUs={templateDAUs}
canDeleteTemplate={permissions.deleteTemplates}
handleDeleteTemplate={handleDeleteTemplate}
deleteTemplateError={deleteTemplateError}
/>
<DeleteDialog
isOpen={templateState.matches("confirmingDelete")}
confirmLoading={templateState.matches("deleting")}
entity="template"
name={template.name}
onConfirm={() => {
templateSend("CONFIRM_DELETE")
}}
onCancel={() => {
templateSend("CANCEL_DELETE")
}}
/>
</>
)
}
const useStyles = makeStyles((theme) => ({
errorBox: {
padding: theme.spacing(3),
},
}))
export default TemplatePage

View File

@ -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<TemplatePageViewProps>
> = ({
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) && (
<AlertBanner severity="error" error={deleteTemplateError} dismissible />
)
const getStartedResources = (resources: WorkspaceResource[]) => {
return resources.filter(
(resource) => resource.workspace_transition === "start",
)
}
const createWorkspaceButton = (className?: string) => (
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/workspace`}
>
<Button className={className ?? ""} startIcon={<AddCircleOutline />}>
{Language.createButton}
</Button>
</Link>
)
return (
<Margins>
<>
<PageHeader
actions={
<>
<Link
underline="none"
component={RouterLink}
to={`/templates/${template.name}/settings`}
>
<Button variant="outlined" startIcon={<SettingsOutlined />}>
{Language.settingsButton}
</Button>
</Link>
{canDeleteTemplate ? (
<DropdownButton
primaryAction={createWorkspaceButton(styles.actionButton)}
secondaryActions={[
{
action: "delete",
button: (
<DeleteButton
handleAction={() => handleDeleteTemplate(template.id)}
/>
),
},
]}
canCancel={false}
/>
) : (
createWorkspaceButton()
)}
</>
}
>
<Stack direction="row" spacing={3} className={styles.pageTitle}>
<div>
{hasIcon ? (
<div className={styles.iconWrapper}>
<img src={template.icon} alt="" />
</div>
) : (
<Avatar className={styles.avatar}>
{firstLetter(template.name)}
</Avatar>
)}
</div>
<div>
<PageHeaderTitle>{template.name}</PageHeaderTitle>
<PageHeaderSubtitle condensed>
{template.description === ""
? Language.noDescription
: template.description}
</PageHeaderSubtitle>
</div>
</Stack>
</PageHeader>
<Stack spacing={2.5}>
{deleteError}
{templateDAUs && <DAUChart templateDAUs={templateDAUs} />}
<TemplateStats
template={template}
activeVersion={activeTemplateVersion}
/>
<TemplateResourcesTable
resources={getStartedResources(templateResources)}
/>
<WorkspaceSection
title={Language.readmeTitle}
contentsProps={{ className: styles.readmeContents }}
>
<div className={styles.markdownWrapper}>
<Markdown>{readme.body}</Markdown>
</div>
</WorkspaceSection>
<WorkspaceSection
title={Language.versionsTitle}
contentsProps={{ className: styles.versionsTableContents }}
>
<VersionsTable versions={templateVersions} />
</WorkspaceSection>
</Stack>
</>
</Margins>
)
}
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%",
},
},
}
})

View File

@ -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<unknown>
> = () => {
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 (
<>
<Helmet>
<title>{pageTitle(`${template.name} · Permissions`)}</title>
</Helmet>
<ChooseOne>
<Cond condition={!isRBACEnabled}>
<Paywall
message="Template permissions"
description="Manage your template permissions to allow users or groups to view or admin the template. To use this feature, you have to upgrade your account."
cta={
<Stack direction="row" alignItems="center">
<Link
underline="none"
href="https://coder.com/docs/coder-oss/latest/admin/upgrade"
target="_blank"
rel="noreferrer"
>
<Button size="small" startIcon={<ArrowRightAltOutlined />}>
See how to upgrade
</Button>
</Link>
<Link
underline="none"
href="https://coder.com/docs/coder-oss/latest/admin/upgrade"
target="_blank"
rel="noreferrer"
>
Read the docs
</Link>
</Stack>
}
/>
</Cond>
<Cond>
<TemplatePermissionsPageView
organizationId={organizationId}
templateACL={templateACL}
canUpdatePermissions={permissions.canUpdateTemplate}
onAddUser={(user, role, reset) => {
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 })
}}
/>
</Cond>
</ChooseOne>
</>
)
}
export default TemplatePermissionsPage

View File

@ -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<TemplatePermissionsPageViewProps> = (
args: TemplatePermissionsPageViewProps,
) => <TemplatePermissionsPageView {...args} />
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,
}

View File

@ -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<AddTemplateUserOrGroupProps> = ({
isLoading,
onSubmit,
organizationId,
templateACL,
}) => {
const styles = useStyles()
const [selectedOption, setSelectedOption] =
useState<UserOrGroupAutocompleteValue>(null)
const [selectedRole, setSelectedRole] = useState<TemplateRole>("view")
const excludeFromAutocomplete = templateACL
? [...templateACL.group, ...templateACL.users]
: []
const resetValues = () => {
setSelectedOption(null)
setSelectedRole("view")
}
return (
<form
onSubmit={(e) => {
e.preventDefault()
if (selectedOption && selectedRole) {
onSubmit(
{
...selectedOption,
role: selectedRole,
},
selectedRole,
resetValues,
)
}
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<UserOrGroupAutocomplete
exclude={excludeFromAutocomplete}
organizationId={organizationId}
value={selectedOption}
onChange={(newValue) => {
setSelectedOption(newValue)
}}
/>
<Select
defaultValue="view"
variant="outlined"
className={styles.select}
disabled={isLoading}
onChange={(event) => {
setSelectedRole(event.target.value as TemplateRole)
}}
>
<MenuItem key="view" value="view">
View
</MenuItem>
<MenuItem key="admin" value="admin">
Admin
</MenuItem>
</Select>
<LoadingButton
disabled={!selectedRole || !selectedOption}
type="submit"
size="small"
startIcon={<PersonAdd />}
loading={isLoading}
>
Add member
</LoadingButton>
</Stack>
</form>
)
}
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<TemplatePermissionsPageViewProps>
> = ({
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 (
<Stack spacing={2.5}>
<Maybe condition={canUpdatePermissions}>
<AddTemplateUserOrGroup
templateACL={templateACL}
organizationId={organizationId}
isLoading={isAddingUser || isAddingGroup}
onSubmit={(value, role, resetAutocomplete) =>
"members" in value
? onAddGroup(value, role, resetAutocomplete)
: onAddUser(value, role, resetAutocomplete)
}
/>
</Maybe>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell width="60%">Member</TableCell>
<TableCell width="40%">Role</TableCell>
<TableCell width="1%" />
</TableRow>
</TableHead>
<TableBody>
<ChooseOne>
<Cond condition={!templateACL}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
</TableRow>
</Cond>
<Cond>
{templateACL?.group.map((group) => (
<TableRow key={group.id}>
<TableCell>
<AvatarData
title={group.name}
subtitle={`${group.members.length} members`}
highlightTitle
/>
</TableCell>
<TableCell>
<ChooseOne>
<Cond condition={canUpdatePermissions}>
<Select
value={group.role}
variant="outlined"
className={styles.updateSelect}
disabled={
updatingGroup && updatingGroup.id === group.id
}
onChange={(event) => {
onUpdateGroup(
group,
event.target.value as TemplateRole,
)
}}
>
<MenuItem key="view" value="view">
View
</MenuItem>
<MenuItem key="admin" value="admin">
Admin
</MenuItem>
</Select>
</Cond>
<Cond>
<div className={styles.role}>{group.role}</div>
</Cond>
</ChooseOne>
</TableCell>
<TableCell>
<Maybe condition={canUpdatePermissions}>
<TableRowMenu
data={group}
menuItems={[
{
label: "Remove",
onClick: () => onRemoveGroup(group),
},
]}
/>
</Maybe>
</TableCell>
</TableRow>
))}
{templateACL?.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<AvatarData
title={user.username}
subtitle={user.email}
highlightTitle
avatar={
user.avatar_url ? (
<img
className={styles.avatar}
alt={`${user.username}'s Avatar`}
src={user.avatar_url}
/>
) : null
}
/>
</TableCell>
<TableCell>
<ChooseOne>
<Cond condition={canUpdatePermissions}>
<Select
value={user.role}
variant="outlined"
className={styles.updateSelect}
disabled={
updatingUser && updatingUser.id === user.id
}
onChange={(event) => {
onUpdateUser(
user,
event.target.value as TemplateRole,
)
}}
>
<MenuItem key="view" value="view">
View
</MenuItem>
<MenuItem key="admin" value="admin">
Admin
</MenuItem>
</Select>
</Cond>
<Cond>
<div className={styles.role}>{user.role}</div>
</Cond>
</ChooseOne>
</TableCell>
<TableCell>
<Maybe condition={canUpdatePermissions}>
<TableRowMenu
data={user}
menuItems={[
{
label: "Remove",
onClick: () => onRemoveUser(user),
},
]}
/>
</Maybe>
</TableCell>
</TableRow>
))}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</TableContainer>
</Stack>
)
}
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",
},
}
})

Some files were not shown because too many files have changed in this diff Show More