Repository: owncloud/ocis
Pull request #5573 (2023-02-14 16:10:52)

Add support for $filter on appRoleAssignment
This add support for filtering on the `appRoleAssignment` relation of users.  E.g.

```
$filter=appRoleAssignments/any(m:m/appRoleId eq '262982c1-2362-4afa-bfdf-8cbfef64a06e')
```

combining it with a filter on groupMemberShip does also work:

```
$filter=memberOf/any(m:m/id eq '262982c1-2362-4afa-bfdf-8cbfef64a06e') and appRoleAssignments/any(m:m/appRoleId eq 'd7beeea8-8ff4-406b-8fb6-ab2dd81e6b11')
```

The filter is still very inefficient as it always needs to get the full users list. We need to adapt it to only filter on a subset of users when using this filter 'and' combined with other filters.

Closes: #5488

update-docs[bot]: Thanks for opening this pull request! The maintainers of this repository would appreciate it if you would create a [changelog](https://github.com/owncloud/ocis/blob/master/changelog/README.md) item based on your changes.

sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5573)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [![C](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/C-16px.png 'C')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [1 Bug](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL)

[![77.6%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '77.6%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list) [77.6% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list)


mmattel: Will you update https://owncloud.dev/libre-graph-api/#/users/ListUsers accordingly? 😄 
sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5573)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [![C](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/C-16px.png 'C')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [4 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL)

[![86.7%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '86.7%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list) [86.7% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: SonarCloud Quality Gate failed.&nbsp; &nbsp; [![Quality Gate failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/failed-16px.png 'Quality Gate failed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5573)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [![C](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/C-16px.png 'C')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [1 Bug](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL)

[![82.8%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '82.8%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list) [82.8% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list)


rhafer: > Will you update https://owncloud.dev/libre-graph-api/#/users/ListUsers accordingly? smile

https://github.com/owncloud/libre-graph-api/pull/95

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5573)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL)

[![82.8%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '82.8%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list) [82.8% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list)


JammingBen: Filtering roles alone works, but the concatenation with `memberOf` does not work for me :/ Am I missing something?

```
$filter=appRoleAssignments/any(m:m/appRoleId eq '71881883-1768-46bd-a24d-a356a2afdf7f')
```

-> returns some user

```
$filter=memberOf/any(m:m/id eq '37fb8ce9-dedd-453e-867d-cc7f1734c9b0')
```

-> returns the very same user

```
$filter=memberOf/any(m:m/id eq '37fb8ce9-dedd-453e-867d-cc7f1734c9b0') and appRoleAssignments/any(m:m/appRoleId eq '71881883-1768-46bd-a24d-a356a2afdf7f')
```

-> returns `{ "value": [] }`
rhafer: > ```
> $filter=memberOf/any(m:m/id eq '37fb8ce9-dedd-453e-867d-cc7f1734c9b0') and appRoleAssignments/any(m:m/appRoleId eq '71881883-1768-46bd-a24d-a356a2afdf7f')
> ```
> 
> -> returns `{ "value": [] }`

@JammingBen Should be fixed now. Thanks for trying it out!

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5573)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5573&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5573&resolved=false&types=CODE_SMELL)

[![80.4%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/60-16px.png '80.4%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list) [80.4% Coverage](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_coverage&view=list)  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5573&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/services/graph/pkg/service/v0/users_filter.go b/services/graph/pkg/service/v0/users_filter.go
index 63a6579cd5f..a836f5378a7 100644
--- a/services/graph/pkg/service/v0/users_filter.go
+++ b/services/graph/pkg/service/v0/users_filter.go
@@ -8,6 +8,10 @@ import (
 	libregraph "github.com/owncloud/libre-graph-api-go"
 )
 
+const (
+	appRoleID = "appRoleId"
+)
+
 func invalidFilterError() error {
 	return godata.BadRequestError("invalid filter")
 }
@@ -53,22 +57,51 @@ func (g Graph) applyFilterLogical(ctx context.Context, req *godata.GoDataRequest
 
 func (g Graph) applyFilterLogicalAnd(ctx context.Context, req *godata.GoDataRequest, operand1 *godata.ParseNode, operand2 *godata.ParseNode) (users []*libregraph.User, err error) {
 	logger := g.logger.SubloggerWithRequestID(ctx)
-	results := make([][]*libregraph.User, 0, 2)
+	var res1, res2 []*libregraph.User
 
-	for _, node := range []*godata.ParseNode{operand1, operand2} {
-		res, err := g.applyUserFilter(ctx, req, node)
+	// The appRoleAssignmentFilter requires a full list of user, get try to avoid a full user listing and
+	// process the other part of the filter first, if that is an appRoleAssignmentFilter as well, we have
+	// to bite the bullet and get a full user listing anyway
+	if matched, property, value := g.isAppRoleAssignmentFilter(ctx, operand1); matched {
+		logger.Debug().Str("property", property).Str("value", value).Msg("delay processing of approleAssignments filter")
+		if property != appRoleID {
+			return users, unsupportedFilterError()
+		}
+		res2, err = g.applyUserFilter(ctx, req, operand2)
 		if err != nil {
-			return users, err
+			return []*libregraph.User{}, err
+		}
+		logger.Debug().Str("property", property).Str("value", value).Msg("applying approleAssignments filter on result set")
+		return g.filterUsersByAppRoleID(ctx, value, res2)
+	}
+
+	// 1st part is no appRoleAssignmentFilter, run the filter query
+	res1, err = g.applyUserFilter(ctx, req, operand1)
+	if err != nil {
+		return []*libregraph.User{}, err
+	}
+
+	// Now check 2nd part for appRoleAssignmentFilter and apply that using the result return from the first
+	// filter
+	if matched, property, value := g.isAppRoleAssignmentFilter(ctx, operand2); matched {
+		if property != appRoleID {
+			return users, unsupportedFilterError()
 		}
-		logger.Debug().Interface("subfilter", res).Msg("result part")
-		results = append(results, res)
+		logger.Debug().Str("property", property).Str("value", value).Msg("applying approleAssignments filter on result set")
+		return g.filterUsersByAppRoleID(ctx, value, res1)
+	}
+
+	// 2nd part is no appRoleAssignmentFilter either
+	res2, err = g.applyUserFilter(ctx, req, operand2)
+	if err != nil {
+		return []*libregraph.User{}, err
 	}
 
-	// 'results' contains two slices of libregraph.Users now turn one of them
+	// We now have two slice with results of the subfilters. Now turn one of them
 	// into a map for efficiently getting the intersection of both slices
-	userSet := userSliceToMap(results[0])
+	userSet := userSliceToMap(res1)
 	var filteredUsers []*libregraph.User
-	for _, user := range results[1] {
+	for _, user := range res2 {
 		if _, found := userSet[user.GetId()]; found {
 			filteredUsers = append(filteredUsers, user)
 		}
@@ -81,16 +114,22 @@ func (g Graph) applyFilterLambda(ctx context.Context, req *godata.GoDataRequest,
 	if len(nodes) != 2 {
 		return users, invalidFilterError()
 	}
-	// We only support memberOf/any queries for now
-	if nodes[0].Token.Type != godata.ExpressionTokenLiteral || nodes[0].Token.Value != "memberOf" {
-		logger.Debug().Str("Token", nodes[0].Token.Value).Msg("unsupported relation for lambda filter")
-		return users, unsupportedFilterError()
-	}
+	// We only support the "any" operator for lambda queries for now
 	if nodes[1].Token.Type != godata.ExpressionTokenLambda || nodes[1].Token.Value != "any" {
 		logger.Debug().Str("Token", nodes[1].Token.Value).Msg("unsupported lambda filter")
 		return users, unsupportedFilterError()
 	}
-	return g.applyLambdaMemberOfAny(ctx, req, nodes[1].Children)
+	if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
+		return users, unsupportedFilterError()
+	}
+	switch nodes[0].Token.Value {
+	case "memberOf":
+		return g.applyLambdaMemberOfAny(ctx, req, nodes[1].Children)
+	case "appRoleAssignments":
+		return g.applyLambdaAppRoleAssignmentAny(ctx, req, nodes[1].Children)
+	}
+	logger.Debug().Str("Token", nodes[0].Token.Value).Msg("unsupported relation for lambda filter")
+	return users, unsupportedFilterError()
 }
 
 func (g Graph) applyLambdaMemberOfAny(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
@@ -126,22 +165,150 @@ func (g Graph) applyMemberOfEq(ctx context.Context, req *godata.GoDataRequest, n
 
 	switch nodes[0].Children[1].Token.Value {
 	case "id":
-		var filterValue string
-		switch nodes[1].Token.Type {
-		case godata.ExpressionTokenGuid:
-			filterValue = nodes[1].Token.Value
-		case godata.ExpressionTokenString:
-			// unquote
-			filterValue = strings.Trim(nodes[1].Token.Value, "'")
-		default:
+		filterValue, err := g.getUUIDTokenValue(ctx, nodes[1])
+		if err != nil {
 			return users, unsupportedFilterError()
 		}
+
 		logger.Debug().Str("property", nodes[0].Children[1].Token.Value).Str("value", filterValue).Msg("Filtering memberOf by group id")
 		return g.identityBackend.GetGroupMembers(ctx, filterValue, req)
 	default:
 		return users, unsupportedFilterError()
 	}
+}
+
+func (g Graph) applyLambdaAppRoleAssignmentAny(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
+	if len(nodes) != 2 {
+		return users, invalidFilterError()
+	}
+
+	// First element is the "name" of the lambda function's parameter
+	if nodes[0].Token.Type != godata.ExpressionTokenLiteral {
+		return users, invalidFilterError()
+	}
+
+	// We only support the 'eq' expression for now
+	if nodes[1].Token.Type != godata.ExpressionTokenLogical || nodes[1].Token.Value != "eq" {
+		return users, unsupportedFilterError()
+	}
+	return g.applyAppRoleAssignmentEq(ctx, req, nodes[1].Children)
+}
+
+func (g Graph) applyAppRoleAssignmentEq(ctx context.Context, req *godata.GoDataRequest, nodes []*godata.ParseNode) (users []*libregraph.User, err error) {
+	logger := g.logger.SubloggerWithRequestID(ctx)
+	if len(nodes) != 2 {
+		return users, invalidFilterError()
+	}
+
+	if nodes[0].Token.Type != godata.ExpressionTokenNav {
+		return users, invalidFilterError()
+	}
 
+	if len(nodes[0].Children) != 2 {
+		return users, invalidFilterError()
+	}
+
+	if nodes[0].Children[1].Token.Value == appRoleID {
+		filterValue, err := g.getUUIDTokenValue(ctx, nodes[1])
+		if err != nil {
+			return users, unsupportedFilterError()
+		}
+
+		logger.Debug().Str("property", nodes[0].Children[1].Token.Value).Str("value", filterValue).Msg("Filtering appRoleAssignments by appRoleId")
+		if users, err = g.identityBackend.GetUsers(ctx, req); err != nil {
+			return users, err
+		}
+
+		return g.filterUsersByAppRoleID(ctx, filterValue, users)
+	}
+	return users, unsupportedFilterError()
+}
+
+func (g Graph) filterUsersByAppRoleID(ctx context.Context, id string, users []*libregraph.User) ([]*libregraph.User, error) {
+	// We're using a map for the results here, in order to avoid returning
+	// a user twice. The settings API, still has an issue that causes it to
+	// duplicate some assignments on restart:
+	// https://github.com/owncloud/ocis/issues/3432
+	resultUsersMap := make(map[string]*libregraph.User, len(users))
+	for _, user := range users {
+		assignments, err := g.fetchAppRoleAssignments(ctx, user.GetId())
+		if err != nil {
+			return users, err
+		}
+		for _, assignment := range assignments {
+			if assignment.GetAppRoleId() == id {
+				if _, ok := resultUsersMap[user.GetId()]; !ok {
+					resultUsersMap[user.GetId()] = user
+				}
+			}
+		}
+	}
+	resultUsers := make([]*libregraph.User, 0, len(resultUsersMap))
+	for _, user := range resultUsersMap {
+		resultUsers = append(resultUsers, user)
+	}
+	return resultUsers, nil
+}
+
+func (g Graph) getUUIDTokenValue(ctx context.Context, node *godata.ParseNode) (string, error) {
+	var value string
+	switch node.Token.Type {
+	case godata.ExpressionTokenGuid:
+		value = node.Token.Value
+	case godata.ExpressionTokenString:
+		// unquote
+		value = strings.Trim(node.Token.Value, "'")
+	default:
+		return "", unsupportedFilterError()
+	}
+	return value, nil
+}
+
+func (g Graph) isAppRoleAssignmentFilter(ctx context.Context, node *godata.ParseNode) (match bool, property string, filter string) {
+	if node.Token.Type != godata.ExpressionTokenLambdaNav {
+		return false, "", ""
+	}
+
+	if len(node.Children) != 2 {
+		return false, "", ""
+	}
+
+	if node.Children[0].Token.Type != godata.ExpressionTokenLiteral || node.Children[0].Token.Value != "appRoleAssignments" {
+		return false, "", ""
+	}
+
+	if node.Children[1].Token.Type != godata.ExpressionTokenLambda || node.Children[1].Token.Value != "any" {
+		return false, "", ""
+	}
+
+	if len(node.Children[1].Children) != 2 {
+		return false, "", ""
+	}
+	lambdaParam := node.Children[1].Children
+	// We only support the 'eq' expression for now
+	if lambdaParam[1].Token.Type != godata.ExpressionTokenLogical || lambdaParam[1].Token.Value != "eq" {
+		return false, "", ""
+	}
+
+	if len(lambdaParam[1].Children) != 2 {
+		return false, "", ""
+	}
+	expression := lambdaParam[1].Children
+	if expression[0].Token.Type != godata.ExpressionTokenNav || expression[0].Token.Value != "/" {
+		return false, "", ""
+	}
+
+	if len(expression[0].Children) != 2 {
+		return false, "", ""
+	}
+	if expression[0].Children[1].Token.Value != appRoleID {
+		return false, "", ""
+	}
+	filterValue, err := g.getUUIDTokenValue(ctx, expression[1])
+	if err != nil {
+		return false, "", ""
+	}
+	return true, appRoleID, filterValue
 }
 
 func userSliceToMap(users []*libregraph.User) map[string]*libregraph.User {


Test patch:
diff --git a/services/graph/pkg/service/v0/users_test.go b/services/graph/pkg/service/v0/users_test.go
index 870368d3da6..b020ef20732 100644
--- a/services/graph/pkg/service/v0/users_test.go
+++ b/services/graph/pkg/service/v0/users_test.go
@@ -340,16 +340,36 @@ var _ = Describe("Users", func() {
 		Entry("with unsupported lambda token ", "memberOf/all(n:n/id eq 1)", http.StatusNotImplemented),
 		Entry("with unsupported filter operation ", "memberOf/any(n:n/id ne 1)", http.StatusNotImplemented),
 		Entry("with unsupported filter operand type", "memberOf/any(n:n/id eq 1)", http.StatusNotImplemented),
-		Entry("with unsupported lambda filter property", "memberOf/any(n:n/name eq 'name')", http.StatusNotImplemented),
+		Entry("with unsupported memberOf lambda filter property", "memberOf/any(n:n/name eq 'name')", http.StatusNotImplemented),
+		Entry("with unsupported appRoleAssignments lambda filter property", "appRoleAssignments/any(n:n/id eq 'id')", http.StatusNotImplemented),
+		Entry("with unsupported appRoleAssignments lambda filter property",
+			"appRoleAssignments/any(n:n/id eq 'id') and appRoleAssignments/any(n:n/id eq 'id')", http.StatusNotImplemented),
+		Entry("with unsupported appRoleAssignments lambda filter operation",
+			"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),
 	)
 
-	DescribeTable("With a valid memberOf filter",
+	DescribeTable("With a valid filter",
 		func(filter string, status int) {
 			user := &libregraph.User{}
 			user.SetId("25cb7bc0-3168-4a0c-adbe-396f478ad494")
 			users := []*libregraph.User{user}
 			identityBackend.On("GetGroupMembers", mock.Anything, "25cb7bc0-3168-4a0c-adbe-396f478ad494", mock.Anything).Return(users, nil)
 			identityBackend.On("GetGroupMembers", mock.Anything, "2713f1d5-6822-42bd-ad56-9f6c55a3a8fa", mock.Anything).Return([]*libregraph.User{}, nil)
+			identityBackend.On("GetUsers", mock.Anything, mock.Anything).Return([]*libregraph.User{user}, nil)
+			roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).
+				Return(func(ctx context.Context, in *settings.ListRoleAssignmentsRequest, opts ...client.CallOption) *settings.ListRoleAssignmentsResponse {
+					return &settings.ListRoleAssignmentsResponse{Assignments: []*settingsmsg.UserRoleAssignment{
+						{
+							Id:          "some-appRoleAssignment-ID",
+							AccountUuid: user.GetId(),
+							RoleId:      "some-appRole-ID",
+						},
+					}}
+				}, nil)
 			r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users?$filter="+url.QueryEscape(filter), nil)
 			svc.GetUsers(rr, r)
 
@@ -357,9 +377,13 @@ var _ = Describe("Users", func() {
 		},
 		Entry("with memberOf lambda filter with UUID", "memberOf/any(n:n/id eq 25cb7bc0-3168-4a0c-adbe-396f478ad494)", http.StatusOK),
 		Entry("with memberOf lambda filter with UUID string", "memberOf/any(n:n/id eq '25cb7bc0-3168-4a0c-adbe-396f478ad494')", http.StatusOK),
+		Entry("with appRoleAssignments lambda filter with appRoleId", "appRoleAssignments/any(n:n/appRoleId eq 'some-appRole-ID')", http.StatusOK),
 		Entry("with two memberOf lambda filters",
 			"memberOf/any(n:n/id eq 25cb7bc0-3168-4a0c-adbe-396f478ad494) and memberOf/any(n:n/id eq 2713f1d5-6822-42bd-ad56-9f6c55a3a8fa)",
 			http.StatusOK),
+		Entry("with supported appRoleAssignments lambda filter property",
+			"appRoleAssignments/any(n:n/appRoleId eq 'some-appRoleAssignment-ID') and memberOf/any(n:n/id eq 2713f1d5-6822-42bd-ad56-9f6c55a3a8fa)",
+			http.StatusOK),
 	)
 
 	Describe("GetUser", func() {