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).
| 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: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. |

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.
| 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: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}` | | |

View File

@@ -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")

View File

@@ -26,7 +26,9 @@ import (
const (
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"
@@ -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)
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(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) {
@@ -722,20 +773,51 @@ 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)
// projectsRoles contains all projects with all their roles for a user
type projectsRoles struct {
// 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 {
switch gender {

View File

@@ -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 {

View File

@@ -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)
}