feat: handle Zitadel provider IdP login via login v1 (#12469)

# Which Problems Are Solved

This PR adds support for using a ZITADEL IdP provider in the login v1 UI
flow and conditionally grants a support-user instance membership based
on a ZITADEL-specific project-roles token claim.

# How the Problems Are Solved

- Introduced ZITADEL IdP handling in the login v1 handler, including
extracting the `urn:zitadel:iam:org:project:roles` claim and granting
`IAM_OWNER_VIEWER` instance membership when configured orgs match.
- Added a new `AddInstanceMemberFromLogin` to assign an instance
membership during the login v1 ZITADEL-IdP flow without permission
checks (as the authorization is established based on the project roles
in the token claim)
- Added unit tests

# Additional Changes

N/A

# Additional Context
- Closes https://github.com/zitadel/zitadel/issues/12446
This commit is contained in:
gayathri
2026-07-28 15:20:46 +02:00
committed by GitHub
parent 318acd4bc3
commit ffd9b39bdb
14 changed files with 908 additions and 492 deletions
+255
View File
@@ -276,6 +276,261 @@ func TestCommandSide_AddInstanceMember(t *testing.T) {
}
}
func TestCommandSide_EnsureInstanceMemberRolesFromLogin(t *testing.T) {
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
zitadelRoles []authz.RoleMapping
checkPermission domain.PermissionCheck
}
type args struct {
member *AddInstanceMember
}
type res struct {
want *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
name: "no roles, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
member: &AddInstanceMember{
InstanceID: "INSTANCE",
UserID: "user1",
},
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "member not existing, added, bypasses permission check",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectFilter(
eventFromEventPusherWithInstanceID(
"INSTANCE",
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"username1",
"firstname1",
"lastname1",
"nickname1",
"displayname1",
language.German,
domain.GenderMale,
"email1",
true,
),
),
),
expectFilter(),
expectPush(
instance.NewMemberAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
"user1",
[]string{"IAM_OWNER_VIEWER"}...,
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
zitadelRoles: []authz.RoleMapping{
{
Role: "IAM_OWNER_VIEWER",
},
},
},
args: args{
member: &AddInstanceMember{
InstanceID: "INSTANCE",
UserID: "user1",
Roles: []string{"IAM_OWNER_VIEWER"},
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "INSTANCE",
},
},
},
{
name: "member existing without the role, role added",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
instance.NewMemberAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
"user1",
[]string{"IAM_LOGIN_CLIENT"}...,
),
),
),
expectPush(
instance.NewMemberChangedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
"user1",
[]string{"IAM_LOGIN_CLIENT", "IAM_OWNER_VIEWER"}...,
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
zitadelRoles: []authz.RoleMapping{
{
Role: "IAM_LOGIN_CLIENT",
},
{
Role: "IAM_OWNER_VIEWER",
},
},
},
args: args{
member: &AddInstanceMember{
InstanceID: "INSTANCE",
UserID: "user1",
Roles: []string{"IAM_OWNER_VIEWER"},
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "INSTANCE",
},
},
},
{
name: "member existing with the role, no push",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
instance.NewMemberAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
"user1",
[]string{"IAM_OWNER", "IAM_OWNER_VIEWER"}...,
),
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
zitadelRoles: []authz.RoleMapping{
{
Role: "IAM_OWNER",
},
{
Role: "IAM_OWNER_VIEWER",
},
},
},
args: args{
member: &AddInstanceMember{
InstanceID: "INSTANCE",
UserID: "user1",
Roles: []string{"IAM_OWNER_VIEWER"},
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "INSTANCE",
},
},
},
{
name: "no role configured on instance, invalid argument error",
fields: fields{
eventstore: expectEventstore(),
checkPermission: newMockPermissionCheckNotAllowed(),
zitadelRoles: []authz.RoleMapping{
{
Role: "IAM_OWNER",
},
},
},
args: args{
member: &AddInstanceMember{
InstanceID: "INSTANCE",
UserID: "user1",
Roles: []string{"IAM_OWNER_VIEWER"},
},
},
res: res{
err: zerrors.IsErrorInvalidArgument,
},
},
{
name: "role not configured on instance, dropped from the granted roles",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
instance.NewMemberAddedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
"user1",
[]string{"IAM_LOGIN_CLIENT"}...,
),
),
),
expectPush(
instance.NewMemberChangedEvent(context.Background(),
&instance.NewAggregate("INSTANCE").Aggregate,
"user1",
[]string{"IAM_LOGIN_CLIENT", "IAM_OWNER_VIEWER"}...,
),
),
),
checkPermission: newMockPermissionCheckNotAllowed(),
zitadelRoles: []authz.RoleMapping{
{
Role: "IAM_LOGIN_CLIENT",
},
{
Role: "IAM_OWNER_VIEWER",
},
},
},
args: args{
member: &AddInstanceMember{
InstanceID: "INSTANCE",
UserID: "user1",
Roles: []string{"IAM_OWNER_VIEWER", "IAM_ROLE_OF_OTHER_INSTANCE"},
},
},
res: res{
want: &domain.ObjectDetails{
ResourceOwner: "INSTANCE",
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &Commands{
eventstore: tt.fields.eventstore(t),
zitadelRoles: tt.fields.zitadelRoles,
checkPermission: tt.fields.checkPermission,
}
got, err := r.EnsureInstanceMemberRolesFromLogin(context.Background(), tt.args.member)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assertObjectDetails(t, tt.res.want, got)
}
})
}
}
func TestCommandSide_ChangeInstanceMember(t *testing.T) {
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore