mirror of https://github.com/coder/coder.git
feat: add template RBAC/groups (#4235)
This commit is contained in:
parent
2687e3db49
commit
3120c94c22
|
@ -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 ""
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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"`
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/open-policy-agent/opa/ast"
|
||||
|
@ -11,17 +12,10 @@ import (
|
|||
|
||||
func TestCompileQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts := ast.ParserOptions{
|
||||
AllFutureKeywords: true,
|
||||
}
|
||||
|
||||
t.Run("EmptyQuery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
must(ast.ParseBody("")),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t, ""))
|
||||
require.NoError(t, err, "compile empty")
|
||||
|
||||
require.Equal(t, "true", expression.RegoString(), "empty query is rego 'true'")
|
||||
|
@ -30,12 +24,7 @@ func TestCompileQuery(t *testing.T) {
|
|||
|
||||
t.Run("TrueQuery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
must(ast.ParseBody("true")),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t, "true"))
|
||||
require.NoError(t, err, "compile")
|
||||
|
||||
require.Equal(t, "true", expression.RegoString(), "true query is rego 'true'")
|
||||
|
@ -44,49 +33,118 @@ func TestCompileQuery(t *testing.T) {
|
|||
|
||||
t.Run("ACLIn", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list.allUsers`, opts),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t, `"*" in input.object.acl_group_list.allUsers`))
|
||||
require.NoError(t, err, "compile")
|
||||
|
||||
require.Equal(t, `internal.member_2("*", input.object.acl_group_list.allUsers)`, expression.RegoString(), "convert to internal_member")
|
||||
require.Equal(t, `group_acl->allUsers ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in")
|
||||
require.Equal(t, `group_acl->'allUsers' ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in")
|
||||
})
|
||||
|
||||
t.Run("Complex", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts),
|
||||
ast.MustParseBodyWithOpts(`input.object.org_owner in {"a", "b", "c"}`, opts),
|
||||
ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts),
|
||||
ast.MustParseBodyWithOpts(`"read" in input.object.acl_group_list.allUsers`, opts),
|
||||
ast.MustParseBodyWithOpts(`"read" in input.object.acl_user_list.me`, opts),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`input.object.org_owner != ""`,
|
||||
`input.object.org_owner in {"a", "b", "c"}`,
|
||||
`input.object.org_owner != ""`,
|
||||
`"read" in input.object.acl_group_list.allUsers`,
|
||||
`"read" in input.object.acl_user_list.me`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `(organization_id :: text != '' OR `+
|
||||
`organization_id :: text = ANY(ARRAY ['a','b','c']) OR `+
|
||||
`organization_id :: text != '' OR `+
|
||||
`group_acl->allUsers ? 'read' OR `+
|
||||
`user_acl->me ? 'read')`,
|
||||
`group_acl->'allUsers' ? 'read' OR `+
|
||||
`user_acl->'me' ? 'read')`,
|
||||
expression.SQLString(DefaultConfig()), "complex")
|
||||
})
|
||||
|
||||
t.Run("SetDereference", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list[input.object.org_owner]`, opts),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"*" in input.object.acl_group_list[input.object.org_owner]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `group_acl->organization_id :: text ? '*'`,
|
||||
expression.SQLString(DefaultConfig()), "set dereference")
|
||||
})
|
||||
|
||||
t.Run("JsonbLiteralDereference", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `group_acl->'4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75' ? '*'`,
|
||||
expression.SQLString(DefaultConfig()), "literal dereference")
|
||||
})
|
||||
|
||||
t.Run("NoACLColumns", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `true`,
|
||||
expression.SQLString(NoACLConfig()), "literal dereference")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvalQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("GroupACL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"read" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
|
||||
result := expression.Eval(Object{
|
||||
Owner: "not-me",
|
||||
OrgID: "random",
|
||||
Type: "workspace",
|
||||
ACLUserList: map[string][]Action{},
|
||||
ACLGroupList: map[string][]Action{
|
||||
"4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75": {"read"},
|
||||
},
|
||||
})
|
||||
require.True(t, result, "eval")
|
||||
})
|
||||
}
|
||||
|
||||
func partialQueries(t *testing.T, queries ...string) *PartialAuthorizer {
|
||||
opts := ast.ParserOptions{
|
||||
AllFutureKeywords: true,
|
||||
}
|
||||
|
||||
astQueries := make([]ast.Body, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
astQueries = append(astQueries, ast.MustParseBodyWithOpts(q, opts))
|
||||
}
|
||||
|
||||
prepareQueries := make([]rego.PreparedEvalQuery, 0, len(queries))
|
||||
for _, q := range astQueries {
|
||||
var prepped rego.PreparedEvalQuery
|
||||
var err error
|
||||
if q.String() == "" {
|
||||
prepped, err = rego.New(
|
||||
rego.Query("true"),
|
||||
).PrepareForEval(context.Background())
|
||||
} else {
|
||||
prepped, err = rego.New(
|
||||
rego.ParsedQuery(q),
|
||||
).PrepareForEval(context.Background())
|
||||
}
|
||||
require.NoError(t, err, "prepare query")
|
||||
prepareQueries = append(prepareQueries, prepped)
|
||||
}
|
||||
return &PartialAuthorizer{
|
||||
partialQueries: ®o.PartialQueries{
|
||||
Queries: astQueries,
|
||||
Support: []*ast.Module{},
|
||||
},
|
||||
preparedQueries: prepareQueries,
|
||||
input: nil,
|
||||
alwaysTrue: false,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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{},
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -360,7 +360,6 @@ func TestWorkspaceApplicationAuth(t *testing.T) {
|
|||
ResourceType: "application_connect",
|
||||
OwnerID: "me",
|
||||
OrganizationID: firstUser.OrganizationID.String(),
|
||||
ResourceID: uuid.NewString(),
|
||||
},
|
||||
Action: "create",
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
})
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
}),
|
||||
|
|
|
@ -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"])
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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())
|
||||
})
|
||||
}
|
|
@ -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
4
go.mod
|
@ -156,7 +156,9 @@ require (
|
|||
tailscale.com v1.30.0
|
||||
)
|
||||
|
||||
require github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5 // indirect
|
||||
require github.com/jmoiron/sqlx v1.3.5
|
||||
|
||||
require github.com/cakturk/go-netstat v0.0.0-20200220111822-e5b49efee7a5
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.0.0-rc.1 // indirect
|
||||
|
|
4
go.sum
4
go.sum
|
@ -694,6 +694,7 @@ github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8w
|
|||
github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||
|
@ -1101,6 +1102,8 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW
|
|||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks=
|
||||
github.com/jmoiron/sqlx v1.3.1/go.mod h1:2BljVx/86SuTyjE+aPYlHCTNvZrnJXghYGpNiXLBMCQ=
|
||||
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
|
||||
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
|
||||
github.com/joefitzgerald/rainbow-reporter v0.1.0/go.mod h1:481CNgqmVHQZzdIbN52CupLJyoVwB10FQ/IQlF1pdL8=
|
||||
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901 h1:rp+c0RAYOWj8l6qbCUTSiRLG/iKnW3K3/QfPPuSsBt4=
|
||||
github.com/joeshaw/multierror v0.0.0-20140124173710-69b34d4ec901/go.mod h1:Z86h9688Y0wesXCyonoVr47MasHilkuLMqGhRZ4Hpak=
|
||||
|
@ -1277,6 +1280,7 @@ github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vq
|
|||
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
|
||||
github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.10 h1:MLn+5bFRlWMGoSRmJour3CL1w/qL96mvipqpwQW/Sfk=
|
||||
github.com/mattn/go-sqlite3 v1.14.10/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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> => {
|
||||
|
|
|
@ -22,4 +22,5 @@ export enum FeatureNames {
|
|||
BrowserOnly = "browser_only",
|
||||
SCIM = "scim",
|
||||
WorkspaceQuota = "workspace_quota",
|
||||
RBAC = "rbac",
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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" },
|
||||
)
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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%" />
|
||||
) : (
|
||||
|
|
|
@ -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%",
|
||||
},
|
||||
}
|
||||
})
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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 = {}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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%",
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
|
@ -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
|
|
@ -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,
|
||||
}
|
|
@ -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
Loading…
Reference in New Issue