feat: role claims for service user tokens (#5577)

tokens of service users can now contain role claims by requesting them through scopes
This commit is contained in:
Silvan
2023-04-03 14:26:51 +02:00
committed by GitHub
parent 4691298eb6
commit e688954308
6 changed files with 158 additions and 67 deletions

View File

@@ -74,10 +74,11 @@ 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). ZITADEL reserves some claims to assert certain data. Please check out the [reserved scopes](scopes#reserved-scopes).
| Claims | Example | Description | | 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: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: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: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: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: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:id | `{"urn:zitadel:iam:user:resourceowner:id": "orgid"}` | This claim represents the id of the resource owner organisation of the user. |

View File

@@ -23,8 +23,9 @@ 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. In addition to the standard compliant scopes we utilize the following scopes.
| Scopes | Example | Description | | 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: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: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: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:role:{rolename}` | | |

View File

@@ -245,12 +245,8 @@ func (o *OPStorage) assertProjectRoleScopes(ctx context.Context, clientID string
return scopes, nil 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) 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) projectIDQuery, err := query.NewProjectRoleProjectIDSearchQuery(projectID)
if err != nil { if err != nil {
return errors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal") return errors.ThrowInternal(err, "OIDC-Cyc78", "Errors.Internal")

View File

@@ -26,7 +26,9 @@ import (
const ( const (
ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:" ScopeProjectRolePrefix = "urn:zitadel:iam:org:project:role:"
ScopeProjectsRoles = "urn:zitadel:iam:org:projects:roles"
ClaimProjectRoles = "urn:zitadel:iam:org:project:roles" ClaimProjectRoles = "urn:zitadel:iam:org:project:roles"
ClaimProjectRolesFormat = "urn:zitadel:iam:org:project:%s:roles"
ScopeUserMetaData = "urn:zitadel:iam:user:metadata" ScopeUserMetaData = "urn:zitadel:iam:user:metadata"
ClaimUserMetaData = ScopeUserMetaData ClaimUserMetaData = ScopeUserMetaData
ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner" ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner"
@@ -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 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) { 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 { 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") return errors.ThrowPermissionDenied(nil, "OIDC-Adfg5", "client not found")
} }
if token.IsPAT { if token.IsPAT {
err = o.assertClientScopesForPAT(ctx, token, clientID) err = o.assertClientScopesForPAT(ctx, token, clientID, projectID)
if err != nil { if err != nil {
return errors.ThrowPreconditionFailed(err, "OIDC-AGefw", "Errors.Internal") 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 { for _, aud := range token.Audience {
if aud == clientID || aud == projectID { if aud == clientID || aud == projectID {
userInfo := new(oidc.UserInfo) 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 { if err != nil {
return err return err
} }
@@ -254,13 +256,14 @@ func (o *OPStorage) checkOrgScopes(ctx context.Context, user *query.User, scopes
return scopes, nil 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) ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }() defer func() { span.EndWithError(err) }()
user, err := o.query.GetUserByID(ctx, true, userID, false) user, err := o.query.GetUserByID(ctx, true, userID, false)
if err != nil { if err != nil {
return err return err
} }
var allRoles bool
roles := make([]string, 0) roles := make([]string, 0)
for _, scope := range scopes { for _, scope := range scopes {
switch scope { 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 { if err := o.setUserInfoResourceOwner(ctx, userInfo, userID); err != nil {
return err return err
} }
case ScopeProjectsRoles:
allRoles = true
default: default:
if strings.HasPrefix(scope, ScopeProjectRolePrefix) { if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
roles = append(roles, strings.TrimPrefix(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 { if err != nil {
return err return err
} }
o.setUserInfoRoleClaims(userInfo, projectRoles)
if len(projectRoles) > 0 {
userInfo.AppendClaims(ClaimProjectRoles, projectRoles)
}
return o.userinfoFlows(ctx, user.ResourceOwner, userGrants, userInfo) return o.userinfoFlows(ctx, user.ResourceOwner, userGrants, userInfo)
} }
@@ -367,6 +374,17 @@ func (o *OPStorage) setUserInfoResourceOwner(ctx context.Context, userInfo *oidc
return nil 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 { 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) queriedActions, err := o.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, domain.TriggerTypePreUserinfoCreation, resourceOwner, false)
if err != nil { 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) { func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) {
roles := make([]string, 0) roles := make([]string, 0)
var allRoles bool
for _, scope := range scopes { for _, scope := range scopes {
switch scope { switch scope {
case ScopeUserMetaData: case ScopeUserMetaData:
@@ -512,6 +531,8 @@ func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clie
for claim, value := range resourceOwnerClaims { for claim, value := range resourceOwnerClaims {
claims = appendClaim(claims, claim, value) claims = appendClaim(claims, claim, value)
} }
case ScopeProjectsRoles:
allRoles = true
} }
if strings.HasPrefix(scope, ScopeProjectRolePrefix) { if strings.HasPrefix(scope, ScopeProjectRolePrefix) {
roles = append(roles, strings.TrimPrefix(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 { if err != nil {
return nil, err return nil, err
} }
if len(projectRoles) > 0 { if projectRoles != nil && len(projectRoles.projects) > 0 {
claims = appendClaim(claims, ClaimProjectRoles, projectRoles) 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) return o.privateClaimsFlows(ctx, userID, userGrants, claims)
@@ -662,35 +695,53 @@ func (o *OPStorage) privateClaimsFlows(ctx context.Context, userID string, userG
return claims, nil return claims, nil
} }
func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles []string) (*query.UserGrants, map[string]map[string]string, error) { func (o *OPStorage) assertRoles(ctx context.Context, userID, applicationID string, requestedRoles, roleAudience []string) (*query.UserGrants, *projectsRoles, error) {
if applicationID == "" || len(requestedRoles) == 0 { if (applicationID == "" || len(requestedRoles) == 0) && len(roleAudience) == 0 {
return nil, nil, nil return nil, nil, nil
} }
projectID, err := o.query.ProjectIDFromClientID(ctx, applicationID, false) 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 { if err != nil {
return nil, nil, err return nil, nil, err
} }
projectQuery, err := query.NewUserGrantProjectIDSearchQuery(projectID) queries = append(queries, projectQuery)
if err != nil {
return nil, nil, err
}
userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID) userIDQuery, err := query.NewUserGrantUserIDSearchQuery(userID)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
queries = append(queries, userIDQuery)
grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{ grants, err := o.query.UserGrants(ctx, &query.UserGrantsQueries{
Queries: []query.SearchQuery{projectQuery, userIDQuery}, Queries: queries,
}, true, false) }, true, false)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
projectRoles := make(map[string]map[string]string) 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 _, requestedRole := range requestedRoles {
for _, grant := range grants.UserGrants { for _, grant := range grants.UserGrants {
checkGrantedRoles(projectRoles, grant, requestedRole) checkGrantedRoles(roles, grant, requestedRole, grant.ProjectID == projectID)
} }
} }
return grants, projectRoles, nil 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, roles, nil
} }
func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) { func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[string]string, error) {
@@ -722,20 +773,51 @@ func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string)
}, nil }, 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 { for _, grantedRole := range grant.Roles {
if requestedRole == grantedRole { 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) { // projectsRoles contains all projects with all their roles for a user
if roles[role] == nil { type projectsRoles struct {
roles[role] = make(map[string]string, 0) // key is projectID
projects map[string]projectRoles
requestProjectID string
} }
roles[role][orgID] = orgPrimaryDomain
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 { func getGender(gender domain.Gender) oidc.Gender {
switch gender { switch gender {

View File

@@ -112,10 +112,13 @@ func (c *Client) IsScopeAllowed(scope string) bool {
if strings.HasPrefix(scope, domain.SelectIDPScope) { if strings.HasPrefix(scope, domain.SelectIDPScope) {
return true return true
} }
if strings.HasPrefix(scope, ScopeUserMetaData) { if scope == ScopeUserMetaData {
return true return true
} }
if strings.HasPrefix(scope, ScopeResourceOwner) { if scope == ScopeResourceOwner {
return true
}
if scope == ScopeProjectsRoles {
return true return true
} }
for _, allowedScope := range c.allowedScopes { for _, allowedScope := range c.allowedScopes {

View File

@@ -75,6 +75,14 @@ func NewUserGrantProjectIDSearchQuery(id string) (SearchQuery, error) {
return NewTextQuery(UserGrantProjectID, id, TextEquals) 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) { func NewUserGrantProjectOwnerSearchQuery(id string) (SearchQuery, error) {
return NewTextQuery(ProjectColumnResourceOwner, id, TextEquals) return NewTextQuery(ProjectColumnResourceOwner, id, TextEquals)
} }