mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-11 19:07:30 +00:00
feat(oidc): organization roles scope (#8120)
# Which Problems Are Solved An admin / application might want to be able to reduce the amount of roles returned in the token, for example if a user is granted to many organizations or for specific cases where the application want to narrow down the access for that token to a specific organization or multiple. This can now be achieved by providing a scope with the id of the organization, resp. multiple scopes for every organization, which should be included. ``` urn:zitadel:iam:org:roles🆔{orgID} ``` **Note:** the new scope does not work when Introspection / Userinfo are set to legacy mode. # How the Problems Are Solved The user info query now has two variants: 1. Variant that returns all organization authorization grants if the new scope wasn't provided for backward compatibility. 2. Variant that filters the organizations based on the IDs passed in one or more of the above scopes and returns only those authorization grants. The query is defined as a `text/template` and both variants are rendered once in package `init()`. # Additional Changes - In the integration tests `assertProjectRoleClaims` now also checks the org IDs in the roles. # Additional Context - Closes #7996
This commit is contained in:
@@ -24,14 +24,17 @@ 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: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: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: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}` | | |
|
||||||
|
| `urn:zitadel:iam:org:roles:id:{orgID}` | `urn:zitadel:iam:org:roles:id:178204173316174381` | This scope can be used one or more times to limit the granted organization IDs in the returned roles. Unknown organization IDs are ignored. When this scope is not used, all granted organizations are returned inside the roles.[^1] |
|
||||||
| `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:{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: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: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: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. |
|
| `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. |
|
||||||
|
|
||||||
|
[^1]: `urn:zitadel:iam:org:roles:id:{orgID}` is not supported when the `oidcLegacyIntrospection` [feature flag](/docs/apis/resources/feature_service_v2/feature-service-set-instance-features) is enabled.
|
||||||
|
@@ -242,6 +242,9 @@ func isScopeAllowed(scope string, allowedScopes ...string) bool {
|
|||||||
if strings.HasPrefix(scope, domain.SelectIDPScope) {
|
if strings.HasPrefix(scope, domain.SelectIDPScope) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if strings.HasPrefix(scope, domain.OrgRoleIDScope) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if scope == ScopeUserMetaData {
|
if scope == ScopeUserMetaData {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
@@ -104,7 +104,8 @@ func (s *Server) userInfo(
|
|||||||
defer func() { span.EndWithError(err) }()
|
defer func() { span.EndWithError(err) }()
|
||||||
|
|
||||||
roleAudience, requestedRoles = prepareRoles(ctx, scope, projectID, projectRoleAssertion, currentProjectOnly)
|
roleAudience, requestedRoles = prepareRoles(ctx, scope, projectID, projectRoleAssertion, currentProjectOnly)
|
||||||
qu, err = s.query.GetOIDCUserInfo(ctx, userID, roleAudience)
|
roleOrgIDs := domain.RoleOrgIDsFromScope(scope)
|
||||||
|
qu, err = s.query.GetOIDCUserInfo(ctx, userID, roleAudience, roleOrgIDs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
|
||||||
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
||||||
"github.com/zitadel/zitadel/internal/domain"
|
"github.com/zitadel/zitadel/internal/domain"
|
||||||
@@ -143,7 +144,7 @@ func testServer_UserInfo(t *testing.T) {
|
|||||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||||
assertUserinfo,
|
assertUserinfo,
|
||||||
func(t *testing.T, ui *oidc.UserInfo) {
|
func(t *testing.T, ui *oidc.UserInfo) {
|
||||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo, roleBar)
|
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []string{Tester.Organisation.ID})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -156,7 +157,7 @@ func testServer_UserInfo(t *testing.T) {
|
|||||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||||
assertUserinfo,
|
assertUserinfo,
|
||||||
func(t *testing.T, ui *oidc.UserInfo) {
|
func(t *testing.T, ui *oidc.UserInfo) {
|
||||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo)
|
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Tester.Organisation.ID})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -170,7 +171,7 @@ func testServer_UserInfo(t *testing.T) {
|
|||||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||||
assertUserinfo,
|
assertUserinfo,
|
||||||
func(t *testing.T, ui *oidc.UserInfo) {
|
func(t *testing.T, ui *oidc.UserInfo) {
|
||||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, roleFoo)
|
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Tester.Organisation.ID})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -221,6 +222,76 @@ func testServer_UserInfo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestServer_UserInfo_OrgIDRoles tests the [domain.OrgRoleIDScope] functionality
|
||||||
|
// it is a separate test because it is not supported in legacy mode.
|
||||||
|
func TestServer_UserInfo_OrgIDRoles(t *testing.T) {
|
||||||
|
const (
|
||||||
|
roleFoo = "foo"
|
||||||
|
roleBar = "bar"
|
||||||
|
)
|
||||||
|
clientID, projectID := createClient(t)
|
||||||
|
addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar)
|
||||||
|
grantedOrgID := addProjectOrgGrant(t, User.GetUserId(), projectID, roleFoo, roleBar)
|
||||||
|
|
||||||
|
_, err := Tester.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
|
||||||
|
Id: projectID,
|
||||||
|
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
|
||||||
|
ProjectRoleAssertion: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := Tester.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
scope []string
|
||||||
|
wantRoleOrgIDs []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default returns all role orgs",
|
||||||
|
scope: []string{
|
||||||
|
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||||
|
},
|
||||||
|
wantRoleOrgIDs: []string{Tester.Organisation.ID, grantedOrgID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only granted org",
|
||||||
|
scope: []string{
|
||||||
|
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||||
|
domain.OrgRoleIDScope + grantedOrgID},
|
||||||
|
wantRoleOrgIDs: []string{grantedOrgID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only own org",
|
||||||
|
scope: []string{
|
||||||
|
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||||
|
domain.OrgRoleIDScope + Tester.Organisation.ID,
|
||||||
|
},
|
||||||
|
wantRoleOrgIDs: []string{Tester.Organisation.ID},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "request both orgs",
|
||||||
|
scope: []string{
|
||||||
|
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||||
|
domain.OrgRoleIDScope + Tester.Organisation.ID,
|
||||||
|
domain.OrgRoleIDScope + grantedOrgID,
|
||||||
|
},
|
||||||
|
wantRoleOrgIDs: []string{Tester.Organisation.ID, grantedOrgID},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tokens := getTokens(t, clientID, tt.scope)
|
||||||
|
provider, err := Tester.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||||
|
require.NoError(t, err)
|
||||||
|
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertProjectRoleClaims(t, projectID, userinfo.Claims, true, []string{roleFoo, roleBar}, tt.wantRoleOrgIDs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/zitadel/zitadel/issues/6662
|
// https://github.com/zitadel/zitadel/issues/6662
|
||||||
func TestServer_UserInfo_Issue6662(t *testing.T) {
|
func TestServer_UserInfo_Issue6662(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
@@ -247,7 +318,7 @@ func TestServer_UserInfo_Issue6662(t *testing.T) {
|
|||||||
|
|
||||||
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, user.GetUserId(), provider)
|
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, user.GetUserId(), provider)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assertProjectRoleClaims(t, projectID, userinfo.Claims, false, roleFoo)
|
assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo}, []string{Tester.Organisation.ID})
|
||||||
}
|
}
|
||||||
|
|
||||||
func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) {
|
func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) {
|
||||||
@@ -272,6 +343,28 @@ func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...stri
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addProjectOrgGrant adds a new organization which will be granted on the projectID with the specified roles.
|
||||||
|
// The userID will be granted in the new organization to the project with the same roles.
|
||||||
|
func addProjectOrgGrant(t *testing.T, userID, projectID string, roles ...string) (grantedOrgID string) {
|
||||||
|
grantedOrg := Tester.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_GRANTED_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
|
||||||
|
projectGrant, err := Tester.Client.Mgmt.AddProjectGrant(CTX, &management.AddProjectGrantRequest{
|
||||||
|
ProjectId: projectID,
|
||||||
|
GrantedOrgId: grantedOrg.GetOrganizationId(),
|
||||||
|
RoleKeys: roles,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctxOrg := metadata.AppendToOutgoingContext(CTXIAM, "x-zitadel-orgid", grantedOrg.GetOrganizationId())
|
||||||
|
_, err = Tester.Client.Mgmt.AddUserGrant(ctxOrg, &management.AddUserGrantRequest{
|
||||||
|
UserId: userID,
|
||||||
|
ProjectId: projectID,
|
||||||
|
ProjectGrantId: projectGrant.GetGrantId(),
|
||||||
|
RoleKeys: roles,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
return grantedOrg.GetOrganizationId()
|
||||||
|
}
|
||||||
|
|
||||||
func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
|
func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
|
||||||
authRequestID := createAuthRequest(t, clientID, redirectURI, scope...)
|
authRequestID := createAuthRequest(t, clientID, redirectURI, scope...)
|
||||||
sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
sessionID, sessionToken, startTime, changeTime := Tester.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||||
@@ -316,19 +409,36 @@ func assertNoReservedScopes(t *testing.T, claims map[string]any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertProjectRoleClaims(t *testing.T, projectID string, claims map[string]any, claimProjectRole bool, roles ...string) {
|
// assertProjectRoleClaims asserts the projectRoles in the claims.
|
||||||
|
// By default it searches for the [oidc_api.ClaimProjectRolesFormat] claim with a project ID,
|
||||||
|
// and optionally for the [oidc_api.ClaimProjectRoles] claim if claimProjectRole is true.
|
||||||
|
// Each claim should contain the roles expected by wantRoles and
|
||||||
|
// each role should contain the org IDs expected by wantRoleOrgIDs.
|
||||||
|
//
|
||||||
|
// In the claim map, each project role claim is expected to be a map of multiple roles and
|
||||||
|
// each role is expected to be a map of multiple Org IDs to Org Domains.
|
||||||
|
func assertProjectRoleClaims(t *testing.T, projectID string, claims map[string]any, claimProjectRole bool, wantRoles, wantRoleOrgIDs []string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
projectRoleClaims := []string{fmt.Sprintf(oidc_api.ClaimProjectRolesFormat, projectID)}
|
projectRoleClaims := []string{fmt.Sprintf(oidc_api.ClaimProjectRolesFormat, projectID)}
|
||||||
if claimProjectRole {
|
if claimProjectRole {
|
||||||
projectRoleClaims = append(projectRoleClaims, oidc_api.ClaimProjectRoles)
|
projectRoleClaims = append(projectRoleClaims, oidc_api.ClaimProjectRoles)
|
||||||
}
|
}
|
||||||
for _, claim := range projectRoleClaims {
|
for _, claim := range projectRoleClaims {
|
||||||
roleMap, ok := claims[claim].(map[string]any)
|
roleMap, ok := claims[claim].(map[string]any) // map of multiple roles
|
||||||
require.Truef(t, ok, "claim %s not found or wrong type %T", claim, claims[claim])
|
require.Truef(t, ok, "claim %s not found or wrong type %T", claim, claims[claim])
|
||||||
for _, roleKey := range roles {
|
|
||||||
role, ok := roleMap[roleKey].(map[string]any)
|
gotRoles := make([]string, 0, len(roleMap))
|
||||||
|
for roleKey := range roleMap {
|
||||||
|
role, ok := roleMap[roleKey].(map[string]any) // map of multiple org IDs to org domains
|
||||||
require.Truef(t, ok, "role %s not found or wrong type %T", roleKey, roleMap[roleKey])
|
require.Truef(t, ok, "role %s not found or wrong type %T", roleKey, roleMap[roleKey])
|
||||||
assert.Equal(t, role[Tester.Organisation.ID], Tester.Organisation.Domain, "org domain in role")
|
gotRoles = append(gotRoles, roleKey)
|
||||||
|
|
||||||
|
gotRoleOrgIDs := make([]string, 0, len(role))
|
||||||
|
for orgID := range role {
|
||||||
|
gotRoleOrgIDs = append(gotRoleOrgIDs, orgID)
|
||||||
|
}
|
||||||
|
assert.ElementsMatch(t, wantRoleOrgIDs, gotRoleOrgIDs)
|
||||||
}
|
}
|
||||||
|
assert.ElementsMatch(t, wantRoles, gotRoles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,6 +3,7 @@ package domain
|
|||||||
const (
|
const (
|
||||||
OrgDomainPrimaryScope = "urn:zitadel:iam:org:domain:primary:"
|
OrgDomainPrimaryScope = "urn:zitadel:iam:org:domain:primary:"
|
||||||
OrgIDScope = "urn:zitadel:iam:org:id:"
|
OrgIDScope = "urn:zitadel:iam:org:id:"
|
||||||
|
OrgRoleIDScope = "urn:zitadel:iam:org:roles:id:"
|
||||||
OrgDomainPrimaryClaim = "urn:zitadel:iam:org:domain:primary"
|
OrgDomainPrimaryClaim = "urn:zitadel:iam:org:domain:primary"
|
||||||
OrgIDClaim = "urn:zitadel:iam:org:id"
|
OrgIDClaim = "urn:zitadel:iam:org:id"
|
||||||
ProjectIDScope = "urn:zitadel:iam:org:project:id:"
|
ProjectIDScope = "urn:zitadel:iam:org:project:id:"
|
||||||
|
@@ -21,6 +21,17 @@ func AddAudScopeToAudience(ctx context.Context, audience, scopes []string) []str
|
|||||||
return audience
|
return audience
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RoleOrgIDsFromScope parses orgIDs from [OrgRoleIDScope] prefixed scopes.
|
||||||
|
func RoleOrgIDsFromScope(scopes []string) (orgIDs []string) {
|
||||||
|
for _, scope := range scopes {
|
||||||
|
orgID, found := strings.CutPrefix(scope, OrgRoleIDScope)
|
||||||
|
if found {
|
||||||
|
orgIDs = append(orgIDs, orgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return orgIDs
|
||||||
|
}
|
||||||
|
|
||||||
func addProjectID(audience []string, projectID string) []string {
|
func addProjectID(audience []string, projectID string) []string {
|
||||||
for _, a := range audience {
|
for _, a := range audience {
|
||||||
if a == projectID {
|
if a == projectID {
|
||||||
|
45
internal/domain/token_test.go
Normal file
45
internal/domain/token_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRoleOrgIDsFromScope(t *testing.T) {
|
||||||
|
type args struct {
|
||||||
|
scopes []string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args args
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil",
|
||||||
|
args: args{nil},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated scope",
|
||||||
|
args: args{[]string{"foo", "bar"}},
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "orgID role scope",
|
||||||
|
args: args{[]string{OrgRoleIDScope + "123"}},
|
||||||
|
want: []string{"123"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed scope",
|
||||||
|
args: args{[]string{"foo", OrgRoleIDScope + "123"}},
|
||||||
|
want: []string{"123"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := RoleOrgIDsFromScope(tt.args.scopes)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@@ -5,7 +5,9 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
"github.com/zitadel/zitadel/internal/api/authz"
|
"github.com/zitadel/zitadel/internal/api/authz"
|
||||||
"github.com/zitadel/zitadel/internal/database"
|
"github.com/zitadel/zitadel/internal/database"
|
||||||
@@ -35,16 +37,43 @@ func TriggerOIDCUserInfoProjections(ctx context.Context) {
|
|||||||
triggerBatch(ctx, oidcUserInfoTriggerHandlers()...)
|
triggerBatch(ctx, oidcUserInfoTriggerHandlers()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:embed userinfo_by_id.sql
|
var (
|
||||||
var oidcUserInfoQuery string
|
//go:embed userinfo_by_id.sql
|
||||||
|
oidcUserInfoQueryTmpl string
|
||||||
|
oidcUserInfoQuery string
|
||||||
|
oidcUserInfoWithRoleOrgIDsQuery string
|
||||||
|
)
|
||||||
|
|
||||||
func (q *Queries) GetOIDCUserInfo(ctx context.Context, userID string, roleAudience []string) (_ *OIDCUserInfo, err error) {
|
// build the two variants of the userInfo query
|
||||||
|
func init() {
|
||||||
|
tmpl := template.Must(template.New("oidcUserInfoQuery").Parse(oidcUserInfoQueryTmpl))
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := tmpl.Execute(&buf, false); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
oidcUserInfoQuery = buf.String()
|
||||||
|
buf.Reset()
|
||||||
|
|
||||||
|
if err := tmpl.Execute(&buf, true); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
oidcUserInfoWithRoleOrgIDsQuery = buf.String()
|
||||||
|
buf.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) GetOIDCUserInfo(ctx context.Context, userID string, roleAudience []string, roleOrgIDs ...string) (userInfo *OIDCUserInfo, err error) {
|
||||||
ctx, span := tracing.NewSpan(ctx)
|
ctx, span := tracing.NewSpan(ctx)
|
||||||
defer func() { span.EndWithError(err) }()
|
defer func() { span.EndWithError(err) }()
|
||||||
|
|
||||||
userInfo, err := database.QueryJSONObject[OIDCUserInfo](ctx, q.client, oidcUserInfoQuery,
|
if len(roleOrgIDs) > 0 {
|
||||||
userID, authz.GetInstance(ctx).InstanceID(), database.TextArray[string](roleAudience),
|
userInfo, err = database.QueryJSONObject[OIDCUserInfo](ctx, q.client, oidcUserInfoWithRoleOrgIDsQuery,
|
||||||
)
|
userID, authz.GetInstance(ctx).InstanceID(), database.TextArray[string](roleAudience), database.TextArray[string](roleOrgIDs),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
userInfo, err = database.QueryJSONObject[OIDCUserInfo](ctx, q.client, oidcUserInfoQuery,
|
||||||
|
userID, authz.GetInstance(ctx).InstanceID(), database.TextArray[string](roleAudience),
|
||||||
|
)
|
||||||
|
}
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, zerrors.ThrowNotFound(err, "QUERY-Eey2a", "Errors.User.NotFound")
|
return nil, zerrors.ThrowNotFound(err, "QUERY-Eey2a", "Errors.User.NotFound")
|
||||||
}
|
}
|
||||||
|
@@ -38,6 +38,9 @@ user_grants as (
|
|||||||
where user_id = $1
|
where user_id = $1
|
||||||
and instance_id = $2
|
and instance_id = $2
|
||||||
and project_id = any($3)
|
and project_id = any($3)
|
||||||
|
{{ if . -}}
|
||||||
|
and resource_owner = any($4)
|
||||||
|
{{- end }}
|
||||||
),
|
),
|
||||||
-- filter all orgs we are interested in.
|
-- filter all orgs we are interested in.
|
||||||
orgs as (
|
orgs as (
|
||||||
|
@@ -48,10 +48,10 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
||||||
expQuery := regexp.QuoteMeta(oidcUserInfoQuery)
|
|
||||||
type args struct {
|
type args struct {
|
||||||
userID string
|
userID string
|
||||||
roleAudience []string
|
roleAudience []string
|
||||||
|
roleOrgIDs []string
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -65,7 +65,7 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
|||||||
args: args{
|
args: args{
|
||||||
userID: "231965491734773762",
|
userID: "231965491734773762",
|
||||||
},
|
},
|
||||||
mock: mockQueryErr(expQuery, sql.ErrConnDone, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
mock: mockQueryErr(regexp.QuoteMeta(oidcUserInfoQuery), sql.ErrConnDone, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
||||||
wantErr: sql.ErrConnDone,
|
wantErr: sql.ErrConnDone,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -73,7 +73,7 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
|||||||
args: args{
|
args: args{
|
||||||
userID: "231965491734773762",
|
userID: "231965491734773762",
|
||||||
},
|
},
|
||||||
mock: mockQuery(expQuery, []string{"json_build_object"}, []driver.Value{testdataUserInfoNotFound}, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
mock: mockQuery(regexp.QuoteMeta(oidcUserInfoQuery), []string{"json_build_object"}, []driver.Value{testdataUserInfoNotFound}, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
||||||
wantErr: zerrors.ThrowNotFound(nil, "QUERY-ahs4S", "Errors.User.NotFound"),
|
wantErr: zerrors.ThrowNotFound(nil, "QUERY-ahs4S", "Errors.User.NotFound"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,7 +81,7 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
|||||||
args: args{
|
args: args{
|
||||||
userID: "231965491734773762",
|
userID: "231965491734773762",
|
||||||
},
|
},
|
||||||
mock: mockQuery(expQuery, []string{"json_build_object"}, []driver.Value{testdataUserInfoHumanNoMD}, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
mock: mockQuery(regexp.QuoteMeta(oidcUserInfoQuery), []string{"json_build_object"}, []driver.Value{testdataUserInfoHumanNoMD}, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
||||||
want: &OIDCUserInfo{
|
want: &OIDCUserInfo{
|
||||||
User: &User{
|
User: &User{
|
||||||
ID: "231965491734773762",
|
ID: "231965491734773762",
|
||||||
@@ -120,7 +120,7 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
|||||||
args: args{
|
args: args{
|
||||||
userID: "231965491734773762",
|
userID: "231965491734773762",
|
||||||
},
|
},
|
||||||
mock: mockQuery(expQuery, []string{"json_build_object"}, []driver.Value{testdataUserInfoHuman}, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
mock: mockQuery(regexp.QuoteMeta(oidcUserInfoQuery), []string{"json_build_object"}, []driver.Value{testdataUserInfoHuman}, "231965491734773762", "instanceID", database.TextArray[string](nil)),
|
||||||
want: &OIDCUserInfo{
|
want: &OIDCUserInfo{
|
||||||
User: &User{
|
User: &User{
|
||||||
ID: "231965491734773762",
|
ID: "231965491734773762",
|
||||||
@@ -177,7 +177,7 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
|||||||
userID: "231965491734773762",
|
userID: "231965491734773762",
|
||||||
roleAudience: []string{"236645808328409090", "240762134579904514"},
|
roleAudience: []string{"236645808328409090", "240762134579904514"},
|
||||||
},
|
},
|
||||||
mock: mockQuery(expQuery,
|
mock: mockQuery(regexp.QuoteMeta(oidcUserInfoQuery),
|
||||||
[]string{"json_build_object"},
|
[]string{"json_build_object"},
|
||||||
[]driver.Value{testdataUserInfoHumanGrants},
|
[]driver.Value{testdataUserInfoHumanGrants},
|
||||||
"231965491734773762", "instanceID", database.TextArray[string]{"236645808328409090", "240762134579904514"},
|
"231965491734773762", "instanceID", database.TextArray[string]{"236645808328409090", "240762134579904514"},
|
||||||
@@ -272,12 +272,116 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "human with metadata and grants, role orgIDs",
|
||||||
|
args: args{
|
||||||
|
userID: "231965491734773762",
|
||||||
|
roleAudience: []string{"236645808328409090", "240762134579904514"},
|
||||||
|
roleOrgIDs: []string{"231848297847848962"},
|
||||||
|
},
|
||||||
|
mock: mockQuery(regexp.QuoteMeta(oidcUserInfoWithRoleOrgIDsQuery),
|
||||||
|
[]string{"json_build_object"},
|
||||||
|
[]driver.Value{testdataUserInfoHumanGrants},
|
||||||
|
"231965491734773762", "instanceID",
|
||||||
|
database.TextArray[string]{"236645808328409090", "240762134579904514"},
|
||||||
|
database.TextArray[string]{"231848297847848962"},
|
||||||
|
),
|
||||||
|
want: &OIDCUserInfo{
|
||||||
|
User: &User{
|
||||||
|
ID: "231965491734773762",
|
||||||
|
CreationDate: time.Date(2023, time.September, 15, 6, 10, 7, 434142000, timeLocation),
|
||||||
|
ChangeDate: time.Date(2023, time.November, 14, 13, 27, 2, 72318000, timeLocation),
|
||||||
|
Sequence: 1148,
|
||||||
|
State: 1,
|
||||||
|
ResourceOwner: "231848297847848962",
|
||||||
|
Username: "tim+tesmail@zitadel.com",
|
||||||
|
PreferredLoginName: "tim+tesmail@zitadel.com@demo.localhost",
|
||||||
|
Human: &Human{
|
||||||
|
FirstName: "Tim",
|
||||||
|
LastName: "Mohlmann",
|
||||||
|
NickName: "muhlemmer",
|
||||||
|
DisplayName: "Tim Mohlmann",
|
||||||
|
AvatarKey: "",
|
||||||
|
PreferredLanguage: language.English,
|
||||||
|
Gender: domain.GenderMale,
|
||||||
|
Email: "tim+tesmail@zitadel.com",
|
||||||
|
IsEmailVerified: true,
|
||||||
|
Phone: "+40123456789",
|
||||||
|
IsPhoneVerified: false,
|
||||||
|
},
|
||||||
|
Machine: nil,
|
||||||
|
},
|
||||||
|
Org: &UserInfoOrg{
|
||||||
|
ID: "231848297847848962",
|
||||||
|
Name: "demo",
|
||||||
|
PrimaryDomain: "demo.localhost",
|
||||||
|
},
|
||||||
|
Metadata: []UserMetadata{
|
||||||
|
{
|
||||||
|
CreationDate: time.Date(2023, time.November, 14, 13, 26, 3, 553702000, timeLocation),
|
||||||
|
ChangeDate: time.Date(2023, time.November, 14, 13, 26, 3, 553702000, timeLocation),
|
||||||
|
Sequence: 1147,
|
||||||
|
ResourceOwner: "231848297847848962",
|
||||||
|
Key: "bar",
|
||||||
|
Value: []byte("foo"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CreationDate: time.Date(2023, time.November, 14, 13, 25, 57, 171368000, timeLocation),
|
||||||
|
ChangeDate: time.Date(2023, time.November, 14, 13, 25, 57, 171368000, timeLocation),
|
||||||
|
Sequence: 1146,
|
||||||
|
ResourceOwner: "231848297847848962",
|
||||||
|
Key: "foo",
|
||||||
|
Value: []byte("bar"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
UserGrants: []UserGrant{
|
||||||
|
{
|
||||||
|
ID: "240749256523120642",
|
||||||
|
GrantID: "",
|
||||||
|
State: 1,
|
||||||
|
CreationDate: time.Date(2023, time.November, 14, 20, 28, 59, 168208000, timeLocation),
|
||||||
|
ChangeDate: time.Date(2023, time.November, 14, 20, 50, 58, 822391000, timeLocation),
|
||||||
|
Sequence: 2,
|
||||||
|
UserID: "231965491734773762",
|
||||||
|
Roles: []string{
|
||||||
|
"role1",
|
||||||
|
"role2",
|
||||||
|
},
|
||||||
|
ResourceOwner: "231848297847848962",
|
||||||
|
ProjectID: "236645808328409090",
|
||||||
|
OrgName: "demo",
|
||||||
|
OrgPrimaryDomain: "demo.localhost",
|
||||||
|
ProjectName: "tests",
|
||||||
|
UserResourceOwner: "231848297847848962",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "240762315572510722",
|
||||||
|
GrantID: "",
|
||||||
|
State: 1,
|
||||||
|
CreationDate: time.Date(2023, time.November, 14, 22, 38, 42, 967317000, timeLocation),
|
||||||
|
ChangeDate: time.Date(2023, time.November, 14, 22, 38, 42, 967317000, timeLocation),
|
||||||
|
Sequence: 1,
|
||||||
|
UserID: "231965491734773762",
|
||||||
|
Roles: []string{
|
||||||
|
"role3",
|
||||||
|
"role4",
|
||||||
|
},
|
||||||
|
ResourceOwner: "231848297847848962",
|
||||||
|
ProjectID: "240762134579904514",
|
||||||
|
OrgName: "demo",
|
||||||
|
OrgPrimaryDomain: "demo.localhost",
|
||||||
|
ProjectName: "tests2",
|
||||||
|
UserResourceOwner: "231848297847848962",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "machine with metadata",
|
name: "machine with metadata",
|
||||||
args: args{
|
args: args{
|
||||||
userID: "240707570677841922",
|
userID: "240707570677841922",
|
||||||
},
|
},
|
||||||
mock: mockQuery(expQuery, []string{"json_build_object"}, []driver.Value{testdataUserInfoMachine}, "240707570677841922", "instanceID", database.TextArray[string](nil)),
|
mock: mockQuery(regexp.QuoteMeta(oidcUserInfoQuery), []string{"json_build_object"}, []driver.Value{testdataUserInfoMachine}, "240707570677841922", "instanceID", database.TextArray[string](nil)),
|
||||||
want: &OIDCUserInfo{
|
want: &OIDCUserInfo{
|
||||||
User: &User{
|
User: &User{
|
||||||
ID: "240707570677841922",
|
ID: "240707570677841922",
|
||||||
@@ -331,7 +435,7 @@ func TestQueries_GetOIDCUserInfo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
ctx := authz.NewMockContext("instanceID", "orgID", "loginClient")
|
ctx := authz.NewMockContext("instanceID", "orgID", "loginClient")
|
||||||
|
|
||||||
got, err := q.GetOIDCUserInfo(ctx, tt.args.userID, tt.args.roleAudience)
|
got, err := q.GetOIDCUserInfo(ctx, tt.args.userID, tt.args.roleAudience, tt.args.roleOrgIDs...)
|
||||||
require.ErrorIs(t, err, tt.wantErr)
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
assert.Equal(t, tt.want, got)
|
assert.Equal(t, tt.want, got)
|
||||||
})
|
})
|
||||||
|
Reference in New Issue
Block a user