diff --git a/internal/api/oidc/client.go b/internal/api/oidc/client.go index ced270e54..6acd1486c 100644 --- a/internal/api/oidc/client.go +++ b/internal/api/oidc/client.go @@ -32,6 +32,10 @@ const ( ClaimResourceOwnerName = ScopeResourceOwner + ":name" ClaimResourceOwnerPrimaryDomain = ScopeResourceOwner + ":primary_domain" ClaimActionLogFormat = "urn:zitadel:iam:action:%s:log" + ScopeUserGroups = "groups" + ClaimUserGroups = ScopeUserGroups + ScopeCustomUserGroups = "urn:zitadel:iam:user:groups" + ClaimCustomUserGroups = ScopeCustomUserGroups oidcCtx = "oidc" ) diff --git a/internal/api/oidc/client_converter.go b/internal/api/oidc/client_converter.go index ec254e2fc..3253d92e4 100644 --- a/internal/api/oidc/client_converter.go +++ b/internal/api/oidc/client_converter.go @@ -271,5 +271,8 @@ func isScopeAllowed(scope string, allowedScopes ...string) bool { if scope == ScopeProjectsRoles { return true } + if scope == ScopeCustomUserGroups || scope == ScopeUserGroups { + return true + } return slices.Contains(allowedScopes, scope) } diff --git a/internal/api/oidc/integration_test/userinfo_test.go b/internal/api/oidc/integration_test/userinfo_test.go index b1b636869..e041d8721 100644 --- a/internal/api/oidc/integration_test/userinfo_test.go +++ b/internal/api/oidc/integration_test/userinfo_test.go @@ -30,6 +30,13 @@ func TestServer_UserInfo(t *testing.T) { clientID, projectID := createClient(t, Instance) addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar) + // create user groups + group1 := Instance.CreateGroup(CTXIAM, t, Instance.DefaultOrg.GetId(), "group1") + group2 := Instance.CreateGroup(CTXIAM, t, Instance.DefaultOrg.GetId(), "group2") + // add the user to groups + Instance.AddUsersToGroup(CTXIAM, t, group1.GetId(), []string{User.GetUserId()}) + Instance.AddUsersToGroup(CTXIAM, t, group2.GetId(), []string{User.GetUserId()}) + tests := []struct { name string prepare func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] @@ -154,6 +161,39 @@ func TestServer_UserInfo(t *testing.T) { }, }, }, + { + name: "custom group scope", + prepare: getTokens, + scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess, + oidc_api.ScopeCustomUserGroups}, + assertions: []func(*testing.T, *oidc.UserInfo){ + assertUserinfo, + func(t *testing.T, info *oidc.UserInfo) { + expectedUserGroups := []map[string]interface{}{ + {"id": group1.GetId(), "name": "group1"}, + {"id": group2.GetId(), "name": "group2"}, + } + userGroupsClaim := info.Claims[oidc_api.ClaimCustomUserGroups] + require.Len(t, userGroupsClaim, len(expectedUserGroups)) + assert.ElementsMatch(t, expectedUserGroups, userGroupsClaim) + }, + }, + }, + { + name: "group scope", + prepare: getTokens, + scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess, + oidc_api.ScopeUserGroups}, + assertions: []func(*testing.T, *oidc.UserInfo){ + assertUserinfo, + func(t *testing.T, info *oidc.UserInfo) { + expectedUserGroups := []string{"group1", "group2"} + userGroupsClaim := info.Claims[oidc_api.ClaimUserGroups] + require.Len(t, userGroupsClaim, len(expectedUserGroups)) + assert.ElementsMatch(t, expectedUserGroups, userGroupsClaim) + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index 15ec5b21e..1ebbac581 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -190,6 +190,10 @@ func userInfoToOIDC(user *query.OIDCUserInfo, userInfoAssertion bool, scope []st setUserInfoMetadata(user.Metadata, out) case ScopeResourceOwner: setUserInfoOrgClaims(user, out) + case ScopeCustomUserGroups: + setUserInfoCustomUserGroups(user.UserGroups, out) + case ScopeUserGroups: + setUserInfoUserGroups(user.UserGroups, out) default: if claim, ok := strings.CutPrefix(s, domain.OrgDomainPrimaryScope); ok { out.AppendClaims(domain.OrgDomainPrimaryClaim, claim) @@ -287,6 +291,24 @@ func setUserInfoRoleClaims(userInfo *oidc.UserInfo, roles *projectsRoles) { } } +func setUserInfoCustomUserGroups(userGroups []query.UserInfoUserGroup, out *oidc.UserInfo) { + if len(userGroups) == 0 { + return + } + out.AppendClaims(ClaimCustomUserGroups, userGroups) +} + +func setUserInfoUserGroups(userGroups []query.UserInfoUserGroup, out *oidc.UserInfo) { + if len(userGroups) == 0 { + return + } + groups := make([]string, len(userGroups)) + for i, userGroup := range userGroups { + groups[i] = userGroup.Name + } + out.AppendClaims(ClaimUserGroups, groups) +} + //nolint:gocognit func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, triggerType domain.TriggerType, clientID string) (err error) { ctx, span := tracing.NewSpan(ctx) diff --git a/internal/api/oidc/userinfo_test.go b/internal/api/oidc/userinfo_test.go index 2646cc903..2bb8556b2 100644 --- a/internal/api/oidc/userinfo_test.go +++ b/internal/api/oidc/userinfo_test.go @@ -179,6 +179,16 @@ func Test_userInfoToOIDC(t *testing.T) { Name: "orgName", PrimaryDomain: "orgDomain", } + userGroups := []query.UserInfoUserGroup{ + { + Name: "group1", + ID: "group1-id", + }, + { + Name: "group2", + ID: "group2-id", + }, + } humanUserInfo := &query.OIDCUserInfo{ User: &query.User{ ID: "human1", @@ -226,6 +236,7 @@ func Test_userInfoToOIDC(t *testing.T) { UserResourceOwner: "org1", }, }, + UserGroups: userGroups, } machineUserInfo := &query.OIDCUserInfo{ User: &query.User{ @@ -503,6 +514,41 @@ func Test_userInfoToOIDC(t *testing.T) { }, }, }, + { + name: "human, scope custom user groups, found", + args: args{ + user: humanUserInfo, + scope: []string{ScopeCustomUserGroups}, + }, + want: &oidc.UserInfo{ + Subject: "human1", + Claims: map[string]any{ + ClaimCustomUserGroups: []query.UserInfoUserGroup{ + { + Name: "group1", + ID: "group1-id", + }, + { + Name: "group2", + ID: "group2-id", + }, + }, + }, + }, + }, + { + name: "human, scope user groups (group names), found", + args: args{ + user: humanUserInfo, + scope: []string{ScopeUserGroups}, + }, + want: &oidc.UserInfo{ + Subject: "human1", + Claims: map[string]any{ + ClaimUserGroups: []string{"group1", "group2"}, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/query/projection/group.go b/internal/query/projection/group.go index 5f25f13c3..d8d488926 100644 --- a/internal/query/projection/group.go +++ b/internal/query/projection/group.go @@ -51,6 +51,7 @@ func (g *groupProjection) Init() *old_handler.Check { }, handler.NewPrimaryKey(GroupColumnInstanceID, GroupColumnID), handler.WithIndex(handler.NewIndex("resource_owner", []string{GroupColumnResourceOwner})), + handler.WithIndex(handler.NewIndex("state", []string{GroupColumnState})), ), ) } diff --git a/internal/query/userinfo.go b/internal/query/userinfo.go index aa2920dfb..eaf52ec5e 100644 --- a/internal/query/userinfo.go +++ b/internal/query/userinfo.go @@ -82,10 +82,11 @@ func (q *Queries) GetOIDCUserInfo(ctx context.Context, userID string, roleAudien } type OIDCUserInfo struct { - User *User `json:"user,omitempty"` - Metadata []UserMetadata `json:"metadata,omitempty"` - Org *UserInfoOrg `json:"org,omitempty"` - UserGrants []UserGrant `json:"user_grants,omitempty"` + User *User `json:"user,omitempty"` + Metadata []UserMetadata `json:"metadata,omitempty"` + Org *UserInfoOrg `json:"org,omitempty"` + UserGrants []UserGrant `json:"user_grants,omitempty"` + UserGroups []UserInfoUserGroup `json:"user_groups,omitempty"` } type UserInfoOrg struct { @@ -94,6 +95,11 @@ type UserInfoOrg struct { PrimaryDomain string `json:"primary_domain,omitempty"` } +type UserInfoUserGroup struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` +} + //go:embed userinfo_client_by_id.sql var oidcUserinfoClientQuery string diff --git a/internal/query/userinfo_by_id.sql b/internal/query/userinfo_by_id.sql index fec5bc968..99b8d1d16 100644 --- a/internal/query/userinfo_by_id.sql +++ b/internal/query/userinfo_by_id.sql @@ -62,6 +62,18 @@ user_org as ( join usr u on o.id = u.resource_owner ) r ), +-- find the user's groups +user_groups as ( + select json_agg(row_to_json(r)) as user_groups from ( + select g.id, g.name + from projections.group_users1 as gu + left join projections.groups1 as g on gu.group_id = g.id and gu.instance_id = g.instance_id + where gu.user_id = $1 + and gu.instance_id = $2 + and g.state = 1 + ) + r +), -- join user grants to orgs, projects and user grants as ( select json_agg(row_to_json(r)) as grants from ( @@ -87,5 +99,6 @@ select json_build_object( ), 'org', (select organization from user_org), 'metadata', (select metadata from metadata), - 'user_grants', (select grants from grants) + 'user_grants', (select grants from grants), + 'user_groups', (select user_groups from user_groups) );