From 31a91a003954d38ccd4e5a40139c72df0214dcc8 Mon Sep 17 00:00:00 2001 From: Fabi <38692350+fgerschwiler@users.noreply.github.com> Date: Tue, 31 Aug 2021 11:49:31 +0200 Subject: [PATCH] feat: add resource owner scope / claim (#2274) * feat: add resource owner scope / claime * fix: private claimes * fix: private claims * fix: add claim description * Update claims.md Co-authored-by: Livio Amstutz --- docs/docs/apis/openidoauth/claims.md | 68 +++++++++++-------- docs/docs/apis/openidoauth/scopes.md | 1 + internal/api/oidc/client.go | 64 +++++++++++++---- internal/api/oidc/client_converter.go | 3 + .../eventsourcing/eventstore/user.go | 13 ++++ internal/auth/repository/user.go | 1 + internal/org/repository/view/model/org.go | 14 ++++ 7 files changed, 119 insertions(+), 45 deletions(-) diff --git a/docs/docs/apis/openidoauth/claims.md b/docs/docs/apis/openidoauth/claims.md index 09f03fd46..005318430 100644 --- a/docs/docs/apis/openidoauth/claims.md +++ b/docs/docs/apis/openidoauth/claims.md @@ -5,31 +5,35 @@ title: Claims ZITADEL asserts claims on different places according to the corresponding specifications or project and clients settings. Please check below the matrix for an overview where which scope is asserted. -| Claims | Userinfo | Introspection | ID Token | Access Token | -|:------------------------------------------------|:---------------|----------------|---------------------------------------------|--------------------------------------| -| acr | No | No | Yes | No | -| address | When requested | When requested | When requested amd response_type `id_token` | No | -| amr | No | No | Yes | No | -| aud | No | No | Yes | When JWT | -| auth_time | No | No | Yes | No | -| azp | No | No | Yes | When JWT | -| email | When requested | When requested | When requested amd response_type `id_token` | No | -| email_verified | When requested | When requested | When requested amd response_type `id_token` | No | -| exp | No | No | Yes | When JWT | -| family_name | When requested | When requested | When requested amd response_type `id_token` | No | -| gender | When requested | When requested | When requested amd response_type `id_token` | No | -| given_name | When requested | When requested | When requested amd response_type `id_token` | No | -| iat | No | No | Yes | When JWT | -| iss | No | No | Yes | When JWT | -| locale | When requested | When requested | When requested amd response_type `id_token` | No | -| name | When requested | When requested | When requested amd response_type `id_token` | No | -| nonce | No | No | Yes | No | -| phone | When requested | When requested | When requested amd response_type `id_token` | No | -| phone_verified | When requested | When requested | When requested amd response_type `id_token` | No | -| preferred_username (username when Introspect ) | When requested | When requested | Yes | No | -| sub | Yes | Yes | Yes | When JWT | -| urn:zitadel:iam:org:domain:primary:{domainname} | When requested | When requested | When requested | When JWT and requested | -| urn:zitadel:iam:org:project:roles:{rolename} | When requested | When requested | When requested or configured | When JWT and requested or configured | +| Claims | Userinfo | Introspection | ID Token | Access Token | +|:--------------------------------------------------|:---------------|----------------|---------------------------------------------|--------------------------------------| +| acr | No | No | Yes | No | +| address | When requested | When requested | When requested amd response_type `id_token` | No | +| amr | No | No | Yes | No | +| aud | No | No | Yes | When JWT | +| auth_time | No | No | Yes | No | +| azp | No | No | Yes | When JWT | +| email | When requested | When requested | When requested amd response_type `id_token` | No | +| email_verified | When requested | When requested | When requested amd response_type `id_token` | No | +| exp | No | No | Yes | When JWT | +| family_name | When requested | When requested | When requested amd response_type `id_token` | No | +| gender | When requested | When requested | When requested amd response_type `id_token` | No | +| given_name | When requested | When requested | When requested amd response_type `id_token` | No | +| iat | No | No | Yes | When JWT | +| iss | No | No | Yes | When JWT | +| locale | When requested | When requested | When requested amd response_type `id_token` | No | +| name | When requested | When requested | When requested amd response_type `id_token` | No | +| nonce | No | No | Yes | No | +| phone | When requested | When requested | When requested amd response_type `id_token` | No | +| phone_verified | When requested | When requested | When requested amd response_type `id_token` | No | +| preferred_username (username when Introspect ) | When requested | When requested | Yes | No | +| sub | Yes | Yes | Yes | When JWT | +| urn:zitadel:iam:org:domain:primary:{domainname} | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:org:project:roles:{rolename} | When requested | When requested | When requested or configured | When JWT and requested or configured | +| urn:zitadel:iam:user:metadata | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:user:resourceowner:id | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:user:resourceowner:name | When requested | When requested | When requested | When JWT and requested | +| urn:zitadel:iam:user:resourceowner:primary_domain | When requested | When requested | When requested | When JWT and requested | ## Standard Claims @@ -64,8 +68,12 @@ Please check below the matrix for an overview where which scope is asserted. ZITADEL reserves some claims to assert certain data. -| Claims | Example | Description | -|:------------------------------------------------|:-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| 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:{rolename} | `{"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 | +| Claims | Example | Description | +|:--------------------------------------------------|:-----------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 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:{rolename} | `{"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. | diff --git a/docs/docs/apis/openidoauth/scopes.md b/docs/docs/apis/openidoauth/scopes.md index f17e9e348..163b1f6b4 100644 --- a/docs/docs/apis/openidoauth/scopes.md +++ b/docs/docs/apis/openidoauth/scopes.md @@ -29,5 +29,6 @@ In addition to the standard compliant scopes we utilize the following scopes. | urn:zitadel:iam:role:{rolename} | | | | `urn:zitadel:iam:org:project:id:{projectid}:aud` | ZITADEL's Project id is `urn:zitadel:iam:org:project:id:69234237810729019:aud` | By adding this scope, the requested projectid will be added to the audience of the access and id 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. | > If access to ZITADEL's API's is needed with a service user the scope `urn:zitadel:iam:org:project:id:69234237810729019:aud` needs to be used with the JWT Profile request diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index 151082b81..96e31f523 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -27,6 +27,8 @@ const ( ClaimProjectRoles = "urn:zitadel:iam:org:project:roles" ScopeUserMetaData = "urn:zitadel:iam:user:metadata" ClaimUserMetaData = ScopeUserMetaData + ScopeResourceOwner = "urn:zitadel:iam:user:resourceowner" + ClaimResourceOwner = ScopeResourceOwner + ":" oidcCtx = "oidc" ) @@ -174,6 +176,23 @@ func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo oidc.Use continue } userInfo.SetAddress(oidc.NewUserInfoAddress(user.StreetAddress, user.Locality, user.Region, user.PostalCode, user.Country, "")) + case ScopeUserMetaData: + userMetaData, err := o.assertUserMetaData(ctx, userID) + if err != nil { + return err + } + if len(userMetaData) > 0 { + userInfo.AppendClaims(ClaimUserMetaData, userMetaData) + } + case ScopeResourceOwner: + resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) + if err != nil { + return err + } + for claim, value := range resourceOwnerClaims { + userInfo.AppendClaims(claim, value) + } + default: if strings.HasPrefix(scope, ScopeProjectRolePrefix) { roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) @@ -183,14 +202,6 @@ func (o *OPStorage) SetUserinfoFromScopes(ctx context.Context, userInfo oidc.Use } } } - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return err - } - if len(userMetaData) > 0 { - userInfo.AppendClaims(ClaimUserMetaData, userMetaData) - } - if len(roles) == 0 || applicationID == "" { return nil } @@ -230,6 +241,24 @@ func (o *OPStorage) SetIntrospectionFromToken(ctx context.Context, introspection func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { roles := make([]string, 0) for _, scope := range scopes { + switch scope { + case ScopeUserMetaData: + userMetaData, err := o.assertUserMetaData(ctx, userID) + if err != nil { + return nil, err + } + if len(userMetaData) > 0 { + claims = appendClaim(claims, ClaimUserMetaData, userMetaData) + } + case ScopeResourceOwner: + resourceOwnerClaims, err := o.assertUserResourceOwner(ctx, userID) + if err != nil { + return nil, err + } + for claim, value := range resourceOwnerClaims { + claims = appendClaim(claims, claim, value) + } + } if strings.HasPrefix(scope, ScopeProjectRolePrefix) { roles = append(roles, strings.TrimPrefix(scope, ScopeProjectRolePrefix)) } else if strings.HasPrefix(scope, model.OrgDomainPrimaryScope) { @@ -246,13 +275,6 @@ func (o *OPStorage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clie if len(projectRoles) > 0 { claims = appendClaim(claims, ClaimProjectRoles, projectRoles) } - userMetaData, err := o.assertUserMetaData(ctx, userID) - if err != nil { - return nil, err - } - if len(userMetaData) > 0 { - claims = appendClaim(claims, ClaimUserMetaData, userMetaData) - } return claims, err } @@ -287,6 +309,18 @@ func (o *OPStorage) assertUserMetaData(ctx context.Context, userID string) (map[ return userMetaData, nil } +func (o *OPStorage) assertUserResourceOwner(ctx context.Context, userID string) (map[string]string, error) { + resourceOwner, err := o.repo.OrgByUserID(ctx, userID) + if err != nil { + return nil, err + } + return map[string]string{ + ClaimResourceOwner + "id": resourceOwner.AggregateID, + ClaimResourceOwner + "name": resourceOwner.Name, + ClaimResourceOwner + "primary_domain": resourceOwner.PrimaryDomain, + }, nil +} + func checkGrantedRoles(roles map[string]map[string]string, grant *grant_model.UserGrantView, requestedRole string) { for _, grantedRole := range grant.RoleKeys { if requestedRole == grantedRole { diff --git a/internal/api/oidc/client_converter.go b/internal/api/oidc/client_converter.go index 16797b12e..17e1424be 100644 --- a/internal/api/oidc/client_converter.go +++ b/internal/api/oidc/client_converter.go @@ -109,6 +109,9 @@ func (c *Client) IsScopeAllowed(scope string) bool { if strings.HasPrefix(scope, ScopeUserMetaData) { return true } + if strings.HasPrefix(scope, ScopeResourceOwner) { + return true + } for _, allowedScope := range c.allowedScopes { if scope == allowedScope { return true diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go index fba74a3b9..f3610739e 100644 --- a/internal/auth/repository/eventsourcing/eventstore/user.go +++ b/internal/auth/repository/eventsourcing/eventstore/user.go @@ -17,6 +17,7 @@ import ( iam_model "github.com/caos/zitadel/internal/iam/repository/view/model" key_model "github.com/caos/zitadel/internal/key/model" key_view_model "github.com/caos/zitadel/internal/key/repository/view/model" + org_model "github.com/caos/zitadel/internal/org/repository/view/model" "github.com/caos/zitadel/internal/telemetry/tracing" "github.com/caos/zitadel/internal/user/model" usr_view "github.com/caos/zitadel/internal/user/repository/view" @@ -307,6 +308,18 @@ func (repo *UserRepo) GetMyMetadataByKey(ctx context.Context, key string) (*doma return iam_model.MetadataViewToDomain(data), nil } +func (repo *UserRepo) OrgByUserID(ctx context.Context, userID string) (*domain.Org, error) { + user, err := repo.View.UserByID(userID) + if err != nil { + return nil, err + } + org, err := repo.View.OrgByID(user.ResourceOwner) + if err != nil { + return nil, err + } + return org_model.OrgToDomain(org), nil +} + func (repo *UserRepo) SearchUserMetadata(ctx context.Context, userID string) (*domain.MetadataSearchResponse, error) { req := new(domain.MetadataSearchRequest) return repo.searchUserMetadata(userID, "", req) diff --git a/internal/auth/repository/user.go b/internal/auth/repository/user.go index 0f9c573c8..679df592c 100644 --- a/internal/auth/repository/user.go +++ b/internal/auth/repository/user.go @@ -23,6 +23,7 @@ type UserRepository interface { SearchUsers(ctx context.Context, request *model.UserSearchRequest) (*model.UserSearchResponse, error) SearchUserMetadata(ctx context.Context, userID string) (*domain.MetadataSearchResponse, error) + OrgByUserID(ctx context.Context, userID string) (*domain.Org, error) } type myUserRepo interface { diff --git a/internal/org/repository/view/model/org.go b/internal/org/repository/view/model/org.go index 4ee4f0aac..e44f2e126 100644 --- a/internal/org/repository/view/model/org.go +++ b/internal/org/repository/view/model/org.go @@ -2,6 +2,7 @@ package model import ( "encoding/json" + "github.com/caos/zitadel/internal/domain" "github.com/caos/zitadel/internal/org/repository/eventsourcing/model" "time" @@ -55,6 +56,19 @@ func OrgToModel(org *OrgView) *org_model.OrgView { } } +func OrgToDomain(org *OrgView) *domain.Org { + return &domain.Org{ + ObjectRoot: es_models.ObjectRoot{ + AggregateID: org.ID, + ChangeDate: org.ChangeDate, + CreationDate: org.CreationDate, + Sequence: org.Sequence, + }, + Name: org.Name, + PrimaryDomain: org.Domain, + } +} + func OrgsToModel(orgs []*OrgView) []*org_model.OrgView { modelOrgs := make([]*org_model.OrgView, len(orgs))