mirror of https://github.com/coder/coder.git
feat!: drop reading other 'user' permission (#8650)
* feat: drop reading other 'user' permission Members of the platform can no longer read or list other users. Resources that have "created_by" or "initiated_by" still retain user context, but only include username and avatar url. Attempting to read a user found via those means will result in a 404. * Hide /users page for regular users * make groups a privledged endpoint * Permissions page for template perms * Admin for a given template enables an endpoint for listing users/groups.
This commit is contained in:
parent
8649a10441
commit
2089006fbc
|
@ -2109,6 +2109,44 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/acl/available": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Enterprise"
|
||||
],
|
||||
"summary": "Get template available acl users/groups",
|
||||
"operationId": "get-template-available-acl-usersgroups",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Template ID",
|
||||
"name": "template",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.ACLAvailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/daus": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -6619,6 +6657,23 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ACLAvailable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.Group"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.User"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.APIKey": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
@ -1841,6 +1841,40 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/acl/available": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"CoderSessionToken": []
|
||||
}
|
||||
],
|
||||
"produces": ["application/json"],
|
||||
"tags": ["Enterprise"],
|
||||
"summary": "Get template available acl users/groups",
|
||||
"operationId": "get-template-available-acl-usersgroups",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Template ID",
|
||||
"name": "template",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.ACLAvailable"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/templates/{template}/daus": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
@ -5879,6 +5913,23 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"codersdk.ACLAvailable": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.Group"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/codersdk.User"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"codersdk.APIKey": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
@ -103,7 +103,7 @@ func TestCheckPermissions(t *testing.T) {
|
|||
Client: orgAdminClient,
|
||||
UserID: orgAdminUser.ID,
|
||||
Check: map[string]bool{
|
||||
readAllUsers: true,
|
||||
readAllUsers: false,
|
||||
readMyself: true,
|
||||
readOwnWorkspaces: true,
|
||||
readOrgWorkspaces: true,
|
||||
|
@ -115,7 +115,7 @@ func TestCheckPermissions(t *testing.T) {
|
|||
Client: memberClient,
|
||||
UserID: memberUser.ID,
|
||||
Check: map[string]bool{
|
||||
readAllUsers: true,
|
||||
readAllUsers: false,
|
||||
readMyself: true,
|
||||
readOwnWorkspaces: true,
|
||||
readOrgWorkspaces: false,
|
||||
|
|
|
@ -116,7 +116,7 @@ func (RBACAsserter) convertObjects(t *testing.T, objs ...interface{}) []rbac.Obj
|
|||
case codersdk.TemplateVersion:
|
||||
robj = rbac.ResourceTemplate.InOrg(obj.OrganizationID)
|
||||
case codersdk.User:
|
||||
robj = rbac.ResourceUser.WithID(obj.ID)
|
||||
robj = rbac.ResourceUserObject(obj.ID)
|
||||
case codersdk.Workspace:
|
||||
robj = rbac.ResourceWorkspace.WithID(obj.ID).InOrg(obj.OrganizationID).WithOwner(obj.OwnerID.String())
|
||||
default:
|
||||
|
|
|
@ -1108,7 +1108,7 @@ func (q *querier) GetProvisionerLogsAfterID(ctx context.Context, arg database.Ge
|
|||
}
|
||||
|
||||
func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(userID))
|
||||
err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUserObject(userID))
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
@ -1116,7 +1116,7 @@ func (q *querier) GetQuotaAllowanceForUser(ctx context.Context, userID uuid.UUID
|
|||
}
|
||||
|
||||
func (q *querier) GetQuotaConsumedForUser(ctx context.Context, userID uuid.UUID) (int64, error) {
|
||||
err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(userID))
|
||||
err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUserObject(userID))
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
@ -1390,7 +1390,7 @@ func (q *querier) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]
|
|||
// itself.
|
||||
func (q *querier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]database.User, error) {
|
||||
for _, uid := range ids {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUser.WithID(uid)); err != nil {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, rbac.ResourceUserObject(uid)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
@ -1899,7 +1899,7 @@ func (q *querier) InsertUserGroupsByName(ctx context.Context, arg database.Inser
|
|||
|
||||
// TODO: Should this be in system.go?
|
||||
func (q *querier) InsertUserLink(ctx context.Context, arg database.InsertUserLinkParams) (database.UserLink, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceUser.WithID(arg.UserID)); err != nil {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, rbac.ResourceUserObject(arg.UserID)); err != nil {
|
||||
return database.UserLink{}, err
|
||||
}
|
||||
return q.db.InsertUserLink(ctx, arg)
|
||||
|
@ -2614,24 +2614,24 @@ func (q *querier) GetAuthorizedTemplates(ctx context.Context, arg database.GetTe
|
|||
}
|
||||
|
||||
func (q *querier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateGroup, error) {
|
||||
// An actor is authorized to read template group roles if they are authorized to read the template.
|
||||
// An actor is authorized to read template group roles if they are authorized to update the template.
|
||||
template, err := q.db.GetTemplateByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, template); err != nil {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetTemplateGroupRoles(ctx, id)
|
||||
}
|
||||
|
||||
func (q *querier) GetTemplateUserRoles(ctx context.Context, id uuid.UUID) ([]database.TemplateUser, error) {
|
||||
// An actor is authorized to query template user roles if they are authorized to read the template.
|
||||
// An actor is authorized to query template user roles if they are authorized to update the template.
|
||||
template, err := q.db.GetTemplateByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := q.authorizeContext(ctx, rbac.ActionRead, template); err != nil {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionUpdate, template); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.GetTemplateUserRoles(ctx, id)
|
||||
|
|
|
@ -521,7 +521,7 @@ func (s *MethodTestSuite) TestOrganization() {
|
|||
ma := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: oa.ID})
|
||||
mb := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{OrganizationID: ob.ID})
|
||||
check.Args([]uuid.UUID{ma.UserID, mb.UserID}).
|
||||
Asserts(rbac.ResourceUser.WithID(ma.UserID), rbac.ActionRead, rbac.ResourceUser.WithID(mb.UserID), rbac.ActionRead)
|
||||
Asserts(rbac.ResourceUserObject(ma.UserID), rbac.ActionRead, rbac.ResourceUserObject(mb.UserID), rbac.ActionRead)
|
||||
}))
|
||||
s.Run("GetOrganizationMemberByUserID", s.Subtest(func(db database.Store, check *expects) {
|
||||
mem := dbgen.OrganizationMember(s.T(), db, database.OrganizationMember{})
|
||||
|
@ -698,11 +698,11 @@ func (s *MethodTestSuite) TestTemplate() {
|
|||
}))
|
||||
s.Run("GetTemplateGroupRoles", s.Subtest(func(db database.Store, check *expects) {
|
||||
t1 := dbgen.Template(s.T(), db, database.Template{})
|
||||
check.Args(t1.ID).Asserts(t1, rbac.ActionRead)
|
||||
check.Args(t1.ID).Asserts(t1, rbac.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetTemplateUserRoles", s.Subtest(func(db database.Store, check *expects) {
|
||||
t1 := dbgen.Template(s.T(), db, database.Template{})
|
||||
check.Args(t1.ID).Asserts(t1, rbac.ActionRead)
|
||||
check.Args(t1.ID).Asserts(t1, rbac.ActionUpdate)
|
||||
}))
|
||||
s.Run("GetTemplateVersionByID", s.Subtest(func(db database.Store, check *expects) {
|
||||
t1 := dbgen.Template(s.T(), db, database.Template{})
|
||||
|
|
|
@ -194,14 +194,15 @@ func (w Workspace) LockedRBAC() rbac.Object {
|
|||
func (m OrganizationMember) RBACObject() rbac.Object {
|
||||
return rbac.ResourceOrganizationMember.
|
||||
WithID(m.UserID).
|
||||
InOrg(m.OrganizationID)
|
||||
InOrg(m.OrganizationID).
|
||||
WithOwner(m.UserID.String())
|
||||
}
|
||||
|
||||
func (m GetOrganizationIDsByMemberIDsRow) RBACObject() rbac.Object {
|
||||
// TODO: This feels incorrect as we are really returning a list of orgmembers.
|
||||
// This return type should be refactored to return a list of orgmembers, not this
|
||||
// special type.
|
||||
return rbac.ResourceUser.WithID(m.UserID)
|
||||
return rbac.ResourceUserObject(m.UserID)
|
||||
}
|
||||
|
||||
func (o Organization) RBACObject() rbac.Object {
|
||||
|
@ -233,7 +234,7 @@ func (f File) RBACObject() rbac.Object {
|
|||
// If you are trying to get the RBAC object for the UserData, use
|
||||
// u.UserDataRBACObject() instead.
|
||||
func (u User) RBACObject() rbac.Object {
|
||||
return rbac.ResourceUser.WithID(u.ID)
|
||||
return rbac.ResourceUserObject(u.ID)
|
||||
}
|
||||
|
||||
func (u User) UserDataRBACObject() rbac.Object {
|
||||
|
@ -241,7 +242,7 @@ func (u User) UserDataRBACObject() rbac.Object {
|
|||
}
|
||||
|
||||
func (u GetUsersRow) RBACObject() rbac.Object {
|
||||
return rbac.ResourceUser.WithID(u.ID)
|
||||
return rbac.ResourceUserObject(u.ID)
|
||||
}
|
||||
|
||||
func (u GitSSHKey) RBACObject() rbac.Object {
|
||||
|
|
|
@ -195,6 +195,11 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
// ResourceUserObject is a helper function to create a user object for authz checks.
|
||||
func ResourceUserObject(userID uuid.UUID) Object {
|
||||
return ResourceUser.WithID(userID).WithOwner(userID.String())
|
||||
}
|
||||
|
||||
// Object is used to create objects for authz checks when you have none in
|
||||
// hand to run the check on.
|
||||
// An example is if you want to list all workspaces, you can create a Object
|
||||
|
|
|
@ -259,7 +259,7 @@ neq(input.object.owner, "");
|
|||
},
|
||||
VariableConverter: regosql.UserConverter(),
|
||||
ExpectedSQL: p(
|
||||
p("'10d03e62-7703-4df5-a358-4f76577d4e2f' = ''") + " AND " + p("'' != ''") + " AND " + p("'' = ''"),
|
||||
p("'10d03e62-7703-4df5-a358-4f76577d4e2f' = id :: text") + " AND " + p("id :: text != ''") + " AND " + p("'' = ''"),
|
||||
),
|
||||
},
|
||||
}
|
||||
|
|
|
@ -42,8 +42,8 @@ func UserConverter() *sqltypes.VariableConverter {
|
|||
// Users are never owned by an organization, so always return the empty string
|
||||
// for the org owner.
|
||||
sqltypes.StringVarMatcher("''", []string{"input", "object", "org_owner"}),
|
||||
// Users never have an owner, and are only owned site wide.
|
||||
sqltypes.StringVarMatcher("''", []string{"input", "object", "owner"}),
|
||||
// Users are always owned by themselves.
|
||||
sqltypes.StringVarMatcher("id :: text", []string{"input", "object", "owner"}),
|
||||
)
|
||||
matcher.RegisterMatcher(
|
||||
// No ACLs on the user type
|
||||
|
|
|
@ -145,14 +145,18 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
|||
Name: member,
|
||||
DisplayName: "",
|
||||
Site: Permissions(map[string][]Action{
|
||||
// All users can read all other users and know they exist.
|
||||
ResourceUser.Type: {ActionRead},
|
||||
ResourceRoleAssignment.Type: {ActionRead},
|
||||
// All users can see the provisioner daemons.
|
||||
ResourceProvisionerDaemon.Type: {ActionRead},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
User: allPermsExcept(ResourceWorkspaceLocked),
|
||||
Org: map[string][]Permission{},
|
||||
User: append(allPermsExcept(ResourceWorkspaceLocked, ResourceUser, ResourceOrganizationMember),
|
||||
Permissions(map[string][]Action{
|
||||
// Users cannot do create/update/delete on themselves, but they
|
||||
// can read their own details.
|
||||
ResourceUser.Type: {ActionRead},
|
||||
})...,
|
||||
),
|
||||
}.withCachedRegoValue()
|
||||
|
||||
auditorRole := Role{
|
||||
|
@ -163,6 +167,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
|||
// are not in.
|
||||
ResourceTemplate.Type: {ActionRead},
|
||||
ResourceAuditLog.Type: {ActionRead},
|
||||
ResourceUser.Type: {ActionRead},
|
||||
ResourceGroup.Type: {ActionRead},
|
||||
// Org roles are not really used yet, so grant the perm at the site level.
|
||||
ResourceOrganizationMember.Type: {ActionRead},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
User: []Permission{},
|
||||
|
@ -180,6 +188,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
|||
ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
// Needs to read all organizations since
|
||||
ResourceOrganization.Type: {ActionRead},
|
||||
ResourceUser.Type: {ActionRead},
|
||||
ResourceGroup.Type: {ActionRead},
|
||||
// Org roles are not really used yet, so grant the perm at the site level.
|
||||
ResourceOrganizationMember.Type: {ActionRead},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
User: []Permission{},
|
||||
|
@ -249,11 +261,6 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
|||
Site: []Permission{},
|
||||
Org: map[string][]Permission{
|
||||
organizationID: {
|
||||
{
|
||||
// All org members can read the other members in their org.
|
||||
ResourceType: ResourceOrganizationMember.Type,
|
||||
Action: ActionRead,
|
||||
},
|
||||
{
|
||||
// All org members can read the organization
|
||||
ResourceType: ResourceOrganization.Type,
|
||||
|
@ -264,13 +271,14 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
|
|||
ResourceType: ResourceOrgRoleAssignment.Type,
|
||||
Action: ActionRead,
|
||||
},
|
||||
{
|
||||
ResourceType: ResourceGroup.Type,
|
||||
Action: ActionRead,
|
||||
},
|
||||
},
|
||||
},
|
||||
User: []Permission{},
|
||||
User: []Permission{
|
||||
{
|
||||
ResourceType: ResourceOrganizationMember.Type,
|
||||
Action: ActionRead,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
|
@ -106,10 +106,10 @@ func TestRolePermissions(t *testing.T) {
|
|||
{
|
||||
Name: "MyUser",
|
||||
Actions: []rbac.Action{rbac.ActionRead},
|
||||
Resource: rbac.ResourceUser.WithID(currentUser),
|
||||
Resource: rbac.ResourceUserObject(currentUser),
|
||||
AuthorizeMap: map[bool][]authSubject{
|
||||
true: {owner, memberMe, orgMemberMe, orgAdmin, otherOrgMember, otherOrgAdmin, templateAdmin, userAdmin},
|
||||
false: {},
|
||||
true: {orgMemberMe, owner, memberMe, templateAdmin, userAdmin},
|
||||
false: {otherOrgMember, otherOrgAdmin, orgAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -281,7 +281,7 @@ func TestRolePermissions(t *testing.T) {
|
|||
{
|
||||
Name: "ManageOrgMember",
|
||||
Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
|
||||
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID),
|
||||
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]authSubject{
|
||||
true: {owner, orgAdmin, userAdmin},
|
||||
false: {orgMemberMe, memberMe, otherOrgAdmin, otherOrgMember, templateAdmin},
|
||||
|
@ -290,10 +290,10 @@ func TestRolePermissions(t *testing.T) {
|
|||
{
|
||||
Name: "ReadOrgMember",
|
||||
Actions: []rbac.Action{rbac.ActionRead},
|
||||
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID),
|
||||
Resource: rbac.ResourceOrganizationMember.WithID(currentUser).InOrg(orgID).WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]authSubject{
|
||||
true: {owner, orgAdmin, orgMemberMe, userAdmin},
|
||||
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin},
|
||||
true: {owner, orgAdmin, userAdmin, orgMemberMe, templateAdmin},
|
||||
false: {memberMe, otherOrgAdmin, otherOrgMember},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -314,8 +314,8 @@ func TestRolePermissions(t *testing.T) {
|
|||
Actions: []rbac.Action{rbac.ActionRead},
|
||||
Resource: rbac.ResourceGroup.WithID(groupID).InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]authSubject{
|
||||
true: {owner, orgAdmin, userAdmin, orgMemberMe},
|
||||
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin},
|
||||
true: {owner, orgAdmin, userAdmin, templateAdmin},
|
||||
false: {memberMe, otherOrgAdmin, orgMemberMe, otherOrgMember},
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -183,57 +183,11 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) {
|
|||
// @Router /users [get]
|
||||
func (api *API) users(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query().Get("q")
|
||||
params, errs := searchquery.Users(query)
|
||||
if len(errs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid user search query.",
|
||||
Validations: errs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
paginationParams, ok := parsePagination(rw, r)
|
||||
users, userCount, ok := api.GetUsers(rw, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
userRows, err := api.Database.GetUsers(ctx, database.GetUsersParams{
|
||||
AfterID: paginationParams.AfterID,
|
||||
Search: params.Search,
|
||||
Status: params.Status,
|
||||
RbacRole: params.RbacRole,
|
||||
LastSeenBefore: params.LastSeenBefore,
|
||||
LastSeenAfter: params.LastSeenAfter,
|
||||
OffsetOpt: int32(paginationParams.Offset),
|
||||
LimitOpt: int32(paginationParams.Limit),
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching users.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// GetUsers does not return ErrNoRows because it uses a window function to get the count.
|
||||
// So we need to check if the userRows is empty and return an empty array if so.
|
||||
if len(userRows) == 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.GetUsersResponse{
|
||||
Users: []codersdk.User{},
|
||||
Count: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
users, err := AuthorizeFilter(api.HTTPAuth, r, rbac.ActionRead, database.ConvertUserRows(userRows))
|
||||
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)
|
||||
|
@ -257,10 +211,55 @@ func (api *API) users(rw http.ResponseWriter, r *http.Request) {
|
|||
render.Status(r, http.StatusOK)
|
||||
render.JSON(rw, r, codersdk.GetUsersResponse{
|
||||
Users: convertUsers(users, organizationIDsByUserID),
|
||||
Count: int(userRows[0].Count),
|
||||
Count: int(userCount),
|
||||
})
|
||||
}
|
||||
|
||||
func (api *API) GetUsers(rw http.ResponseWriter, r *http.Request) ([]database.User, int64, bool) {
|
||||
ctx := r.Context()
|
||||
query := r.URL.Query().Get("q")
|
||||
params, errs := searchquery.Users(query)
|
||||
if len(errs) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid user search query.",
|
||||
Validations: errs,
|
||||
})
|
||||
return nil, -1, false
|
||||
}
|
||||
|
||||
paginationParams, ok := parsePagination(rw, r)
|
||||
if !ok {
|
||||
return nil, -1, false
|
||||
}
|
||||
|
||||
userRows, err := api.Database.GetUsers(ctx, database.GetUsersParams{
|
||||
AfterID: paginationParams.AfterID,
|
||||
Search: params.Search,
|
||||
Status: params.Status,
|
||||
RbacRole: params.RbacRole,
|
||||
LastSeenBefore: params.LastSeenBefore,
|
||||
LastSeenAfter: params.LastSeenAfter,
|
||||
OffsetOpt: int32(paginationParams.Offset),
|
||||
LimitOpt: int32(paginationParams.Limit),
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching users.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return nil, -1, false
|
||||
}
|
||||
|
||||
// GetUsers does not return ErrNoRows because it uses a window function to get the count.
|
||||
// So we need to check if the userRows is empty and return an empty array if so.
|
||||
if len(userRows) == 0 {
|
||||
return []database.User{}, 0, true
|
||||
}
|
||||
|
||||
users := database.ConvertUserRows(userRows)
|
||||
return users, userRows[0].Count, true
|
||||
}
|
||||
|
||||
// Creates a new user.
|
||||
//
|
||||
// @Summary Create new user
|
||||
|
|
|
@ -1431,7 +1431,7 @@ func TestGetUsersPagination(t *testing.T) {
|
|||
require.Equal(t, res.Count, 2)
|
||||
|
||||
// if offset is higher than the count postgres returns an empty array
|
||||
// and not an ErrNoRows error. This also means the count must be 0.
|
||||
// and not an ErrNoRows error.
|
||||
res, err = client.Users(ctx, codersdk.UsersRequest{
|
||||
Pagination: codersdk.Pagination{
|
||||
Offset: 3,
|
||||
|
|
|
@ -167,6 +167,13 @@ type UpdateTemplateACL struct {
|
|||
GroupPerms map[string]TemplateRole `json:"group_perms,omitempty" example:"<user_id>>:admin,8bd26b20-f3e8-48be-a903-46bb920cf671:use"`
|
||||
}
|
||||
|
||||
// ACLAvailable is a list of users and groups that can be added to a template
|
||||
// ACL.
|
||||
type ACLAvailable struct {
|
||||
Users []User `json:"users"`
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
|
||||
type UpdateTemplateMeta struct {
|
||||
Name string `json:"name,omitempty" validate:"omitempty,template_name"`
|
||||
DisplayName string `json:"display_name,omitempty" validate:"omitempty,template_display_name"`
|
||||
|
@ -251,6 +258,20 @@ func (c *Client) UpdateTemplateACL(ctx context.Context, templateID uuid.UUID, re
|
|||
return nil
|
||||
}
|
||||
|
||||
// TemplateACLAvailable returns available users + groups that can be assigned template perms
|
||||
func (c *Client) TemplateACLAvailable(ctx context.Context, templateID uuid.UUID) (ACLAvailable, error) {
|
||||
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s/acl/available", templateID), nil)
|
||||
if err != nil {
|
||||
return ACLAvailable{}, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return ACLAvailable{}, ReadBodyAsError(res)
|
||||
}
|
||||
var acl ACLAvailable
|
||||
return acl, json.NewDecoder(res.Body).Decode(&acl)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -1142,6 +1142,131 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template}/acl \
|
|||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get template available acl users/groups
|
||||
|
||||
### Code samples
|
||||
|
||||
```shell
|
||||
# Example request using curl
|
||||
curl -X GET http://coder-server:8080/api/v2/templates/{template}/acl/available \
|
||||
-H 'Accept: application/json' \
|
||||
-H 'Coder-Session-Token: API_KEY'
|
||||
```
|
||||
|
||||
`GET /templates/{template}/acl/available`
|
||||
|
||||
### Parameters
|
||||
|
||||
| Name | In | Type | Required | Description |
|
||||
| ---------- | ---- | ------------ | -------- | ----------- |
|
||||
| `template` | path | string(uuid) | true | Template ID |
|
||||
|
||||
### Example responses
|
||||
|
||||
> 200 Response
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"avatar_url": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"members": [
|
||||
{
|
||||
"avatar_url": "http://example.com",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"email": "user@example.com",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"last_seen_at": "2019-08-24T14:15:22Z",
|
||||
"login_type": "password",
|
||||
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"roles": [
|
||||
{
|
||||
"display_name": "string",
|
||||
"name": "string"
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"avatar_url": "http://example.com",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"email": "user@example.com",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"last_seen_at": "2019-08-24T14:15:22Z",
|
||||
"login_type": "password",
|
||||
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"roles": [
|
||||
{
|
||||
"display_name": "string",
|
||||
"name": "string"
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Responses
|
||||
|
||||
| Status | Meaning | Description | Schema |
|
||||
| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------- |
|
||||
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.ACLAvailable](schemas.md#codersdkaclavailable) |
|
||||
|
||||
<h3 id="get-template-available-acl-users/groups-responseschema">Response Schema</h3>
|
||||
|
||||
Status Code **200**
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ---------------------- | ---------------------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `[array item]` | array | false | | |
|
||||
| `» groups` | array | false | | |
|
||||
| `»» avatar_url` | string | false | | |
|
||||
| `»» id` | string(uuid) | false | | |
|
||||
| `»» members` | array | false | | |
|
||||
| `»»» avatar_url` | string(uri) | false | | |
|
||||
| `»»» created_at` | string(date-time) | true | | |
|
||||
| `»»» email` | string(email) | true | | |
|
||||
| `»»» id` | string(uuid) | true | | |
|
||||
| `»»» last_seen_at` | string(date-time) | false | | |
|
||||
| `»»» login_type` | [codersdk.LoginType](schemas.md#codersdklogintype) | false | | |
|
||||
| `»»» organization_ids` | array | false | | |
|
||||
| `»»» roles` | array | false | | |
|
||||
| `»»»» display_name` | string | false | | |
|
||||
| `»»»» name` | string | false | | |
|
||||
| `»»» status` | [codersdk.UserStatus](schemas.md#codersdkuserstatus) | false | | |
|
||||
| `»»» username` | string | true | | |
|
||||
| `»» name` | string | false | | |
|
||||
| `»» organization_id` | string(uuid) | false | | |
|
||||
| `»» quota_allowance` | integer | false | | |
|
||||
| `» users` | array | false | | |
|
||||
|
||||
#### Enumerated Values
|
||||
|
||||
| Property | Value |
|
||||
| ------------ | ----------- |
|
||||
| `login_type` | `password` |
|
||||
| `login_type` | `github` |
|
||||
| `login_type` | `oidc` |
|
||||
| `login_type` | `token` |
|
||||
| `login_type` | `none` |
|
||||
| `status` | `active` |
|
||||
| `status` | `suspended` |
|
||||
|
||||
To perform this operation, you must be authenticated. [Learn more](authentication.md).
|
||||
|
||||
## Get user quiet hours schedule
|
||||
|
||||
### Code samples
|
||||
|
|
|
@ -755,6 +755,67 @@
|
|||
| ------------ | ------ | -------- | ------------ | ----------- |
|
||||
| `csp-report` | object | false | | |
|
||||
|
||||
## codersdk.ACLAvailable
|
||||
|
||||
```json
|
||||
{
|
||||
"groups": [
|
||||
{
|
||||
"avatar_url": "string",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"members": [
|
||||
{
|
||||
"avatar_url": "http://example.com",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"email": "user@example.com",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"last_seen_at": "2019-08-24T14:15:22Z",
|
||||
"login_type": "password",
|
||||
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"roles": [
|
||||
{
|
||||
"display_name": "string",
|
||||
"name": "string"
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"username": "string"
|
||||
}
|
||||
],
|
||||
"name": "string",
|
||||
"organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6",
|
||||
"quota_allowance": 0
|
||||
}
|
||||
],
|
||||
"users": [
|
||||
{
|
||||
"avatar_url": "http://example.com",
|
||||
"created_at": "2019-08-24T14:15:22Z",
|
||||
"email": "user@example.com",
|
||||
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
|
||||
"last_seen_at": "2019-08-24T14:15:22Z",
|
||||
"login_type": "password",
|
||||
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"roles": [
|
||||
{
|
||||
"display_name": "string",
|
||||
"name": "string"
|
||||
}
|
||||
],
|
||||
"status": "active",
|
||||
"username": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| -------- | ----------------------------------------- | -------- | ------------ | ----------- |
|
||||
| `groups` | array of [codersdk.Group](#codersdkgroup) | false | | |
|
||||
| `users` | array of [codersdk.User](#codersdkuser) | false | | |
|
||||
|
||||
## codersdk.APIKey
|
||||
|
||||
```json
|
||||
|
|
|
@ -202,6 +202,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
|||
apiKeyMiddleware,
|
||||
httpmw.ExtractTemplateParam(api.Database),
|
||||
)
|
||||
r.Get("/available", api.templateAvailablePermissions)
|
||||
r.Get("/", api.templateACL)
|
||||
r.Patch("/", api.patchTemplateACL)
|
||||
})
|
||||
|
|
|
@ -474,9 +474,8 @@ func TestGroup(t *testing.T) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ggroup, err := client1.Group(ctx, group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, group, ggroup)
|
||||
_, err = client1.Group(ctx, group.ID)
|
||||
require.Error(t, err, "regular users cannot read groups")
|
||||
})
|
||||
|
||||
t.Run("FilterDeletedUsers", func(t *testing.T) {
|
||||
|
|
|
@ -9,15 +9,74 @@ import (
|
|||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/audit"
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/httpmw"
|
||||
"github.com/coder/coder/coderd/rbac"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// @Summary Get template available acl users/groups
|
||||
// @ID get-template-available-acl-usersgroups
|
||||
// @Security CoderSessionToken
|
||||
// @Produce json
|
||||
// @Tags Enterprise
|
||||
// @Param template path string true "Template ID" format(uuid)
|
||||
// @Success 200 {array} codersdk.ACLAvailable
|
||||
// @Router /templates/{template}/acl/available [get]
|
||||
func (api *API) templateAvailablePermissions(rw http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
template = httpmw.TemplateParam(r)
|
||||
)
|
||||
|
||||
// Requires update permission on the template to list all avail users/groups
|
||||
// for assignment.
|
||||
if !api.Authorize(r, rbac.ActionUpdate, template) {
|
||||
httpapi.ResourceNotFound(rw)
|
||||
return
|
||||
}
|
||||
|
||||
// We have to use the system restricted context here because the caller
|
||||
// might not have permission to read all users.
|
||||
// nolint:gocritic
|
||||
users, _, ok := api.AGPL.GetUsers(rw, r.WithContext(dbauthz.AsSystemRestricted(ctx)))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Perm check is the template update check.
|
||||
// nolint:gocritic
|
||||
groups, err := api.Database.GetGroupsByOrganizationID(dbauthz.AsSystemRestricted(ctx), template.OrganizationID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
sdkGroups := make([]codersdk.Group, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
// nolint:gocritic
|
||||
members, err := api.Database.GetGroupMembers(dbauthz.AsSystemRestricted(ctx), group.ID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
sdkGroups = append(sdkGroups, convertGroup(group, members))
|
||||
}
|
||||
|
||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ACLAvailable{
|
||||
// No need to pass organization info here.
|
||||
// TODO: @emyrk we should return a MinimalUser here instead of a full user.
|
||||
// The FE requires the `email` field, so this cannot be done without
|
||||
// a UI change.
|
||||
Users: convertUsers(users, map[uuid.UUID][]uuid.UUID{}),
|
||||
Groups: sdkGroups,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary Get template ACLs
|
||||
// @ID get-template-acls
|
||||
// @Security CoderSessionToken
|
||||
|
@ -44,15 +103,6 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) {
|
|||
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)
|
||||
|
@ -73,7 +123,12 @@ func (api *API) templateACL(rw http.ResponseWriter, r *http.Request) {
|
|||
for _, group := range dbGroups {
|
||||
var members []database.User
|
||||
|
||||
members, err = api.Database.GetGroupMembers(ctx, group.ID)
|
||||
// This is a bit of a hack. The caller might not have permission to do this,
|
||||
// but they can read the acl list if the function got this far. So we let
|
||||
// them read the group members.
|
||||
// We should probably at least return more truncated user data here.
|
||||
// nolint:gocritic
|
||||
members, err = api.Database.GetGroupMembers(dbauthz.AsSystemRestricted(ctx), group.ID)
|
||||
if err != nil {
|
||||
httpapi.InternalServerError(rw, err)
|
||||
return
|
||||
|
@ -191,6 +246,9 @@ func (api *API) patchTemplateACL(rw http.ResponseWriter, r *http.Request) {
|
|||
|
||||
// nolint TODO fix stupid flag.
|
||||
func validateTemplateACLPerms(ctx context.Context, db database.Store, perms map[string]codersdk.TemplateRole, field string, isUser bool) []codersdk.ValidationError {
|
||||
// Validate requires full read access to users and groups
|
||||
// nolint:gocritic
|
||||
ctx = dbauthz.AsSystemRestricted(ctx)
|
||||
var validErrs []codersdk.ValidationError
|
||||
for k, v := range perms {
|
||||
if err := validateTemplateRole(v); err != nil {
|
||||
|
|
|
@ -885,6 +885,17 @@ func TestUpdateTemplateACL(t *testing.T) {
|
|||
err := client.UpdateTemplateACL(ctx, template.ID, req)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be able to see user 3
|
||||
available, err := client2.TemplateACLAvailable(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
userFound := false
|
||||
for _, avail := range available.Users {
|
||||
if avail.ID == user3.ID {
|
||||
userFound = true
|
||||
}
|
||||
}
|
||||
require.True(t, userFound, "user not found in acl available")
|
||||
|
||||
req = codersdk.UpdateTemplateACL{
|
||||
UserPerms: map[string]codersdk.TemplateRole{
|
||||
user3.ID.String(): codersdk.TemplateRoleUse,
|
||||
|
@ -897,10 +908,13 @@ func TestUpdateTemplateACL(t *testing.T) {
|
|||
acl, err := client2.TemplateACL(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Contains(t, acl.Users, codersdk.TemplateUser{
|
||||
User: user3,
|
||||
Role: codersdk.TemplateRoleUse,
|
||||
})
|
||||
found := false
|
||||
for _, u := range acl.Users {
|
||||
if u.ID == user3.ID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "user not found in acl")
|
||||
})
|
||||
|
||||
t.Run("allUsersGroup", func(t *testing.T) {
|
||||
|
|
|
@ -869,6 +869,18 @@ export const getDeploymentDAUs = async (
|
|||
return response.data
|
||||
}
|
||||
|
||||
export const getTemplateACLAvailable = async (
|
||||
templateId: string,
|
||||
options: TypesGen.UsersRequest,
|
||||
): Promise<TypesGen.ACLAvailable> => {
|
||||
const url = getURLWithSearchParams(
|
||||
`/api/v2/templates/${templateId}/acl/available`,
|
||||
options,
|
||||
)
|
||||
const response = await axios.get(url.toString())
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getTemplateACL = async (
|
||||
templateId: string,
|
||||
): Promise<TypesGen.TemplateACL> => {
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
// Code generated by 'make site/src/api/typesGenerated.ts'. DO NOT EDIT.
|
||||
|
||||
// From codersdk/templates.go
|
||||
export interface ACLAvailable {
|
||||
readonly users: User[]
|
||||
readonly groups: Group[]
|
||||
}
|
||||
|
||||
// From codersdk/apikey.go
|
||||
export interface APIKey {
|
||||
readonly id: string
|
||||
|
|
|
@ -16,6 +16,7 @@ export const Navbar: FC = () => {
|
|||
const canViewAuditLog =
|
||||
featureVisibility["audit_log"] && Boolean(permissions.viewAuditLog)
|
||||
const canViewDeployment = Boolean(permissions.viewDeploymentValues)
|
||||
const canViewAllUsers = Boolean(permissions.readAllUsers)
|
||||
const onSignOut = () => authSend("SIGN_OUT")
|
||||
const proxyContextValue = useProxy()
|
||||
const dashboard = useDashboard()
|
||||
|
@ -29,6 +30,7 @@ export const Navbar: FC = () => {
|
|||
onSignOut={onSignOut}
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewAllUsers={canViewAllUsers}
|
||||
proxyContextValue={
|
||||
dashboard.experiments.includes("moons") ? proxyContextValue : undefined
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog
|
||||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const workspacesLink = await screen.findByText(navLanguage.workspaces)
|
||||
|
@ -62,6 +63,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog
|
||||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const templatesLink = await screen.findByText(navLanguage.templates)
|
||||
|
@ -76,6 +78,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog
|
||||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const userLink = await screen.findByText(navLanguage.users)
|
||||
|
@ -98,6 +101,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog
|
||||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
|
||||
|
@ -115,6 +119,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog
|
||||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = await screen.findByText(navLanguage.audit)
|
||||
|
@ -129,6 +134,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog={false}
|
||||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = screen.queryByText(navLanguage.audit)
|
||||
|
@ -143,6 +149,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog
|
||||
canViewDeployment
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = await screen.findByText(navLanguage.deployment)
|
||||
|
@ -159,6 +166,7 @@ describe("NavbarView", () => {
|
|||
onSignOut={noop}
|
||||
canViewAuditLog={false}
|
||||
canViewDeployment={false}
|
||||
canViewAllUsers
|
||||
/>,
|
||||
)
|
||||
const auditLink = screen.queryByText(navLanguage.deployment)
|
||||
|
|
|
@ -36,6 +36,7 @@ export interface NavbarViewProps {
|
|||
onSignOut: () => void
|
||||
canViewAuditLog: boolean
|
||||
canViewDeployment: boolean
|
||||
canViewAllUsers: boolean
|
||||
proxyContextValue?: ProxyContextValue
|
||||
}
|
||||
|
||||
|
@ -52,8 +53,9 @@ const NavItems: React.FC<
|
|||
className?: string
|
||||
canViewAuditLog: boolean
|
||||
canViewDeployment: boolean
|
||||
canViewAllUsers: boolean
|
||||
}>
|
||||
> = ({ className, canViewAuditLog, canViewDeployment }) => {
|
||||
> = ({ className, canViewAuditLog, canViewDeployment, canViewAllUsers }) => {
|
||||
const styles = useStyles()
|
||||
const location = useLocation()
|
||||
|
||||
|
@ -75,11 +77,13 @@ const NavItems: React.FC<
|
|||
{Language.templates}
|
||||
</NavLink>
|
||||
</ListItem>
|
||||
<ListItem button className={styles.item}>
|
||||
<NavLink className={styles.link} to={USERS_LINK}>
|
||||
{Language.users}
|
||||
</NavLink>
|
||||
</ListItem>
|
||||
{canViewAllUsers && (
|
||||
<ListItem button className={styles.item}>
|
||||
<NavLink className={styles.link} to={USERS_LINK}>
|
||||
{Language.users}
|
||||
</NavLink>
|
||||
</ListItem>
|
||||
)}
|
||||
{canViewAuditLog && (
|
||||
<ListItem button className={styles.item}>
|
||||
<NavLink className={styles.link} to="/audit">
|
||||
|
@ -105,6 +109,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
|||
onSignOut,
|
||||
canViewAuditLog,
|
||||
canViewDeployment,
|
||||
canViewAllUsers,
|
||||
proxyContextValue,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
@ -142,6 +147,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
|||
<NavItems
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewAllUsers={canViewAllUsers}
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
|
@ -158,6 +164,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
|
|||
className={styles.desktopNavItems}
|
||||
canViewAuditLog={canViewAuditLog}
|
||||
canViewDeployment={canViewDeployment}
|
||||
canViewAllUsers={canViewAllUsers}
|
||||
/>
|
||||
|
||||
<Box
|
||||
|
|
|
@ -21,12 +21,13 @@ export type UserOrGroupAutocompleteProps = {
|
|||
value: UserOrGroupAutocompleteValue
|
||||
onChange: (value: UserOrGroupAutocompleteValue) => void
|
||||
organizationId: string
|
||||
templateID?: string
|
||||
exclude: UserOrGroupAutocompleteValue[]
|
||||
}
|
||||
|
||||
export const UserOrGroupAutocomplete: React.FC<
|
||||
UserOrGroupAutocompleteProps
|
||||
> = ({ value, onChange, organizationId, exclude }) => {
|
||||
> = ({ value, onChange, organizationId, templateID, exclude }) => {
|
||||
const styles = useStyles()
|
||||
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
|
||||
const [searchState, sendSearch] = useMachine(searchUsersAndGroupsMachine, {
|
||||
|
@ -34,6 +35,7 @@ export const UserOrGroupAutocomplete: React.FC<
|
|||
userResults: [],
|
||||
groupResults: [],
|
||||
organizationId,
|
||||
templateID,
|
||||
},
|
||||
})
|
||||
const { userResults, groupResults } = searchState.context
|
||||
|
|
|
@ -64,6 +64,7 @@ export const TemplatePermissionsPage: FC<
|
|||
<Cond>
|
||||
<TemplatePermissionsPageView
|
||||
organizationId={organizationId}
|
||||
templateID={template.id}
|
||||
templateACL={templateACL}
|
||||
canUpdatePermissions={Boolean(permissions?.canUpdateTemplate)}
|
||||
onAddUser={(user, role, reset) => {
|
||||
|
|
|
@ -34,6 +34,7 @@ import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
|
|||
|
||||
type AddTemplateUserOrGroupProps = {
|
||||
organizationId: string
|
||||
templateID: string
|
||||
isLoading: boolean
|
||||
templateACL: TemplateACL | undefined
|
||||
onSubmit: (
|
||||
|
@ -47,6 +48,7 @@ const AddTemplateUserOrGroup: React.FC<AddTemplateUserOrGroupProps> = ({
|
|||
isLoading,
|
||||
onSubmit,
|
||||
organizationId,
|
||||
templateID,
|
||||
templateACL,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
|
@ -83,6 +85,7 @@ const AddTemplateUserOrGroup: React.FC<AddTemplateUserOrGroupProps> = ({
|
|||
<UserOrGroupAutocomplete
|
||||
exclude={excludeFromAutocomplete}
|
||||
organizationId={organizationId}
|
||||
templateID={templateID}
|
||||
value={selectedOption}
|
||||
onChange={(newValue) => {
|
||||
setSelectedOption(newValue)
|
||||
|
@ -151,6 +154,7 @@ const RoleSelect: FC<SelectProps> = (props) => {
|
|||
|
||||
export interface TemplatePermissionsPageViewProps {
|
||||
templateACL: TemplateACL | undefined
|
||||
templateID: string
|
||||
organizationId: string
|
||||
canUpdatePermissions: boolean
|
||||
// User
|
||||
|
@ -177,6 +181,7 @@ export const TemplatePermissionsPageView: FC<
|
|||
templateACL,
|
||||
canUpdatePermissions,
|
||||
organizationId,
|
||||
templateID,
|
||||
// User
|
||||
onAddUser,
|
||||
isAddingUser,
|
||||
|
@ -207,6 +212,7 @@ export const TemplatePermissionsPageView: FC<
|
|||
<Maybe condition={canUpdatePermissions}>
|
||||
<AddTemplateUserOrGroup
|
||||
templateACL={templateACL}
|
||||
templateID={templateID}
|
||||
organizationId={organizationId}
|
||||
isLoading={isAddingUser || isAddingGroup}
|
||||
onSubmit={(value, role, resetAutocomplete) =>
|
||||
|
|
|
@ -15,6 +15,10 @@ export const getGroupSubtitle = (group: Group): string => {
|
|||
return `All users`
|
||||
}
|
||||
|
||||
if (!group.members) {
|
||||
return `0 members`
|
||||
}
|
||||
|
||||
if (group.members.length === 1) {
|
||||
return `1 member`
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getGroups, getUsers } from "api/api"
|
||||
import { getGroups, getTemplateACLAvailable, getUsers } from "api/api"
|
||||
import { Group, User } from "api/typesGenerated"
|
||||
import { queryToFilter } from "utils/filters"
|
||||
import { everyOneGroup } from "utils/groups"
|
||||
|
@ -15,6 +15,7 @@ export const searchUsersAndGroupsMachine = createMachine(
|
|||
schema: {
|
||||
context: {} as {
|
||||
organizationId: string
|
||||
templateID?: string
|
||||
userResults: User[]
|
||||
groupResults: Group[]
|
||||
},
|
||||
|
@ -56,16 +57,29 @@ export const searchUsersAndGroupsMachine = createMachine(
|
|||
},
|
||||
{
|
||||
services: {
|
||||
search: async ({ organizationId }, { query }) => {
|
||||
const [userRes, groups] = await Promise.all([
|
||||
getUsers(queryToFilter(query)),
|
||||
getGroups(organizationId),
|
||||
])
|
||||
search: async ({ organizationId, templateID }, { query }) => {
|
||||
let users, groups
|
||||
if (templateID && templateID !== "") {
|
||||
const res = await getTemplateACLAvailable(
|
||||
templateID,
|
||||
queryToFilter(query),
|
||||
)
|
||||
users = res.users
|
||||
groups = res.groups
|
||||
} else {
|
||||
const [userRes, groupsRes] = await Promise.all([
|
||||
getUsers(queryToFilter(query)),
|
||||
getGroups(organizationId),
|
||||
])
|
||||
|
||||
users = userRes.users
|
||||
groups = groupsRes
|
||||
}
|
||||
|
||||
// The Everyone groups is not returned by the API so we have to add it
|
||||
// manually
|
||||
return {
|
||||
users: userRes.users,
|
||||
users: users,
|
||||
groups: [everyOneGroup(organizationId), ...groups],
|
||||
}
|
||||
},
|
||||
|
|
Loading…
Reference in New Issue