diff --git a/docs/docs/apis/openidoauth/claims.md b/docs/docs/apis/openidoauth/claims.md index 2ee804ad68..74c079c9c1 100644 --- a/docs/docs/apis/openidoauth/claims.md +++ b/docs/docs/apis/openidoauth/claims.md @@ -73,13 +73,14 @@ You can add custom claims using the [complement token flow](/docs/apis/actions/c ZITADEL reserves some claims to assert certain data. Please check out the [reserved scopes](scopes#reserved-scopes). -| Claims | Example | Description | -|:--------------------------------------------------|:-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| urn:zitadel:iam:action:{actionname}:log | `{"urn:zitadel:iam:action:appendCustomClaims:log": ["test log", "another test log"]}` | This claim is set during Actions as a log, e.g. if two custom claims with the same keys are set. | -| urn:zitadel:iam:org:domain:primary:{domainname} | `{"urn:zitadel:iam:org:domain:primary": "acme.ch"}` | This claim represents the primary domain of the organization the user belongs to. | -| urn:zitadel:iam:org:project:roles | `{"urn:zitadel:iam:org:project:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role. | -| urn:zitadel:iam:roles:{rolename} | TBA | TBA | -| urn:zitadel:iam:user:metadata | `{"urn:zitadel:iam:user:metadata": [ {"key": "VmFsdWU=" } ] }` | The metadata claim will include all metadata of a user. The values are base64 encoded. | -| urn:zitadel:iam:user:resourceowner:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the id of the resource owner organisation of the user. | -| urn:zitadel:iam:user:resourceowner:name | `{"urn:zitadel:iam:user:resourceowner:name": "ACME"}` | This claim represents the name of the resource owner organisation of the user. | -| urn:zitadel:iam:user:resourceowner:primary_domain | `{"urn:zitadel:iam:user:resourceowner:primary_domain": "acme.ch"}` | This claim represents the primary domain of the resource owner organisation of the user. | +| Claims | Example | Description | +|:--------------------------------------------------|:---------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| urn:zitadel:iam:action:{actionname}:log | `{"urn:zitadel:iam:action:appendCustomClaims:log": ["test log", "another test log"]}` | This claim is set during Actions as a log, e.g. if two custom claims with the same keys are set. | +| urn:zitadel:iam:org:domain:primary:{domainname} | `{"urn:zitadel:iam:org:domain:primary": "acme.ch"}` | This claim represents the primary domain of the organization the user belongs to. | +| urn:zitadel:iam:org:project:roles | `{"urn:zitadel:iam:org:project:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on the current project (where your client belongs to). | +| urn:zitadel:iam:org:project:{projectid}:roles | `{"urn:zitadel:iam:org:project:id3:roles": [ {"user": {"id1": "acme.zitade.ch", "id2": "caos.ch"} } ] }` | When roles are asserted, ZITADEL does this by providing the `id` and `primaryDomain` below the role. This gives you the option to check in which organization a user has the role on a specific project. | +| urn:zitadel:iam:roles:{rolename} | TBA | TBA | +| urn:zitadel:iam:user:metadata | `{"urn:zitadel:iam:user:metadata": [ {"key": "VmFsdWU=" } ] }` | The metadata claim will include all metadata of a user. The values are base64 encoded. | +| urn:zitadel:iam:user:resourceowner:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the id of the resource owner organisation of the user. | +| urn:zitadel:iam:user:resourceowner:name | `{"urn:zitadel:iam:user:resourceowner:name": "ACME"}` | This claim represents the name of the resource owner organisation of the user. | +| urn:zitadel:iam:user:resourceowner:primary_domain | `{"urn:zitadel:iam:user:resourceowner:primary_domain": "acme.ch"}` | This claim represents the primary domain of the resource owner organisation of the user. | diff --git a/docs/docs/apis/openidoauth/scopes.md b/docs/docs/apis/openidoauth/scopes.md index 4305c6319b..387115d77d 100644 --- a/docs/docs/apis/openidoauth/scopes.md +++ b/docs/docs/apis/openidoauth/scopes.md @@ -22,14 +22,15 @@ ZITADEL supports the usage of scopes as way of requesting information from the I In addition to the standard compliant scopes we utilize the following scopes. -| Scopes | Example | Description | -| :------------------------------------------------ | :----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `urn:zitadel:iam:org:project:role:{rolekey}` | `urn:zitadel:iam:org:project:role:user` | By using this scope a client can request the claim urn:zitadel:iam:roles to be asserted when possible. As an alternative approach you can enable all roles to be asserted from the [project](/guides/manage/console/roles#authorizations) a client belongs to. | -| `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. | -| `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed | -| `urn:zitadel:iam:role:{rolename}` | | | -| `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access token | -| `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project ID will be added to the audience of the access token | -| `urn:zitadel:iam:user:metadata` | `urn:zitadel:iam:user:metadata` | By adding this scope, the metadata of the user will be included in the token. The values are base64 encoded. | -| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope, the resourceowner (id, name, primary_domain) of the user will be included in the token. | -| `urn:zitadel:iam:org:idp:id:{idp_id}` | `urn:zitadel:iam:org:idp:id:76625965177954913` | By adding this scope the user will directly be redirected to the identity provider to authenticate. Make sure you also send the primary domain scope if a custom login policy is configured. Otherwise the system will not be able to identify the identity provider. | +| Scopes | Example | Description | +|:--------------------------------------------------|:-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `urn:zitadel:iam:org:project:role:{rolekey}` | `urn:zitadel:iam:org:project:role:user` | By using this scope a client can request the claim `urn:zitadel:iam:org:project:roles` to be asserted when possible. As an alternative approach you can enable all roles to be asserted from the [project](/guides/manage/console/roles#authorizations) a client belongs to. | +| `urn:zitadel:iam:org:projects:roles` | `urn:zitadel:iam:org:projects:roles` | By using this scope a client can request the claim `urn:zitadel:iam:org:project:{projectid}:roles` to be asserted for each requested project. All projects of the token audience, requested by the `urn:zitadel:iam:org:project:id:{projectid}:aud` scopes will be used. | +| `urn:zitadel:iam:org:id:{id}` | `urn:zitadel:iam:org:id:178204173316174381` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization. If the organization does not exist a failure is displayed. It will assert the `urn:zitadel:iam:user:resourceowner` claims. | +| `urn:zitadel:iam:org:domain:primary:{domainname}` | `urn:zitadel:iam:org:domain:primary:acme.ch` | When requesting this scope **ZITADEL** will enforce that the user is a member of the selected organization and the username is suffixed by the provided domain. If the organization does not exist a failure is displayed | +| `urn:zitadel:iam:role:{rolename}` | | | +| `urn:zitadel:iam:org:project:id:{projectid}:aud` | `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access token | +| `urn:zitadel:iam:org:project:id:zitadel:aud` | `urn:zitadel:iam:org:project:id:zitadel:aud` | By adding this scope, the ZITADEL project ID will be added to the audience of the access token | +| `urn:zitadel:iam:user:metadata` | `urn:zitadel:iam:user:metadata` | By adding this scope, the metadata of the user will be included in the token. The values are base64 encoded. | +| `urn:zitadel:iam:user:resourceowner` | `urn:zitadel:iam:user:resourceowner` | By adding this scope, the resourceowner (id, name, primary_domain) of the user will be included in the token. | +| `urn:zitadel:iam:org:idp:id:{idp_id}` | `urn:zitadel:iam:org:idp:id:76625965177954913` | By adding this scope the user will directly be redirected to the identity provider to authenticate. Make sure you also send the primary domain scope if a custom login policy is configured. Otherwise the system will not be able to identify the identity provider. | diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index de39871b96..0c943bfd6f 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -245,12 +245,8 @@ func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string return scopes, nil } -func (o *OPStorage) assertClientScopesForPAT(ctx context.Context, token *model.TokenView, clientID string) error { +func (o *OPStorage) assertClientScopesForPAT(ctx context.Context, token *model.TokenView, clientID, projectID string) error { token.Audience = append(token.Audience, clientID) - projectID, err := o.query.ProjectIDFromClientID(ctx, clientID, false) - if err != nil { - return errors.ThrowPreconditionFailed(nil, "OIDC-AEG4d", "Errors.Internal") - } projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID) if err != nil { return errors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index be48a2b005..375158ca34 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -25,13 +25,15 @@ import ( ) const ( - ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:" - ClaimProjectRoles = "urn:zitadel:iam:org:project:roles" - ScopeUserMetaData = "urn:zitadel:iam:user:metadata" - ClaimUserMetaData = ScopeUserMetaData - ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner" - ClaimResourceOwner = ScopeResourceOwner + ":" - ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log" + ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:" + ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles" + ClaimProjectRoles = "urn:zitadel:iam:org:project:roles" + ClaimProjectRolesFormat = "urn:zitadel:iam:org:project:%s:roles" + ScopeUserMetaData = "urn:zitadel:iam:user:metadata" + ClaimUserMetaData = ScopeUserMetaData + ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner" + ClaimResourceOwner = ScopeResourceOwner + ":" + ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log" oidcCtx = "oidc" ) @@ -130,7 +132,7 @@ func (o *OPStorage) SetUserinfoFromToken(ctx context.Context, userInfo *oidc.Use return errors.ThrowPermissionDenied(nil, "OIDC-da1f3", "origin is not allowed") } } - return o.setUserinfo(ctx, userInfo, token.UserID, token.ApplicationID, token.Scopes) + return o.setUserinfo(ctx, userInfo, token.UserID, token.ApplicationID, token.Scopes, nil) } func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string) (err error) { @@ -148,7 +150,7 @@ func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo *oidc.Us } } } - return o.setUserinfo(ctx, userInfo, userID, applicationID, scopes) + return o.setUserinfo(ctx, userInfo, userID, applicationID, scopes, nil) } func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection *oidc.IntrospectionResponse, tokenID, subject, clientID string) error { @@ -161,7 +163,7 @@ func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection return errors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found") } if token.IsPAT { - err = o.assertClientScopesForPAT(ctx, token, clientID) + err = o.assertClientScopesForPAT(ctx, token, clientID, projectID) if err != nil { return errors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal") } @@ -169,7 +171,7 @@ func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection for _, aud := range token.Audience { if aud == clientID || aud == projectID { userInfo := new(oidc.UserInfo) - err := o.setUserinfo(ctx, userInfo, subject, clientID, token.Scopes) + err := o.setUserinfo(ctx, userInfo, subject, clientID, token.Scopes, []string{projectID}) // always if err != nil { return err } @@ -254,13 +256,14 @@ func (o *OPStorage) checkOrgScopes(ctx context.Context, user *query.User, scopes return scopes, nil } -func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string) (err error) { +func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, userID, applicationID string, scopes []string, roleAudience []string) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() user, err := o.query.GetUserByID(ctx, true, userID, false) if err != nil { return err } + var allRoles bool roles := make([]string, 0) for _, scope := range scopes { switch scope { @@ -282,6 +285,8 @@ func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, us if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil { return err } + case ScopeProjectsRoles: + allRoles = true default: if strings.HasPrefix(scope, ScopeProjectRolePrefix) { roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) @@ -298,14 +303,16 @@ func (o *OPStorage) setUserinfo(ctx context.Context, userInfo *oidc.UserInfo, us } } - userGrants, projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles) + // if all roles are requested take the audience for those from the scopes + if allRoles && len(roleAudience) == 0 { + roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) + } + + userGrants, projectRoles, err := o.assertRoles(ctx, userID, applicationID, roles, roleAudience) if err != nil { return err } - - if len(projectRoles) > 0 { - userInfo.AppendClaims(ClaimProjectRoles, projectRoles) - } + o.setUserInfoRoleClaims(userInfo, projectRoles) return o.userinfoFlows(ctx, user.ResourceOwner, userGrants, userInfo) } @@ -367,6 +374,17 @@ func (o *OPStorage) setUserInfoResourceOwner(ctx context.Context, userInfo *oidc return nil } +func (o *OPStorage) setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) { + if roles != nil && len(roles.projects) > 0 { + if roles, ok := roles.projects[roles.requestProjectID]; ok { + userInfo.AppendClaims(ClaimProjectRoles, roles) + } + for projectID, roles := range roles.projects { + userInfo.AppendClaims(fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) + } + } +} + func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, userGrants *query.UserGrants, userInfo *oidc.UserInfo) error { queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreUserinfoCreation, resourceOwner, false) if err != nil { @@ -494,6 +512,7 @@ func (o *OPStorage) userinfoFlows(ctx context.Context, resourceOwner string, use func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { roles := make([]string, 0) + var allRoles bool for _, scope := range scopes { switch scope { case ScopeUserMetaData: @@ -512,6 +531,8 @@ func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clie for claim, value := range resourceOwnerClaims { claims = appendClaim(claims, claim, value) } + case ScopeProjectsRoles: + allRoles = true } if strings.HasPrefix(scope, ScopeProjectRolePrefix) { roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) @@ -531,13 +552,25 @@ func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clie } } - userGrants, projectRoles, err := o.assertRoles(ctx, userID, clientID, roles) + // If requested, use the audience as context for the roles, + // otherwise the project itself will be used + var roleAudience []string + if allRoles { + roleAudience = domain.AddAudScopeToAudience(ctx, roleAudience, scopes) + } + + userGrants, projectRoles, err := o.assertRoles(ctx, userID, clientID, roles, roleAudience) if err != nil { return nil, err } - if len(projectRoles) > 0 { - claims = appendClaim(claims, ClaimProjectRoles, projectRoles) + if projectRoles != nil && len(projectRoles.projects) > 0 { + if roles, ok := projectRoles.projects[projectRoles.requestProjectID]; ok { + claims = appendClaim(claims, ClaimProjectRoles, roles) + } + for projectID, roles := range projectRoles.projects { + claims = appendClaim(claims, fmt.Sprintf(ClaimProjectRolesFormat, projectID), roles) + } } return o.privateClaimsFlows(ctx, userID, userGrants, claims) @@ -662,35 +695,53 @@ func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userG return claims, nil } -func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles []string) (*query.UserGrants, map[string]map[string]string, error) { - if applicationID == "" || len(requestedRoles) == 0 { +func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles, roleAudience []string) (*query.UserGrants, *projectsRoles, error) { + if (applicationID == "" || len(requestedRoles) == 0) && len(roleAudience) == 0 { return nil, nil, nil } projectID, err := o.query.ProjectIDFromClientID(ctx, applicationID, false) + // applicationID might contain a username (e.g. client credentials) -> ignore the not found + if err != nil && !errors.IsNotFound(err) { + return nil, nil, err + } + // ensure the projectID of the requesting is part of the roleAudience + if projectID != "" { + roleAudience = append(roleAudience, projectID) + } + queries := make([]query.SearchQuery, 0, 2) + projectQuery, err := query.NewUserGrantProjectIDsSearchQuery(roleAudience) if err != nil { return nil, nil, err } - projectQuery, err := query.NewUserGrantProjectIDSearchQuery(projectID) - if err != nil { - return nil, nil, err - } + queries = append(queries, projectQuery) userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID) if err != nil { return nil, nil, err } + queries = append(queries, userIDQuery) grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{ - Queries: []query.SearchQuery{projectQuery, userIDQuery}, + Queries: queries, }, true, false) if err != nil { return nil, nil, err } - projectRoles := make(map[string]map[string]string) - for _, requestedRole := range requestedRoles { - for _, grant := range grants.UserGrants { - checkGrantedRoles(projectRoles, grant, requestedRole) + roles := new(projectsRoles) + // if specific roles where requested, check if they are granted and append them in the roles list + if len(requestedRoles) > 0 { + for _, requestedRole := range requestedRoles { + for _, grant := range grants.UserGrants { + checkGrantedRoles(roles, grant, requestedRole, grant.ProjectID == projectID) + } + } + return grants, roles, nil + } + // now specific roles were requested, so convert any grants into roles + for _, grant := range grants.UserGrants { + for _, role := range grant.Roles { + roles.Add(grant.ProjectID, role, grant.ResourceOwner, grant.OrgPrimaryDomain, grant.ProjectID == projectID) } } - return grants, projectRoles, nil + return grants, roles, nil } func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) { @@ -722,21 +773,52 @@ func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string) }, nil } -func checkGrantedRoles(roles map[string]map[string]string, grant *query.UserGrant, requestedRole string) { +func checkGrantedRoles(roles *projectsRoles, grant *query.UserGrant, requestedRole string, isRequested bool) { for _, grantedRole := range grant.Roles { if requestedRole == grantedRole { - appendRole(roles, grantedRole, grant.ResourceOwner, grant.OrgPrimaryDomain) + roles.Add(grant.ProjectID, grantedRole, grant.ResourceOwner, grant.OrgPrimaryDomain, isRequested) } } } -func appendRole(roles map[string]map[string]string, role, orgID, orgPrimaryDomain string) { - if roles[role] == nil { - roles[role] = make(map[string]string, 0) - } - roles[role][orgID] = orgPrimaryDomain +// projectsRoles contains all projects with all their roles for a user +type projectsRoles struct { + // key is projectID + projects map[string]projectRoles + + requestProjectID string } +func (p *projectsRoles) Add(projectID, roleKey, orgID, domain string, isRequested bool) { + if p.projects == nil { + p.projects = make(map[string]projectRoles, 1) + } + if p.projects[projectID] == nil { + p.projects[projectID] = make(projectRoles) + } + if isRequested { + p.requestProjectID = projectID + } + p.projects[projectID].Add(roleKey, orgID, domain) +} + +// projectRoles contains the roles of a project of multiple organisations +// +// key is the role key +type projectRoles map[string][]projectRole + +func (p projectRoles) Add(roleKey, orgID, domain string) { + if len(p[roleKey]) == 0 { + p[roleKey] = make([]projectRole, 0, 1) + } + p[roleKey] = append(p[roleKey], projectRole{orgID: domain}) +} + +// projectRole contains all the organisations where a user is granted a certain role +// +// key is the org id, value the org domain +type projectRole map[string]string + func getGender(gender domain.Gender) oidc.Gender { switch gender { case domain.GenderFemale: diff --git a/internal/api/oidc/client_converter.go b/internal/api/oidc/client_converter.go index b5c843e1f5..749a5c3dff 100644 --- a/internal/api/oidc/client_converter.go +++ b/internal/api/oidc/client_converter.go @@ -112,10 +112,13 @@ func (c *Client) IsScopeAllowed(scope string) bool { if strings.HasPrefix(scope, domain.SelectIDPScope) { return true } - if strings.HasPrefix(scope, ScopeUserMetaData) { + if scope == ScopeUserMetaData { return true } - if strings.HasPrefix(scope, ScopeResourceOwner) { + if scope == ScopeResourceOwner { + return true + } + if scope == ScopeProjectsRoles { return true } for _, allowedScope := range c.allowedScopes { diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index 8092b43e36..ceb8652e4d 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -75,6 +75,14 @@ func NewUserGrantProjectIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantProjectID, id, TextEquals) } +func NewUserGrantProjectIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(UserGrantProjectID, list, ListIn) +} + func NewUserGrantProjectOwnerSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(ProjectColumnResourceOwner, id, TextEquals) }