Merge pull request #11695 from owncloud/feat/user-search-attributes

feat: [OCISDEV-234] add configurable display attributes
This commit is contained in:
Lukas Hirt
2025-10-02 14:29:03 +02:00
committed by GitHub
8 changed files with 269 additions and 49 deletions

View File

@@ -0,0 +1,6 @@
Enhancement: Add configurable display attributes
We added new configuration options `UserSearchDisplayedAttributes` which allows to configure the attributes that are displayed in the user search results.
We are also deprecating the `ShowUserEmailInResults` configuration option.
https://github.com/owncloud/ocis/pull/11695

View File

@@ -26,17 +26,18 @@ type Config struct {
SkipUserGroupsInToken bool `yaml:"skip_user_groups_in_token" env:"FRONTEND_SKIP_USER_GROUPS_IN_TOKEN" desc:"Disables the loading of user's group memberships from the reva access token." introductionVersion:"pre5.0"`
EnableFavorites bool `yaml:"enable_favorites" env:"FRONTEND_ENABLE_FAVORITES" desc:"Enables the support for favorites in the clients." introductionVersion:"pre5.0"`
MaxQuota uint64 `yaml:"max_quota" env:"OCIS_SPACES_MAX_QUOTA;FRONTEND_MAX_QUOTA" desc:"Set the global max quota value in bytes. A value of 0 equals unlimited. The value is provided via capabilities." introductionVersion:"pre5.0"`
UploadMaxChunkSize int `yaml:"upload_max_chunk_size" env:"FRONTEND_UPLOAD_MAX_CHUNK_SIZE" desc:"Sets the max chunk sizes in bytes for uploads via the clients." introductionVersion:"pre5.0"`
UploadHTTPMethodOverride string `yaml:"upload_http_method_override" env:"FRONTEND_UPLOAD_HTTP_METHOD_OVERRIDE" desc:"Advise TUS to replace PATCH requests by POST requests." introductionVersion:"pre5.0"`
DefaultUploadProtocol string `yaml:"default_upload_protocol" env:"FRONTEND_DEFAULT_UPLOAD_PROTOCOL" desc:"The default upload protocol to use in clients. Currently only 'tus' is available. See the developer API documentation for more details about TUS." introductionVersion:"pre5.0"`
EnableFederatedSharingIncoming bool `yaml:"enable_federated_sharing_incoming" env:"OCIS_ENABLE_OCM;FRONTEND_ENABLE_FEDERATED_SHARING_INCOMING" desc:"Changing this value is NOT supported. Enables support for incoming federated sharing for clients. The backend behaviour is not changed." introductionVersion:"pre5.0"`
EnableFederatedSharingOutgoing bool `yaml:"enable_federated_sharing_outgoing" env:"OCIS_ENABLE_OCM;FRONTEND_ENABLE_FEDERATED_SHARING_OUTGOING" desc:"Changing this value is NOT supported. Enables support for outgoing federated sharing for clients. The backend behaviour is not changed." introductionVersion:"pre5.0"`
SearchMinLength int `yaml:"search_min_length" env:"FRONTEND_SEARCH_MIN_LENGTH" desc:"Minimum number of characters to enter before a client should start a search for Share receivers. This setting can be used to customize the user experience if e.g too many results are displayed." introductionVersion:"pre5.0"`
Edition string `yaml:"edition" env:"OCIS_EDITION;FRONTEND_EDITION" desc:"Edition of oCIS. Used for branding purposes." introductionVersion:"pre5.0"`
DisableSSE bool `yaml:"disable_sse" env:"OCIS_DISABLE_SSE;FRONTEND_DISABLE_SSE" desc:"When set to true, clients are informed that the Server-Sent Events endpoint is not accessible." introductionVersion:"pre5.0"`
DefaultLinkPermissions int `yaml:"default_link_permissions" env:"FRONTEND_DEFAULT_LINK_PERMISSIONS" desc:"Defines the default permissions a link is being created with. Possible values are 0 (= internal link, for instance members only) and 1 (= public link with viewer permissions). Defaults to 1." introductionVersion:"5.0"`
EnableFavorites bool `yaml:"enable_favorites" env:"FRONTEND_ENABLE_FAVORITES" desc:"Enables the support for favorites in the clients." introductionVersion:"pre5.0"`
MaxQuota uint64 `yaml:"max_quota" env:"OCIS_SPACES_MAX_QUOTA;FRONTEND_MAX_QUOTA" desc:"Set the global max quota value in bytes. A value of 0 equals unlimited. The value is provided via capabilities." introductionVersion:"pre5.0"`
UploadMaxChunkSize int `yaml:"upload_max_chunk_size" env:"FRONTEND_UPLOAD_MAX_CHUNK_SIZE" desc:"Sets the max chunk sizes in bytes for uploads via the clients." introductionVersion:"pre5.0"`
UploadHTTPMethodOverride string `yaml:"upload_http_method_override" env:"FRONTEND_UPLOAD_HTTP_METHOD_OVERRIDE" desc:"Advise TUS to replace PATCH requests by POST requests." introductionVersion:"pre5.0"`
DefaultUploadProtocol string `yaml:"default_upload_protocol" env:"FRONTEND_DEFAULT_UPLOAD_PROTOCOL" desc:"The default upload protocol to use in clients. Currently only 'tus' is available. See the developer API documentation for more details about TUS." introductionVersion:"pre5.0"`
EnableFederatedSharingIncoming bool `yaml:"enable_federated_sharing_incoming" env:"OCIS_ENABLE_OCM;FRONTEND_ENABLE_FEDERATED_SHARING_INCOMING" desc:"Changing this value is NOT supported. Enables support for incoming federated sharing for clients. The backend behaviour is not changed." introductionVersion:"pre5.0"`
EnableFederatedSharingOutgoing bool `yaml:"enable_federated_sharing_outgoing" env:"OCIS_ENABLE_OCM;FRONTEND_ENABLE_FEDERATED_SHARING_OUTGOING" desc:"Changing this value is NOT supported. Enables support for outgoing federated sharing for clients. The backend behaviour is not changed." introductionVersion:"pre5.0"`
SearchMinLength int `yaml:"search_min_length" env:"FRONTEND_SEARCH_MIN_LENGTH" desc:"Minimum number of characters to enter before a client should start a search for Share receivers. This setting can be used to customize the user experience if e.g too many results are displayed." introductionVersion:"pre5.0"`
UserSearchDisplayedAttributes []string `yaml:"user_search_displayed_attributes" env:"OCIS_USER_SEARCH_DISPLAYED_ATTRIBUTES;FRONTEND_USER_SEARCH_DISPLAYED_ATTRIBUTES" desc:"A list of user attributes to display in the user search results." introductionVersion:"Balch"`
Edition string `yaml:"edition" env:"OCIS_EDITION;FRONTEND_EDITION" desc:"Edition of oCIS. Used for branding purposes." introductionVersion:"pre5.0"`
DisableSSE bool `yaml:"disable_sse" env:"OCIS_DISABLE_SSE;FRONTEND_DISABLE_SSE" desc:"When set to true, clients are informed that the Server-Sent Events endpoint is not accessible." introductionVersion:"pre5.0"`
DefaultLinkPermissions int `yaml:"default_link_permissions" env:"FRONTEND_DEFAULT_LINK_PERMISSIONS" desc:"Defines the default permissions a link is being created with. Possible values are 0 (= internal link, for instance members only) and 1 (= public link with viewer permissions). Defaults to 1." introductionVersion:"5.0"`
PublicURL string `yaml:"public_url" env:"OCIS_URL;FRONTEND_PUBLIC_URL" desc:"The public facing URL of the oCIS frontend." introductionVersion:"pre5.0"`
MaxConcurrency int `yaml:"max_concurrency" env:"OCIS_MAX_CONCURRENCY;FRONTEND_MAX_CONCURRENCY" desc:"Maximum number of concurrent go-routines. Higher values can potentially get work done faster but will also cause more load on the system. Values of 0 or below will be ignored and the default value will be used." introductionVersion:"7.0.0"`
@@ -150,7 +151,7 @@ type OCS struct {
IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OCIS_ENABLE_OCM" desc:"Include OCM sharees when listing sharees." introductionVersion:"5.0" deprecationVersion:"7.0.0" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"The OCS API is deprecated" deprecationReplacement:""`
PublicShareMustHavePassword bool `yaml:"public_sharing_share_must_have_password" env:"OCIS_SHARING_PUBLIC_SHARE_MUST_HAVE_PASSWORD" desc:"Set this to true if you want to enforce passwords on all public shares." introductionVersion:"5.0" deprecationVersion:"7.0.0" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"The OCS API is deprecated" deprecationReplacement:""`
WriteablePublicShareMustHavePassword bool `yaml:"public_sharing_writeableshare_must_have_password" env:"OCIS_SHARING_PUBLIC_WRITEABLE_SHARE_MUST_HAVE_PASSWORD" desc:"Set this to true if you want to enforce passwords for writable shares. Only effective if the setting for 'passwords on all public shares' is set to false." introductionVersion:"5.0" deprecationVersion:"7.0.0" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"The OCS API is deprecated" deprecationReplacement:""`
ShowUserEmailInResults bool `yaml:"show_email_in_results" env:"OCIS_SHOW_USER_EMAIL_IN_RESULTS" desc:"Include user email addresses in responses. If absent or set to false emails will be omitted from results. Please note that admin users can always see all email addresses." introductionVersion:"6.0.0"`
ShowUserEmailInResults bool `yaml:"show_email_in_results" env:"OCIS_SHOW_USER_EMAIL_IN_RESULTS" desc:"Include user email addresses in responses. If absent or set to false emails will be omitted from results. Please note that admin users can always see all email addresses." introductionVersion:"6.0.0" deprecationVersion:"Balch" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"Deprecating in favor of a unified array with arbitrary attributes" deprecationReplacement:"UserSearchDisplayedAttributes"`
}
type CacheWarmupDrivers struct {

View File

@@ -357,8 +357,9 @@ func FrontendConfigFromStruct(cfg *config.Config, logger log.Logger) (map[string
"productversion": version.GetString(),
},
},
"include_ocm_sharees": cfg.OCS.IncludeOCMSharees,
"show_email_in_results": cfg.OCS.ShowUserEmailInResults,
"include_ocm_sharees": cfg.OCS.IncludeOCMSharees,
"show_email_in_results": cfg.OCS.ShowUserEmailInResults,
"user_search_displayed_attributes": cfg.UserSearchDisplayedAttributes,
},
},
},

View File

@@ -114,11 +114,12 @@ type Identity struct {
// API represents API configuration parameters.
type API struct {
GroupMembersPatchLimit int `yaml:"group_members_patch_limit" env:"GRAPH_GROUP_MEMBERS_PATCH_LIMIT" desc:"The amount of group members allowed to be added with a single patch request." introductionVersion:"pre5.0"`
UsernameMatch string `yaml:"graph_username_match" env:"GRAPH_USERNAME_MATCH" desc:"Apply restrictions to usernames. Supported values are 'default' and 'none'. When set to 'default', user names must not start with a number and are restricted to ASCII characters. When set to 'none', no restrictions are applied. The default value is 'default'." introductionVersion:"pre5.0"`
AssignDefaultUserRole bool `yaml:"graph_assign_default_user_role" env:"GRAPH_ASSIGN_DEFAULT_USER_ROLE" desc:"Whether to assign newly created users the default role 'User'. Set this to 'false' if you want to assign roles manually, or if the role assignment should happen at first login. Set this to 'true' (the default) to assign the role 'User' when creating a new user." introductionVersion:"pre5.0"`
IdentitySearchMinLength int `yaml:"graph_identity_search_min_length" env:"GRAPH_IDENTITY_SEARCH_MIN_LENGTH" desc:"The minimum length the search term needs to have for unprivileged users when searching for users or groups." introductionVersion:"5.0"`
ShowUserEmailInResults bool `yaml:"show_email_in_results" env:"OCIS_SHOW_USER_EMAIL_IN_RESULTS" desc:"Include user email addresses in responses. If absent or set to false emails will be omitted from results. Please note that admin users can always see all email addresses." introductionVersion:"6.0.0"`
GroupMembersPatchLimit int `yaml:"group_members_patch_limit" env:"GRAPH_GROUP_MEMBERS_PATCH_LIMIT" desc:"The amount of group members allowed to be added with a single patch request." introductionVersion:"pre5.0"`
UsernameMatch string `yaml:"graph_username_match" env:"GRAPH_USERNAME_MATCH" desc:"Apply restrictions to usernames. Supported values are 'default' and 'none'. When set to 'default', user names must not start with a number and are restricted to ASCII characters. When set to 'none', no restrictions are applied. The default value is 'default'." introductionVersion:"pre5.0"`
AssignDefaultUserRole bool `yaml:"graph_assign_default_user_role" env:"GRAPH_ASSIGN_DEFAULT_USER_ROLE" desc:"Whether to assign newly created users the default role 'User'. Set this to 'false' if you want to assign roles manually, or if the role assignment should happen at first login. Set this to 'true' (the default) to assign the role 'User' when creating a new user." introductionVersion:"pre5.0"`
IdentitySearchMinLength int `yaml:"graph_identity_search_min_length" env:"GRAPH_IDENTITY_SEARCH_MIN_LENGTH" desc:"The minimum length the search term needs to have for unprivileged users when searching for users or groups." introductionVersion:"5.0"`
ShowUserEmailInResults bool `yaml:"show_email_in_results" env:"OCIS_SHOW_USER_EMAIL_IN_RESULTS" desc:"Include user email addresses in responses. If absent or set to false emails will be omitted from results. Please note that admin users can always see all email addresses." introductionVersion:"6.0.0" deprecationVersion:"Balch" removalVersion:"%%NEXT_PRODUCTION_VERSION%%" deprecationInfo:"Deprecating in favor of a unified array with arbitrary attributes" deprecationReplacement:"UserSearchDisplayedAttributes"`
UserSearchDisplayedAttributes []string `yaml:"user_search_displayed_attributes" env:"OCIS_USER_SEARCH_DISPLAYED_ATTRIBUTES" desc:"The attributes to display in the user search results." introductionVersion:"Balch"`
}
// Events combines the configuration options for the event bus.

View File

@@ -2,6 +2,7 @@ package svc
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -209,6 +210,92 @@ func (g Graph) contextUserHasFullAccountPerms(reqctx context.Context) bool {
return true
}
// UserWithAttributes is a wrapper for the User type that includes attributes.
// Attributes are a list of attributes that are displayed in the user search results.
type UserWithAttributes struct {
*libregraph.User
Attributes []string `json:"attributes"`
}
// MarshalJSON is a custom marshaler for the UserWithAttributes type.
// It marshals the User type and the Attributes type.
func (u UserWithAttributes) MarshalJSON() ([]byte, error) {
if u.User == nil {
return []byte("null"), nil
}
userMap, err := u.User.ToMap()
if err != nil {
return nil, err
}
if u.Attributes != nil {
userMap["attributes"] = u.Attributes
} else {
userMap["attributes"] = []string{}
}
return json.Marshal(userMap)
}
// UnmarshalJSON is a custom unmarshaler for the UserWithAttributes type.
// It unmarshals the User type and the Attributes type.
func (u *UserWithAttributes) UnmarshalJSON(data []byte) error {
raw := make(map[string]json.RawMessage)
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
if attrData, exists := raw["attributes"]; exists {
if err := json.Unmarshal(attrData, &u.Attributes); err != nil {
return err
}
delete(raw, "attributes")
}
if userJSON, err := json.Marshal(raw); err != nil {
return err
} else if err := json.Unmarshal(userJSON, &u.User); err != nil {
return err
}
return nil
}
// getUsersAttributes returns the attributes of the user that are in the allowed list.
func getUsersAttributes(displayedAttributes []string, user *libregraph.User) ([]string, error) {
userMap, err := user.ToMap()
if err != nil {
return []string{}, err
}
attributes := []string{}
for attrStr, val := range userMap {
if !slices.Contains(displayedAttributes, attrStr) {
continue
}
switch v := val.(type) {
case string:
attributes = append(attributes, v)
case *string:
if v != nil {
attributes = append(attributes, *v)
}
case []libregraph.Group:
groups := userMap[attrStr].([]libregraph.Group)
for _, group := range groups {
attributes = append(attributes, *group.DisplayName)
}
default:
// skip unsupported types
}
}
return attributes, nil
}
// GetUsers implements the Service interface.
func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
@@ -292,24 +379,6 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
return
}
// If the user isn't admin, we'll show just the minimum user attibutes
if !ctxHasFullPerms {
finalUsers := make([]*libregraph.User, len(users))
for i, u := range users {
finalUsers[i] = &libregraph.User{
Id: u.Id,
DisplayName: u.DisplayName,
UserType: u.UserType,
Identities: u.Identities,
}
if g.config.API.ShowUserEmailInResults {
finalUsers[i].Mail = u.Mail
}
}
users = finalUsers
}
exp, err := identity.GetExpandValues(odataReq.Query)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users: $expand error")
@@ -340,8 +409,42 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
usersWithAttributes := make([]*UserWithAttributes, 0, len(users))
displayedAttributes := g.config.API.UserSearchDisplayedAttributes
if g.config.API.ShowUserEmailInResults && !slices.Contains(displayedAttributes, "mail") {
displayedAttributes = append([]string{"mail"}, displayedAttributes...)
}
for _, user := range users {
attributes, err := getUsersAttributes(displayedAttributes, user)
if err != nil {
logger.Debug().Err(err).Str("user", user.GetId()).Msg("could not get user attributes")
}
// If the user isn't admin, we'll show just the minimum user attributes
finalUser := &libregraph.User{
Id: user.Id,
DisplayName: user.DisplayName,
UserType: user.UserType,
Identities: user.Identities,
}
if ctxHasFullPerms {
finalUser = user
} else if g.config.API.ShowUserEmailInResults || slices.Contains(displayedAttributes, "mail") {
// Remove this once `ShowUserEmailInResults` is removed
finalUser.Mail = user.Mail
}
usersWithAttributes = append(usersWithAttributes, &UserWithAttributes{
User: finalUser,
Attributes: attributes,
})
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: users})
render.JSON(w, r, &ListResponse{Value: usersWithAttributes})
}
// PostUser implements the Service interface.
@@ -567,7 +670,7 @@ func (g Graph) GetUser(w http.ResponseWriter, r *http.Request) {
}
}
if !g.config.API.ShowUserEmailInResults {
if !g.config.API.ShowUserEmailInResults && !slices.Contains(g.config.API.UserSearchDisplayedAttributes, "mail") {
user.Mail = nil
}

View File

@@ -39,7 +39,7 @@ import (
)
type userList struct {
Value []*libregraph.User
Value []*service.UserWithAttributes
}
var _ = Describe("Users", func() {
@@ -344,6 +344,91 @@ var _ = Describe("Users", func() {
}
})
Describe("user attributes", func() {
var (
user *libregraph.User
users []*libregraph.User
)
BeforeEach(func() {
cfg.API.UserSearchDisplayedAttributes = []string{"displayName", "onPremisesSamAccountName"}
cfg.API.ShowUserEmailInResults = true
user = libregraph.NewUser("Albert Einstein", "einstein")
user.SetId("user1")
user.SetMail("albert.einstein@example.com")
user.SetUserType("Member")
user.SetPasswordProfile(libregraph.PasswordProfile{Password: libregraph.PtrString("secret")})
users = []*libregraph.User{user}
identityBackend.On("GetUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
})
It("returns full user objects and configured attributes for privileged users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
Permission: &settingsmsg.Permission{
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$search=einstein", nil)
r = r.WithContext(mfa.Set(r.Context(), true))
svc.GetUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var res userList
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(res.Value).To(HaveLen(1))
Expect(res.Value[0].User.GetId()).To(Equal("user1"))
Expect(res.Value[0].User.GetDisplayName()).To(Equal("Albert Einstein"))
Expect(res.Value[0].User.GetOnPremisesSamAccountName()).To(Equal("einstein"))
Expect(res.Value[0].User.GetMail()).To(Equal("albert.einstein@example.com"))
Expect(res.Value[0].User.HasPasswordProfile()).To(BeTrue())
Expect(res.Value[0].Attributes).To(ConsistOf(
"Albert Einstein",
"einstein",
"albert.einstein@example.com",
))
})
It("returns restricted user objects and configured attributes for unprivileged users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$search=einstein", nil)
svc.GetUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var res userList
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(res.Value).To(HaveLen(1))
Expect(res.Value[0].User.GetId()).To(Equal("user1"))
Expect(res.Value[0].User.GetDisplayName()).To(Equal("Albert Einstein"))
Expect(res.Value[0].User.GetUserType()).To(Equal("Member"))
Expect(res.Value[0].User.GetOnPremisesSamAccountName()).To(Equal(""))
Expect(res.Value[0].User.HasMail()).To(BeTrue())
Expect(res.Value[0].User.HasPasswordProfile()).To(BeFalse())
Expect(res.Value[0].Attributes).To(ConsistOf(
"Albert Einstein",
"einstein",
"albert.einstein@example.com",
))
})
})
It("sorts", func() {
user := &libregraph.User{}
user.SetId("user1")
@@ -366,7 +451,7 @@ var _ = Describe("Users", func() {
},
}, nil)
getUsers := func(path string) []*libregraph.User {
getUsers := func(path string) []*service.UserWithAttributes {
r := httptest.NewRequest(http.MethodGet, path, nil)
r = r.WithContext(mfa.Set(r.Context(), true))
rec := httptest.NewRecorder()

View File

@@ -1050,6 +1050,7 @@ Feature: get users
"type": "object",
"required": ["displayName", "id", "userType", "onPremisesSamAccountName"],
"properties": {
"attributes": {"const": []},
"displayName": {"const": "special user"},
"id": {"pattern": "^%user_id_pattern%$"},
"userType": {"const": "Member"},

View File

@@ -272,12 +272,17 @@ Feature: edit/search user including email
"items": {
"type": "object",
"required": [
"attributes",
"displayName",
"id",
"mail",
"userType"
],
"properties": {
"attributes": {
"type": "array",
"maxItems": 1,
"minItems": 1
},
"displayName": {
"const": "Alice Hansen"
},
@@ -285,9 +290,6 @@ Feature: edit/search user including email
"type": "string",
"pattern": "^%user_id_pattern%$"
},
"mail": {
"const": "alice@example.org"
},
"userType": {
"const": "Member"
}
@@ -320,12 +322,17 @@ Feature: edit/search user including email
"items": {
"type": "object",
"required": [
"attributes",
"displayName",
"id",
"mail",
"userType"
],
"properties": {
"attributes": {
"type": "array",
"maxItems": 1,
"minItems": 1
},
"displayName": {
"const": "Alice Hansen"
},
@@ -364,12 +371,17 @@ Feature: edit/search user including email
"items": {
"type": "object",
"required": [
"attributes",
"displayName",
"id",
"mail",
"userType"
],
"properties": {
"attributes": {
"type": "array",
"maxItems": 1,
"minItems": 1
},
"displayName": {
"const": "Alice Hansen"
},
@@ -415,12 +427,17 @@ Feature: edit/search user including email
{
"type": "object",
"required": [
"attributes",
"displayName",
"id",
"mail",
"userType"
],
"properties": {
"attributes": {
"type": "array",
"maxItems": 1,
"minItems": 1
},
"displayName": {
"const": "Alice Hansen"
},
@@ -439,12 +456,17 @@ Feature: edit/search user including email
{
"type": "object",
"required": [
"attributes",
"displayName",
"id",
"mail",
"userType"
],
"properties": {
"attributes": {
"type": "array",
"maxItems": 1,
"minItems": 1
},
"displayName": {
"const": "Alice Murphy"
},