Merge pull request #9788 from rhafer/filter-usertype

feat(graph): add support for filtering users by type
This commit is contained in:
Viktor Scharf
2024-08-14 10:11:25 +02:00
committed by GitHub
7 changed files with 210 additions and 54 deletions

View File

@@ -0,0 +1,14 @@
Enhancement: OCM related adjustments in graph
The /users enpdoint of the graph service was changed with respect to how
it handles OCM federeated users:
- The 'userType' property is now alway returned. As new usertype 'Federated'
was introduced. To indicate that the user is a federated user.
- Supported for filtering users by 'userType' as added. Queries like
"$filter=userType eq 'Federated'" are now possible.
- Federated users are only returned when explicitly requested via filter.
When no filter is provider only 'Member' users are returned.
https://github.com/owncloud/ocis/pull/9788
https://github.com/owncloud/ocis/pull/9757
https://github.com/owncloud/ocis/issues/9702

View File

@@ -237,7 +237,21 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
return
}
if !ctxHasFullPerms && (odataReq.Query.Filter != nil || odataReq.Query.Apply != nil || odataReq.Query.Expand != nil || odataReq.Query.Compute != nil) {
if !ctxHasFullPerms && odataReq.Query.Filter != nil {
// regular users are allowed to filter only by userType
filter := odataReq.Query.Filter
switch {
case filter.Tree.Token.Type != godata.ExpressionTokenLogical:
fallthrough
case filter.Tree.Token.Value != "eq":
fallthrough
case filter.Tree.Children[0].Token.Value != "userType":
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden filter for a regular user")
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "filter has forbidden elements for regular users")
return
}
}
if !ctxHasFullPerms && (odataReq.Query.Apply != nil || odataReq.Query.Expand != nil || odataReq.Query.Compute != nil) {
// regular users can't use filter, apply, expand and compute
logger.Debug().Interface("query", r.URL.Query()).Msg("forbidden query elements for a regular user")
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "query has forbidden elements for regular users")
@@ -254,34 +268,6 @@ func (g Graph) GetUsers(w http.ResponseWriter, r *http.Request) {
users, err = g.identityBackend.GetUsers(r.Context(), odataReq)
}
if g.config.IncludeOCMSharees {
gwc, err := g.gatewaySelector.Next()
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
term, err := identity.GetSearchValues(odataReq.Query)
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusBadRequest, err.Error())
return
}
remoteUsersRes, err := gwc.FindAcceptedUsers(r.Context(), &invitepb.FindAcceptedUsersRequest{Filter: term})
if err != nil {
// TODO grpc FindAcceptedUsers call failed
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if remoteUsersRes.Status.Code != cs3rpc.Code_CODE_OK {
// TODO "error searching remote users"
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, remoteUsersRes.Status.Message)
return
}
for _, user := range remoteUsersRes.GetAcceptedUsers() {
users = append(users, identity.CreateUserModelFromCS3(user))
}
}
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get users from backend")
var errcode errorcode.Error
@@ -1014,3 +1000,38 @@ func isValidUserType(userType string) bool {
return false
}
func (g Graph) searchOCMAcceptedUsers(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.User, error) {
logger := g.logger.SubloggerWithRequestID(ctx)
users := []*libregraph.User{}
if !g.config.IncludeOCMSharees {
logger.Debug().Msg("OCM sharing is disabled")
return users, nil
}
gwc, err := g.gatewaySelector.Next()
if err != nil {
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
term, err := identity.GetSearchValues(odataReq.Query)
if err != nil {
return nil, errorcode.New(errorcode.InvalidRequest, err.Error())
}
remoteUsersRes, err := gwc.FindAcceptedUsers(ctx, &invitepb.FindAcceptedUsersRequest{Filter: term})
if err != nil {
logger.Error().Err(err).Msg("FindAcceptedUsers call failed")
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
if remoteUsersRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return nil, errorcode.FromCS3Status(remoteUsersRes.Status, nil)
}
cs3Users := remoteUsersRes.GetAcceptedUsers()
users = make([]*libregraph.User, 0, len(cs3Users))
for _, user := range cs3Users {
users = append(users, identity.CreateUserModelFromCS3(user))
}
return users, nil
}

View File

@@ -124,19 +124,19 @@ func (g Graph) applyFilterLogical(ctx context.Context, req *godata.GoDataRequest
return users, invalidFilterError()
}
// As we currently don't suppport the 'has' or 'in' operator, all our
// currently supported user filters of the ExpressionTokenLogical type
// require exactly two operands.
if len(root.Children) != 2 {
return users, invalidFilterError()
}
switch root.Token.Value {
case "and":
// 'and' needs 2 operands
if len(root.Children) != 2 {
return users, invalidFilterError()
}
return g.applyFilterLogicalAnd(ctx, req, root.Children[0], root.Children[1])
case "or":
// 'or' needs 2 operands
if len(root.Children) != 2 {
return users, invalidFilterError()
}
return g.applyFilterLogicalOr(ctx, req, root.Children[0], root.Children[1])
case "eq":
return g.applyFilterEq(ctx, req, root.Children[0], root.Children[1])
}
logger.Debug().Str("Token", root.Token.Value).Msg("unsupported logical filter")
return users, unsupportedFilterError()
@@ -222,6 +222,29 @@ func (g Graph) applyFilterLogicalOr(ctx context.Context, req *godata.GoDataReque
}
return filteredUsers, nil
}
func (g Graph) applyFilterEq(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
// We only support the 'eq' on 'userType' for now
switch {
case operand1.Token.Type != godata.ExpressionTokenLiteral:
fallthrough
case operand1.Token.Value != "userType":
fallthrough
case operand2.Token.Type != godata.ExpressionTokenString:
return users, unsupportedFilterError()
}
// unquote
value := strings.Trim(operand2.Token.Value, "'")
switch value {
case "Member", "Guest":
return g.identityBackend.GetUsers(ctx, req)
case "Federated":
return g.searchOCMAcceptedUsers(ctx, req)
}
return users, unsupportedFilterError()
}
func (g Graph) applyFilterLambda(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
logger := g.logger.SubloggerWithRequestID(ctx)
if len(nodes) != 2 {

View File

@@ -291,6 +291,14 @@ var _ = Describe("Users", func() {
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("denies filters other that 'userType eq ...' 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=%22abc%22&$filter="+url.QueryEscape("memberOf/any(n:n/id eq 25cb7bc0-3168-4a0c-adbe-396f478ad494)"), nil)
svc.GetUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("only returns a restricted set of attributes for unprivileged users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
user := &libregraph.User{}
@@ -503,8 +511,8 @@ var _ = Describe("Users", func() {
"appRoleAssignments/all(n:n/appRoleId eq 'id') and appRoleAssignments/any(n:n/appRoleId eq 'id')", http.StatusNotImplemented),
Entry("with unsupported appRoleAssignments lambda filter operation",
"appRoleAssignments/any(n:n/appRoleId ne 'id') and appRoleAssignments/any(n:n/appRoleId eq 'id')", http.StatusNotImplemented),
Entry("with unsupported appRoleAssignments lambda filter operation",
"appRoleAssignments/any(n:n/appRoleId eq 1) and appRoleAssignments/any(n:n/appRoleId eq 'id')", http.StatusNotImplemented),
Entry("with unsupported userType in filter", "userType eq 'unsupported'", http.StatusNotImplemented),
Entry("with unsupported property in eq filter", "unsupported eq 'unsupported'", http.StatusNotImplemented),
)
DescribeTable("With a valid filter",
@@ -1125,7 +1133,28 @@ var _ = Describe("Users", func() {
)
})
Describe("GetUsers", func() {
It("lists the users", func() {
It("does not list the federated users without a filter", func() {
gatewayClient.AssertNotCalled(GinkgoT(), "FindAcceptedUsers, mock.Anything, mock.Anything")
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
identityBackend.On("GetUsers", mock.Anything, mock.Anything, mock.Anything).Return(
[]*libregraph.User{}, nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$search=user", nil)
svc.GetUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := userList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(0))
})
It("does list federated users when the right filter is used", func() {
gatewayClient.On("FindAcceptedUsers", mock.Anything, mock.Anything).Return(&invitepb.FindAcceptedUsersResponse{
Status: status.NewOK(ctx),
AcceptedUsers: []*userv1beta1.User{
@@ -1138,18 +1167,13 @@ var _ = Describe("Users", func() {
},
},
}, nil)
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{
Permission: &settingsmsg.Permission{
Operation: settingsmsg.Permission_OPERATION_UNKNOWN,
Constraint: settingsmsg.Permission_CONSTRAINT_ALL,
},
}, nil)
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
identityBackend.On("GetUsers", mock.Anything, mock.Anything, mock.Anything).Return(
[]*libregraph.User{}, nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users", nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$search=federated&$filter="+url.QueryEscape("userType eq 'Federated'"), nil)
svc.GetUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
@@ -1164,6 +1188,33 @@ var _ = Describe("Users", func() {
Expect(res.Value[0].GetId()).To(Equal("federated"))
Expect(res.Value[0].GetUserType()).To(Equal("Federated"))
})
It("does not list federated users when filtering for 'Member' users", func() {
permissionService.On("GetPermissionByID", mock.Anything, mock.Anything).Return(&settings.GetPermissionByIDResponse{}, nil)
user := &libregraph.User{}
user.SetId("user1")
user.SetUserType("Member")
users := []*libregraph.User{user}
identityBackend.On("GetUsers", mock.Anything, mock.Anything, mock.Anything).Return(
users, nil,
)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$search=user&$filter="+url.QueryEscape("userType eq 'Member'"), nil)
svc.GetUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := userList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetId()).To(Equal("user1"))
Expect(res.Value[0].GetUserType()).To(Equal("Member"))
})
})
})
})

View File

@@ -306,6 +306,32 @@ class GraphHelper {
);
}
/**
* @param string $baseUrl
* @param string $xRequestId
* @param string $adminUser
* @param string $adminPassword
* @param string $searchTerm
*
* @return ResponseInterface
*/
public static function searchFederatedUser(
string $baseUrl,
string $xRequestId,
string $adminUser,
string $adminPassword,
string $searchTerm
): ResponseInterface {
$url = self::getFullUrl($baseUrl, "users?\$filter=userType eq 'Federated'&\$search=$searchTerm");
return HttpRequestHelper::get(
$url,
$xRequestId,
$adminUser,
$adminPassword,
self::getRequestHeaders()
);
}
/**
* @param string $baseUrl
* @param string $xRequestId

View File

@@ -18,7 +18,7 @@ Feature: search federation users
And "Alice" has created the federation share invitation
And using server "REMOTE"
And "Brian" has accepted invitation
When user "Brian" searches for user "ali" using Graph API
When user "Brian" searches for federated user "ali" using Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
@@ -53,7 +53,7 @@ Feature: search federation users
}
"""
And using server "LOCAL"
When user "Alice" searches for user "bri" using Graph API
When user "Alice" searches for federated user "bri" using Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
@@ -94,7 +94,7 @@ Feature: search federation users
And "Alice" has created the federation share invitation
And using server "REMOTE"
And "Brian" has accepted invitation
When user "Brian" searches for user "%22alice@example.org%22" using Graph API
When user "Brian" searches for federated user "%22alice@example.org%22" using Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
@@ -129,7 +129,7 @@ Feature: search federation users
}
"""
And using server "LOCAL"
When user "Alice" searches for user "%22brian@example.org%22" using Graph API
When user "Alice" searches for federated user "%22brian@example.org%22" using Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
@@ -170,7 +170,7 @@ Feature: search federation users
And "Alice" has created the federation share invitation
And using server "REMOTE"
And "Brian" has accepted invitation
When user "Brian" searches for user "%22carol@example.org%22" using Graph API
When user "Brian" searches for federated user "%22carol@example.org%22" using Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
@@ -189,7 +189,7 @@ Feature: search federation users
}
"""
And using server "LOCAL"
When user "Carol" searches for user "bria" using Graph API
When user "Carol" searches for federated user "bria" using Graph API
Then the HTTP status code should be "200"
And the JSON data of the response should match
"""
@@ -325,4 +325,4 @@ Feature: search federation users
"""
# TODO try to find federation users after deleting federated conection
# TODO try to find federation users after deleting federated conection

View File

@@ -1253,6 +1253,27 @@ class GraphContext implements Context {
$this->featureContext->setResponse($response);
}
/**
* @When user :byUser searches for federated user :searchTerm using Graph API
*
* @param string $byUser
* @param string $searchTerm
*
* @return void
* @throws GuzzleException
*/
public function userSearchesForFederatedUserUsingGraphApi(string $byUser, string $searchTerm): void {
$credentials = $this->getAdminOrUserCredentials($byUser);
$response = GraphHelper::searchFederatedUser(
$this->featureContext->getBaseUrl(),
$this->featureContext->getStepLineRef(),
$credentials['username'],
$credentials['password'],
$searchTerm,
);
$this->featureContext->setResponse($response);
}
/**
* @When user :user tries to get all users using the Graph API
* @When user :user gets all users using the Graph API