diff --git a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.html b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.html index edd2186d7a..948f5cfa26 100644 --- a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.html +++ b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.html @@ -3,7 +3,7 @@
-
+
{{ (componentType === LoginMethodComponentType.SecondFactor diff --git a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts index 8af41d6556..e16f722a63 100644 --- a/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts +++ b/console/src/app/modules/policies/login-policy/factor-table/factor-table.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { MatPaginator } from '@angular/material/paginator'; import { TranslateService } from '@ngx-translate/core'; @@ -34,14 +34,15 @@ export enum LoginMethodComponentType { templateUrl: './factor-table.component.html', styleUrls: ['./factor-table.component.scss'], }) -export class FactorTableComponent implements OnInit { +export class FactorTableComponent { public LoginMethodComponentType: any = LoginMethodComponentType; @Input() componentType!: LoginMethodComponentType; @Input() public serviceType!: PolicyComponentServiceType; @Input() service!: AdminService | ManagementService; @Input() disabled: boolean = false; + @Input() list: Array = []; + @Output() listChanged: EventEmitter = new EventEmitter(); @ViewChild(MatPaginator) public paginator!: MatPaginator; - public mfas: Array = []; private loadingSubject: BehaviorSubject = new BehaviorSubject(false); public loading$: Observable = this.loadingSubject.asObservable(); @@ -50,10 +51,6 @@ export class FactorTableComponent implements OnInit { constructor(public translate: TranslateService, private toast: ToastService, private dialog: MatDialog) {} - public ngOnInit(): void { - this.getData(); - } - public removeMfa(type: MultiFactorType | SecondFactorType): void { const dialogRef = this.dialog.open(WarnDialogComponent, { data: { @@ -73,14 +70,14 @@ export class FactorTableComponent implements OnInit { req.setType(type as MultiFactorType); (this.service as ManagementService).removeMultiFactorFromLoginPolicy(req).then(() => { this.toast.showInfo('MFA.TOAST.DELETED', true); - this.refreshPageAfterTimout(2000); + this.listChanged.emit(); }); } else if (this.componentType === LoginMethodComponentType.SecondFactor) { const req = new MgmtRemoveSecondFactorFromLoginPolicyRequest(); req.setType(type as SecondFactorType); (this.service as ManagementService).removeSecondFactorFromLoginPolicy(req).then(() => { this.toast.showInfo('MFA.TOAST.DELETED', true); - this.refreshPageAfterTimout(2000); + this.listChanged.emit(); }); } } else if (this.serviceType === PolicyComponentServiceType.ADMIN) { @@ -89,14 +86,14 @@ export class FactorTableComponent implements OnInit { req.setType(type as MultiFactorType); (this.service as AdminService).removeMultiFactorFromLoginPolicy(req).then(() => { this.toast.showInfo('MFA.TOAST.DELETED', true); - this.refreshPageAfterTimout(2000); + this.listChanged.emit(); }); } else if (this.componentType === LoginMethodComponentType.SecondFactor) { const req = new AdminRemoveSecondFactorFromLoginPolicyRequest(); req.setType(type as SecondFactorType); (this.service as AdminService).removeSecondFactorFromLoginPolicy(req).then(() => { this.toast.showInfo('MFA.TOAST.DELETED', true); - this.refreshPageAfterTimout(2000); + this.listChanged.emit(); }); } } @@ -124,7 +121,8 @@ export class FactorTableComponent implements OnInit { (this.service as ManagementService) .addMultiFactorToLoginPolicy(req) .then(() => { - this.refreshPageAfterTimout(2000); + this.toast.showInfo('MFA.TOAST.ADDED', true); + this.listChanged.emit(); }) .catch((error) => { this.toast.showError(error); @@ -135,7 +133,8 @@ export class FactorTableComponent implements OnInit { (this.service as ManagementService) .addSecondFactorToLoginPolicy(req) .then(() => { - this.refreshPageAfterTimout(2000); + this.toast.showInfo('MFA.TOAST.ADDED', true); + this.listChanged.emit(); }) .catch((error) => { this.toast.showError(error); @@ -148,7 +147,8 @@ export class FactorTableComponent implements OnInit { (this.service as AdminService) .addMultiFactorToLoginPolicy(req) .then(() => { - this.refreshPageAfterTimout(2000); + this.toast.showInfo('MFA.TOAST.ADDED', true); + this.listChanged.emit(); }) .catch((error) => { this.toast.showError(error); @@ -159,7 +159,8 @@ export class FactorTableComponent implements OnInit { (this.service as AdminService) .addSecondFactorToLoginPolicy(req) .then(() => { - this.refreshPageAfterTimout(2000); + this.toast.showInfo('MFA.TOAST.ADDED', true); + this.listChanged.emit(); }) .catch((error) => { this.toast.showError(error); @@ -170,66 +171,6 @@ export class FactorTableComponent implements OnInit { }); } - private async getData(): Promise { - this.loadingSubject.next(true); - - if (this.serviceType === PolicyComponentServiceType.MGMT) { - if (this.componentType === LoginMethodComponentType.MultiFactor) { - (this.service as ManagementService) - .listLoginPolicyMultiFactors() - .then((resp) => { - this.mfas = resp.resultList; - this.loadingSubject.next(false); - }) - .catch((error) => { - this.toast.showError(error); - this.loadingSubject.next(false); - }); - } else if (this.componentType === LoginMethodComponentType.SecondFactor) { - (this.service as ManagementService) - .listLoginPolicySecondFactors() - .then((resp) => { - this.mfas = resp.resultList; - this.loadingSubject.next(false); - }) - .catch((error) => { - this.toast.showError(error); - this.loadingSubject.next(false); - }); - } - } else if (this.serviceType === PolicyComponentServiceType.ADMIN) { - if (this.componentType === LoginMethodComponentType.MultiFactor) { - (this.service as AdminService) - .listLoginPolicyMultiFactors() - .then((resp) => { - this.mfas = resp.resultList; - this.loadingSubject.next(false); - }) - .catch((error) => { - this.toast.showError(error); - this.loadingSubject.next(false); - }); - } else if (this.componentType === LoginMethodComponentType.SecondFactor) { - (this.service as AdminService) - .listLoginPolicySecondFactors() - .then((resp) => { - this.mfas = resp.resultList; - this.loadingSubject.next(false); - }) - .catch((error) => { - this.toast.showError(error); - this.loadingSubject.next(false); - }); - } - } - } - - public refreshPageAfterTimout(to: number): void { - setTimeout(() => { - this.getData(); - }, to); - } - public get availableSelection(): Array { const allTypes: MultiFactorType[] | SecondFactorType[] = this.componentType === LoginMethodComponentType.MultiFactor @@ -238,7 +179,7 @@ export class FactorTableComponent implements OnInit { ? [SecondFactorType.SECOND_FACTOR_TYPE_U2F, SecondFactorType.SECOND_FACTOR_TYPE_OTP] : []; - const filtered = (allTypes as Array).filter((type) => !this.mfas.includes(type)); + const filtered = (allTypes as Array).filter((type) => !this.list.includes(type)); return filtered; } diff --git a/console/src/app/modules/policies/login-policy/login-policy.component.html b/console/src/app/modules/policies/login-policy/login-policy.component.html index 6b76509464..946407da94 100644 --- a/console/src/app/modules/policies/login-policy/login-policy.component.html +++ b/console/src/app/modules/policies/login-policy/login-policy.component.html @@ -55,6 +55,8 @@ [service]="service" [serviceType]="serviceType" [componentType]="LoginMethodComponentType.MultiFactor" + [list]="loginData.multiFactorsList" + (listChanged)="fetchData()" [disabled]=" loginData?.passwordlessType === PasswordlessType.PASSWORDLESS_TYPE_NOT_ALLOWED || ([ @@ -103,6 +105,8 @@ [service]="service" [serviceType]="serviceType" [componentType]="LoginMethodComponentType.SecondFactor" + [list]="loginData.secondFactorsList" + (listChanged)="fetchData()" [disabled]=" ([ serviceType === PolicyComponentServiceType.ADMIN diff --git a/console/src/app/modules/policies/login-policy/login-policy.component.ts b/console/src/app/modules/policies/login-policy/login-policy.component.ts index cb4c4d0d10..8f180b7d6f 100644 --- a/console/src/app/modules/policies/login-policy/login-policy.component.ts +++ b/console/src/app/modules/policies/login-policy/login-policy.component.ts @@ -57,7 +57,7 @@ export class LoginPolicyComponent implements OnInit { }); } - private fetchData(): void { + public fetchData(): void { this.getData() .then((resp) => { if (resp.policy) { @@ -147,6 +147,8 @@ export class LoginPolicyComponent implements OnInit { mgmtreq.setForceMfa(this.loginData.forceMfa); mgmtreq.setPasswordlessType(this.loginData.passwordlessType); mgmtreq.setHidePasswordReset(this.loginData.hidePasswordReset); + mgmtreq.setMultiFactorsList(this.loginData.multiFactorsList); + mgmtreq.setSecondFactorsList(this.loginData.secondFactorsList); const pcl = new Duration().setSeconds((this.passwordCheckLifetime?.value ?? 240) * 60 * 60); mgmtreq.setPasswordCheckLifetime(pcl); diff --git a/docs/docs/apis/proto/management.md b/docs/docs/apis/proto/management.md index 9250bbfe5a..3498aa9aed 100644 --- a/docs/docs/apis/proto/management.md +++ b/docs/docs/apis/proto/management.md @@ -3046,6 +3046,21 @@ This is an empty request | mfa_init_skip_lifetime | google.protobuf.Duration | - | | | second_factor_check_lifetime | google.protobuf.Duration | - | | | multi_factor_check_lifetime | google.protobuf.Duration | - | | +| second_factors | repeated zitadel.policy.v1.SecondFactorType | - | | +| multi_factors | repeated zitadel.policy.v1.MultiFactorType | - | | +| idps | repeated AddCustomLoginPolicyRequest.IDP | - | | + + + + +### AddCustomLoginPolicyRequest.IDP + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| idp_id | string | - | string.min_len: 1
string.max_len: 200
| +| ownerType | zitadel.idp.v1.IDPOwnerType | - | enum.defined_only: true
enum.not_in: [0]
| diff --git a/docs/docs/apis/proto/policy.md b/docs/docs/apis/proto/policy.md index 2f519c8d3a..026836f72f 100644 --- a/docs/docs/apis/proto/policy.md +++ b/docs/docs/apis/proto/policy.md @@ -85,6 +85,9 @@ title: zitadel/policy.proto | mfa_init_skip_lifetime | google.protobuf.Duration | - | | | second_factor_check_lifetime | google.protobuf.Duration | - | | | multi_factor_check_lifetime | google.protobuf.Duration | - | | +| second_factors | repeated SecondFactorType | - | | +| multi_factors | repeated MultiFactorType | - | | +| idps | repeated zitadel.idp.v1.IDPLoginPolicyLink | - | | diff --git a/internal/api/grpc/management/policy_login_converter.go b/internal/api/grpc/management/policy_login_converter.go index f4f98d33f4..6b2c6cb88a 100644 --- a/internal/api/grpc/management/policy_login_converter.go +++ b/internal/api/grpc/management/policy_login_converter.go @@ -1,6 +1,7 @@ package management import ( + idp_grpc "github.com/zitadel/zitadel/internal/api/grpc/idp" "github.com/zitadel/zitadel/internal/api/grpc/object" policy_grpc "github.com/zitadel/zitadel/internal/api/grpc/policy" "github.com/zitadel/zitadel/internal/domain" @@ -23,8 +24,21 @@ func addLoginPolicyToDomain(p *mgmt_pb.AddCustomLoginPolicyRequest) *domain.Logi MFAInitSkipLifetime: p.MfaInitSkipLifetime.AsDuration(), SecondFactorCheckLifetime: p.SecondFactorCheckLifetime.AsDuration(), MultiFactorCheckLifetime: p.MultiFactorCheckLifetime.AsDuration(), + SecondFactors: policy_grpc.SecondFactorsTypesToDomain(p.SecondFactors), + MultiFactors: policy_grpc.MultiFactorsTypesToDomain(p.MultiFactors), + IDPProviders: addLoginPolicyIDPsToDomain(p.Idps), } } +func addLoginPolicyIDPsToDomain(idps []*mgmt_pb.AddCustomLoginPolicyRequest_IDP) []*domain.IDPProvider { + providers := make([]*domain.IDPProvider, len(idps)) + for i, idp := range idps { + providers[i] = &domain.IDPProvider{ + Type: idp_grpc.IDPProviderTypeFromPb(idp.OwnerType), + IDPConfigID: idp.IdpId, + } + } + return providers +} func updateLoginPolicyToDomain(p *mgmt_pb.UpdateCustomLoginPolicyRequest) *domain.LoginPolicy { return &domain.LoginPolicy{ diff --git a/internal/api/grpc/policy/second_factor.go b/internal/api/grpc/policy/auth_factor.go similarity index 65% rename from internal/api/grpc/policy/second_factor.go rename to internal/api/grpc/policy/auth_factor.go index 84a61ea00a..56a155413c 100644 --- a/internal/api/grpc/policy/second_factor.go +++ b/internal/api/grpc/policy/auth_factor.go @@ -5,6 +5,14 @@ import ( policy_pb "github.com/zitadel/zitadel/pkg/grpc/policy" ) +func SecondFactorsTypesToDomain(secondFactorTypes []policy_pb.SecondFactorType) []domain.SecondFactorType { + types := make([]domain.SecondFactorType, len(secondFactorTypes)) + for i, factorType := range secondFactorTypes { + types[i] = SecondFactorTypeToDomain(factorType) + } + return types +} + func SecondFactorTypeToDomain(secondFactorType policy_pb.SecondFactorType) domain.SecondFactorType { switch secondFactorType { case policy_pb.SecondFactorType_SECOND_FACTOR_TYPE_OTP: @@ -35,6 +43,23 @@ func ModelSecondFactorTypeToPb(secondFactorType domain.SecondFactorType) policy_ } } +func MultiFactorsTypesToDomain(multiFactorTypes []policy_pb.MultiFactorType) []domain.MultiFactorType { + types := make([]domain.MultiFactorType, len(multiFactorTypes)) + for i, factorType := range multiFactorTypes { + types[i] = MultiFactorTypeToDomain(factorType) + } + return types +} + +func MultiFactorTypeToDomain(multiFactorType policy_pb.MultiFactorType) domain.MultiFactorType { + switch multiFactorType { + case policy_pb.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION: + return domain.MultiFactorTypeU2FWithPIN + default: + return domain.MultiFactorTypeUnspecified + } +} + func ModelMultiFactorTypesToPb(types []domain.MultiFactorType) []policy_pb.MultiFactorType { t := make([]policy_pb.MultiFactorType, len(types)) for i, typ := range types { diff --git a/internal/api/grpc/policy/login_policy.go b/internal/api/grpc/policy/login_policy.go index 4b8d1c0376..b425bfc915 100644 --- a/internal/api/grpc/policy/login_policy.go +++ b/internal/api/grpc/policy/login_policy.go @@ -4,6 +4,7 @@ import ( "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" + idp_grpc "github.com/zitadel/zitadel/internal/api/grpc/idp" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/object" @@ -26,6 +27,9 @@ func ModelLoginPolicyToPb(policy *query.LoginPolicy) *policy_pb.LoginPolicy { MfaInitSkipLifetime: durationpb.New(policy.MFAInitSkipLifetime), SecondFactorCheckLifetime: durationpb.New(policy.SecondFactorCheckLifetime), MultiFactorCheckLifetime: durationpb.New(policy.MultiFactorCheckLifetime), + SecondFactors: ModelSecondFactorTypesToPb(policy.SecondFactors), + MultiFactors: ModelMultiFactorTypesToPb(policy.MultiFactors), + Idps: idp_grpc.IDPLoginPolicyLinksToPb(policy.IDPLinks), Details: &object.ObjectDetails{ Sequence: policy.Sequence, CreationDate: timestamppb.New(policy.CreationDate), diff --git a/internal/api/grpc/policy/multi_factor.go b/internal/api/grpc/policy/multi_factor.go deleted file mode 100644 index 3064e5c586..0000000000 --- a/internal/api/grpc/policy/multi_factor.go +++ /dev/null @@ -1,15 +0,0 @@ -package policy - -import ( - "github.com/zitadel/zitadel/internal/domain" - policy_pb "github.com/zitadel/zitadel/pkg/grpc/policy" -) - -func MultiFactorTypeToDomain(multiFactorType policy_pb.MultiFactorType) domain.MultiFactorType { - switch multiFactorType { - case policy_pb.MultiFactorType_MULTI_FACTOR_TYPE_U2F_WITH_VERIFICATION: - return domain.MultiFactorTypeU2FWithPIN - default: - return domain.MultiFactorTypeUnspecified - } -} diff --git a/internal/command/org_policy_login.go b/internal/command/org_policy_login.go index ec8069608e..55dcfeb107 100644 --- a/internal/command/org_policy_login.go +++ b/internal/command/org_policy_login.go @@ -29,8 +29,7 @@ func (c *Commands) AddLoginPolicy(ctx context.Context, resourceOwner string, pol } orgAgg := OrgAggregateFromWriteModel(&addedPolicy.WriteModel) - pushedEvents, err := c.eventstore.Push( - ctx, + cmds := []eventstore.Command{ org.NewLoginPolicyAddedEvent( ctx, orgAgg, @@ -46,7 +45,32 @@ func (c *Commands) AddLoginPolicy(ctx context.Context, resourceOwner string, pol policy.ExternalLoginCheckLifetime, policy.MFAInitSkipLifetime, policy.SecondFactorCheckLifetime, - policy.MultiFactorCheckLifetime)) + policy.MultiFactorCheckLifetime), + } + for _, factor := range policy.SecondFactors { + if !factor.Valid() { + return nil, caos_errs.ThrowInvalidArgument(nil, "Org-SFeea", "Errors.Org.LoginPolicy.MFA.Unspecified") + } + cmds = append(cmds, org.NewLoginPolicySecondFactorAddedEvent(ctx, orgAgg, factor)) + } + for _, factor := range policy.MultiFactors { + if !factor.Valid() { + return nil, caos_errs.ThrowInvalidArgument(nil, "Org-WSfrg", "Errors.Org.LoginPolicy.MFA.Unspecified") + } + cmds = append(cmds, org.NewLoginPolicyMultiFactorAddedEvent(ctx, orgAgg, factor)) + } + for _, provider := range policy.IDPProviders { + if provider.Type == domain.IdentityProviderTypeOrg { + _, err = c.getOrgIDPConfigByID(ctx, provider.IDPConfigID, resourceOwner) + } else { + _, err = c.getInstanceIDPConfigByID(ctx, provider.IDPConfigID) + } + if err != nil { + return nil, caos_errs.ThrowPreconditionFailed(err, "Org-FEd32", "Errors.IDPConfig.NotExisting") + } + cmds = append(cmds, org.NewIdentityProviderAddedEvent(ctx, orgAgg, provider.IDPConfigID, provider.Type)) + } + pushedEvents, err := c.eventstore.Push(ctx, cmds...) if err != nil { return nil, err } diff --git a/internal/command/org_policy_login_test.go b/internal/command/org_policy_login_test.go index 6d59061533..fe1fc7fc70 100644 --- a/internal/command/org_policy_login_test.go +++ b/internal/command/org_policy_login_test.go @@ -12,6 +12,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/v1/models" + "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/policy" "github.com/zitadel/zitadel/internal/repository/user" @@ -183,6 +184,257 @@ func TestCommandSide_AddLoginPolicy(t *testing.T) { }, }, }, + { + name: "add policy with invalid factors, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + policy: &domain.LoginPolicy{ + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + DefaultRedirectURI: "https://example.com/redirect", + PasswordCheckLifetime: time.Hour * 1, + ExternalLoginCheckLifetime: time.Hour * 2, + MFAInitSkipLifetime: time.Hour * 3, + SecondFactorCheckLifetime: time.Hour * 4, + MultiFactorCheckLifetime: time.Hour * 5, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeUnspecified}, + }, + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "add policy factors,ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + true, + true, + true, + true, + true, + domain.PasswordlessTypeAllowed, + "https://example.com/redirect", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + eventFromEventPusher( + org.NewLoginPolicySecondFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.SecondFactorTypeOTP, + ), + ), + eventFromEventPusher( + org.NewLoginPolicyMultiFactorAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + domain.MultiFactorTypeU2FWithPIN, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + policy: &domain.LoginPolicy{ + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + DefaultRedirectURI: "https://example.com/redirect", + PasswordCheckLifetime: time.Hour * 1, + ExternalLoginCheckLifetime: time.Hour * 2, + MFAInitSkipLifetime: time.Hour * 3, + SecondFactorCheckLifetime: time.Hour * 4, + MultiFactorCheckLifetime: time.Hour * 5, + SecondFactors: []domain.SecondFactorType{domain.SecondFactorTypeOTP}, + MultiFactors: []domain.MultiFactorType{domain.MultiFactorTypeU2FWithPIN}, + }, + }, + res: res{ + want: &domain.LoginPolicy{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "org1", + ResourceOwner: "org1", + }, + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + DefaultRedirectURI: "https://example.com/redirect", + PasswordCheckLifetime: time.Hour * 1, + ExternalLoginCheckLifetime: time.Hour * 2, + MFAInitSkipLifetime: time.Hour * 3, + SecondFactorCheckLifetime: time.Hour * 4, + MultiFactorCheckLifetime: time.Hour * 5, + }, + }, + }, + { + name: "add policy with unknown idp, invalid argument error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + policy: &domain.LoginPolicy{ + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + DefaultRedirectURI: "https://example.com/redirect", + PasswordCheckLifetime: time.Hour * 1, + ExternalLoginCheckLifetime: time.Hour * 2, + MFAInitSkipLifetime: time.Hour * 3, + SecondFactorCheckLifetime: time.Hour * 4, + MultiFactorCheckLifetime: time.Hour * 5, + IDPProviders: []*domain.IDPProvider{ + { + Type: domain.IdentityProviderTypeSystem, + IDPConfigID: "invalid", + }, + }, + }, + }, + res: res{ + err: caos_errs.IsPreconditionFailed, + }, + }, + { + name: "add policy idp, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectFilter( + eventFromEventPusher( + instance.NewIDPConfigAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "config1", + "name1", + domain.IDPConfigTypeOIDC, + domain.IDPConfigStylingTypeGoogle, + true, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + org.NewLoginPolicyAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + true, + true, + true, + true, + true, + true, + domain.PasswordlessTypeAllowed, + "https://example.com/redirect", + time.Hour*1, + time.Hour*2, + time.Hour*3, + time.Hour*4, + time.Hour*5, + ), + ), + eventFromEventPusher( + org.NewIdentityProviderAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "config1", + domain.IdentityProviderTypeSystem, + ), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + policy: &domain.LoginPolicy{ + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + DefaultRedirectURI: "https://example.com/redirect", + PasswordCheckLifetime: time.Hour * 1, + ExternalLoginCheckLifetime: time.Hour * 2, + MFAInitSkipLifetime: time.Hour * 3, + SecondFactorCheckLifetime: time.Hour * 4, + MultiFactorCheckLifetime: time.Hour * 5, + IDPProviders: []*domain.IDPProvider{ + { + Type: domain.IdentityProviderTypeSystem, + IDPConfigID: "config1", + }, + }, + }, + }, + res: res{ + want: &domain.LoginPolicy{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "org1", + ResourceOwner: "org1", + }, + AllowRegister: true, + AllowUsernamePassword: true, + AllowExternalIDP: true, + ForceMFA: true, + HidePasswordReset: true, + IgnoreUnknownUsernames: true, + PasswordlessType: domain.PasswordlessTypeAllowed, + DefaultRedirectURI: "https://example.com/redirect", + PasswordCheckLifetime: time.Hour * 1, + ExternalLoginCheckLifetime: time.Hour * 2, + MFAInitSkipLifetime: time.Hour * 3, + SecondFactorCheckLifetime: time.Hour * 4, + MultiFactorCheckLifetime: time.Hour * 5, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/query/login_policy.go b/internal/query/login_policy.go index 69befcff73..86a8284b86 100644 --- a/internal/query/login_policy.go +++ b/internal/query/login_policy.go @@ -36,6 +36,7 @@ type LoginPolicy struct { MFAInitSkipLifetime time.Duration SecondFactorCheckLifetime time.Duration MultiFactorCheckLifetime time.Duration + IDPLinks []*IDPLoginPolicyLink } type SecondFactors struct { @@ -160,8 +161,11 @@ func (q *Queries) LoginPolicyByID(ctx context.Context, orgID string) (*LoginPoli return nil, errors.ThrowInternal(err, "QUERY-scVHo", "Errors.Query.SQLStatement") } - row := q.client.QueryRowContext(ctx, stmt, args...) - return scan(row) + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-SWgr3", "Errors.Internal") + } + return scan(rows) } func (q *Queries) DefaultLoginPolicy(ctx context.Context) (*LoginPolicy, error) { @@ -174,8 +178,11 @@ func (q *Queries) DefaultLoginPolicy(ctx context.Context) (*LoginPolicy, error) return nil, errors.ThrowInternal(err, "QUERY-t4TBK", "Errors.Query.SQLStatement") } - row := q.client.QueryRowContext(ctx, stmt, args...) - return scan(row) + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-SArt2", "Errors.Internal") + } + return scan(rows) } func (q *Queries) SecondFactorsByOrg(ctx context.Context, orgID string) (*SecondFactors, error) { @@ -278,7 +285,7 @@ func (q *Queries) DefaultMultiFactors(ctx context.Context) (*MultiFactors, error return factors, err } -func prepareLoginPolicyQuery() (sq.SelectBuilder, func(*sql.Row) (*LoginPolicy, error)) { +func prepareLoginPolicyQuery() (sq.SelectBuilder, func(*sql.Rows) (*LoginPolicy, error)) { return sq.Select( LoginPolicyColumnOrgID.identifier(), LoginPolicyColumnCreationDate.identifier(), @@ -300,39 +307,69 @@ func prepareLoginPolicyQuery() (sq.SelectBuilder, func(*sql.Row) (*LoginPolicy, LoginPolicyColumnMFAInitSkipLifetime.identifier(), LoginPolicyColumnSecondFactorCheckLifetime.identifier(), LoginPolicyColumnMultiFacotrCheckLifetime.identifier(), - ).From(loginPolicyTable.identifier()).PlaceholderFormat(sq.Dollar), - func(row *sql.Row) (*LoginPolicy, error) { + IDPLoginPolicyLinkIDPIDCol.identifier(), + IDPNameCol.identifier(), + IDPTypeCol.identifier(), + ).From(loginPolicyTable.identifier()). + LeftJoin(join(IDPLoginPolicyLinkIDPIDCol, LoginPolicyColumnOrgID)). + LeftJoin(join(IDPIDCol, IDPLoginPolicyLinkIDPIDCol)). + PlaceholderFormat(sq.Dollar), + func(rows *sql.Rows) (*LoginPolicy, error) { p := new(LoginPolicy) secondFactors := pq.Int32Array{} multiFactors := pq.Int32Array{} defaultRedirectURI := sql.NullString{} - err := row.Scan( - &p.OrgID, - &p.CreationDate, - &p.ChangeDate, - &p.Sequence, - &p.AllowRegister, - &p.AllowUsernamePassword, - &p.AllowExternalIDPs, - &p.ForceMFA, - &secondFactors, - &multiFactors, - &p.PasswordlessType, - &p.IsDefault, - &p.HidePasswordReset, - &p.IgnoreUnknownUsernames, - &defaultRedirectURI, - &p.PasswordCheckLifetime, - &p.ExternalLoginCheckLifetime, - &p.MFAInitSkipLifetime, - &p.SecondFactorCheckLifetime, - &p.MultiFactorCheckLifetime, - ) - if err != nil { - if errs.Is(err, sql.ErrNoRows) { - return nil, errors.ThrowNotFound(err, "QUERY-QsUBJ", "Errors.LoginPolicy.NotFound") + links := make([]*IDPLoginPolicyLink, 0) + for rows.Next() { + var ( + idpID = sql.NullString{} + idpName = sql.NullString{} + idpType = sql.NullInt16{} + ) + err := rows.Scan( + &p.OrgID, + &p.CreationDate, + &p.ChangeDate, + &p.Sequence, + &p.AllowRegister, + &p.AllowUsernamePassword, + &p.AllowExternalIDPs, + &p.ForceMFA, + &secondFactors, + &multiFactors, + &p.PasswordlessType, + &p.IsDefault, + &p.HidePasswordReset, + &p.IgnoreUnknownUsernames, + &defaultRedirectURI, + &p.PasswordCheckLifetime, + &p.ExternalLoginCheckLifetime, + &p.MFAInitSkipLifetime, + &p.SecondFactorCheckLifetime, + &p.MultiFactorCheckLifetime, + &idpID, + &idpName, + &idpType, + ) + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-YcC53", "Errors.Internal") } - return nil, errors.ThrowInternal(err, "QUERY-YcC53", "Errors.Internal") + var link IDPLoginPolicyLink + if idpID.Valid { + link = IDPLoginPolicyLink{IDPID: idpID.String} + + link.IDPName = idpName.String + //IDPType 0 is oidc so we have to set unspecified manually + if idpType.Valid { + link.IDPType = domain.IDPConfigType(idpType.Int16) + } else { + link.IDPType = domain.IDPConfigTypeUnspecified + } + links = append(links, &link) + } + } + if p.OrgID == "" { + return nil, errors.ThrowNotFound(nil, "QUERY-QsUBJ", "Errors.LoginPolicy.NotFound") } p.DefaultRedirectURI = defaultRedirectURI.String p.MultiFactors = make([]domain.MultiFactorType, len(multiFactors)) @@ -343,6 +380,7 @@ func prepareLoginPolicyQuery() (sq.SelectBuilder, func(*sql.Row) (*LoginPolicy, for i, mfa := range secondFactors { p.SecondFactors[i] = domain.SecondFactorType(mfa) } + p.IDPLinks = links return p, nil } } diff --git a/internal/query/login_policy_test.go b/internal/query/login_policy_test.go index e586ecffb4..ecefa8fa95 100644 --- a/internal/query/login_policy_test.go +++ b/internal/query/login_policy_test.go @@ -50,8 +50,15 @@ func Test_LoginPolicyPrepares(t *testing.T) { ` projections.login_policies.external_login_check_lifetime,`+ ` projections.login_policies.mfa_init_skip_lifetime,`+ ` projections.login_policies.second_factor_check_lifetime,`+ - ` projections.login_policies.multi_factor_check_lifetime`+ - ` FROM projections.login_policies`), + ` projections.login_policies.multi_factor_check_lifetime,`+ + ` projections.idp_login_policy_links.idp_id,`+ + ` projections.idps.name,`+ + ` projections.idps.type`+ + ` FROM projections.login_policies`+ + ` LEFT JOIN projections.idp_login_policy_links ON `+ + ` projections.login_policies.aggregate_id = projections.idp_login_policy_links.idp_id`+ + ` LEFT JOIN projections.idps ON`+ + ` projections.idp_login_policy_links.idp_id = projections.idps.id`), nil, nil, ), @@ -88,8 +95,15 @@ func Test_LoginPolicyPrepares(t *testing.T) { ` projections.login_policies.external_login_check_lifetime,`+ ` projections.login_policies.mfa_init_skip_lifetime,`+ ` projections.login_policies.second_factor_check_lifetime,`+ - ` projections.login_policies.multi_factor_check_lifetime`+ - ` FROM projections.login_policies`), + ` projections.login_policies.multi_factor_check_lifetime,`+ + ` projections.idp_login_policy_links.idp_id,`+ + ` projections.idps.name,`+ + ` projections.idps.type`+ + ` FROM projections.login_policies`+ + ` LEFT JOIN projections.idp_login_policy_links ON `+ + ` projections.login_policies.aggregate_id = projections.idp_login_policy_links.idp_id`+ + ` LEFT JOIN projections.idps ON`+ + ` projections.idp_login_policy_links.idp_id = projections.idps.id`), []string{ "aggregate_id", "creation_date", @@ -111,6 +125,9 @@ func Test_LoginPolicyPrepares(t *testing.T) { "mfa_init_skip_lifetime", "second_factor_check_lifetime", "multi_factor_check_lifetime", + "idp_id", + "name", + "type", }, []driver.Value{ "ro", @@ -133,6 +150,9 @@ func Test_LoginPolicyPrepares(t *testing.T) { time.Hour * 2, time.Hour * 2, time.Hour * 2, + "config1", + "IDP", + domain.IDPConfigTypeJWT, }, ), }, @@ -157,6 +177,13 @@ func Test_LoginPolicyPrepares(t *testing.T) { MFAInitSkipLifetime: time.Hour * 2, SecondFactorCheckLifetime: time.Hour * 2, MultiFactorCheckLifetime: time.Hour * 2, + IDPLinks: []*IDPLoginPolicyLink{ + { + IDPID: "config1", + IDPName: "IDP", + IDPType: domain.IDPConfigTypeJWT, + }, + }, }, }, { @@ -183,8 +210,15 @@ func Test_LoginPolicyPrepares(t *testing.T) { ` projections.login_policies.external_login_check_lifetime,`+ ` projections.login_policies.mfa_init_skip_lifetime,`+ ` projections.login_policies.second_factor_check_lifetime,`+ - ` projections.login_policies.multi_factor_check_lifetime`+ - ` FROM projections.login_policies`), + ` projections.login_policies.multi_factor_check_lifetime,`+ + ` projections.idp_login_policy_links.idp_id,`+ + ` projections.idps.name,`+ + ` projections.idps.type`+ + ` FROM projections.login_policies`+ + ` LEFT JOIN projections.idp_login_policy_links ON `+ + ` projections.login_policies.aggregate_id = projections.idp_login_policy_links.idp_id`+ + ` LEFT JOIN projections.idps ON`+ + ` projections.idp_login_policy_links.idp_id = projections.idps.id`), sql.ErrConnDone, ), err: func(err error) (error, bool) { diff --git a/internal/query/projection/app.go b/internal/query/projection/app.go index 60375af1ad..18c9ab168b 100644 --- a/internal/query/projection/app.go +++ b/internal/query/projection/app.go @@ -87,7 +87,7 @@ func NewAppProjection(ctx context.Context, config crdb.StatementHandlerConfig) * crdb.NewColumn(AppAPIConfigColumnClientSecret, crdb.ColumnTypeJSONB, crdb.Nullable()), crdb.NewColumn(AppAPIConfigColumnAuthMethod, crdb.ColumnTypeEnum), }, - crdb.NewPrimaryKey(AppAPIConfigColumnAppID), + crdb.NewPrimaryKey(AppAPIConfigColumnAppID, AppAPIConfigColumnInstanceID), appAPITableSuffix, crdb.WithForeignKey(crdb.NewForeignKeyOfPublicKeys("fk_api_ref_apps")), crdb.WithIndex(crdb.NewIndex("client_id_idx", []string{AppAPIConfigColumnClientID})), diff --git a/internal/query/projection/idp.go b/internal/query/projection/idp.go index 27a64c6f67..5e05535c96 100644 --- a/internal/query/projection/idp.go +++ b/internal/query/projection/idp.go @@ -76,7 +76,7 @@ func NewIDPProjection(ctx context.Context, config crdb.StatementHandlerConfig) * crdb.NewColumn(IDPStylingTypeCol, crdb.ColumnTypeEnum), crdb.NewColumn(IDPOwnerTypeCol, crdb.ColumnTypeEnum), crdb.NewColumn(IDPAutoRegisterCol, crdb.ColumnTypeBool, crdb.Default(false)), - crdb.NewColumn(IDPTypeCol, crdb.ColumnTypeEnum), + crdb.NewColumn(IDPTypeCol, crdb.ColumnTypeEnum, crdb.Nullable()), }, crdb.NewPrimaryKey(IDPIDCol, IDPInstanceIDCol), crdb.WithIndex(crdb.NewIndex("ro_idx", []string{IDPResourceOwnerCol})), @@ -92,9 +92,9 @@ func NewIDPProjection(ctx context.Context, config crdb.StatementHandlerConfig) * crdb.NewColumn(OIDCConfigDisplayNameMappingCol, crdb.ColumnTypeEnum, crdb.Nullable()), crdb.NewColumn(OIDCConfigUsernameMappingCol, crdb.ColumnTypeEnum, crdb.Nullable()), crdb.NewColumn(OIDCConfigAuthorizationEndpointCol, crdb.ColumnTypeText, crdb.Nullable()), - crdb.NewColumn(OIDCConfigTokenEndpointCol, crdb.ColumnTypeEnum, crdb.Nullable()), + crdb.NewColumn(OIDCConfigTokenEndpointCol, crdb.ColumnTypeText, crdb.Nullable()), }, - crdb.NewPrimaryKey(OIDCConfigIDPIDCol), + crdb.NewPrimaryKey(OIDCConfigIDPIDCol, OIDCConfigInstanceIDCol), IDPOIDCSuffix, crdb.WithForeignKey(crdb.NewForeignKeyOfPublicKeys("fk_oidc_ref_idp")), ), @@ -106,7 +106,7 @@ func NewIDPProjection(ctx context.Context, config crdb.StatementHandlerConfig) * crdb.NewColumn(JWTConfigHeaderNameCol, crdb.ColumnTypeText, crdb.Nullable()), crdb.NewColumn(JWTConfigEndpointCol, crdb.ColumnTypeText, crdb.Nullable()), }, - crdb.NewPrimaryKey(JWTConfigIDPIDCol), + crdb.NewPrimaryKey(JWTConfigIDPIDCol, JWTConfigInstanceIDCol), IDPJWTSuffix, crdb.WithForeignKey(crdb.NewForeignKeyOfPublicKeys("fk_jwt_ref_idp")), ), diff --git a/internal/query/projection/sms.go b/internal/query/projection/sms.go index 7b6a78e0c7..f0c00da3fa 100644 --- a/internal/query/projection/sms.go +++ b/internal/query/projection/sms.go @@ -60,7 +60,7 @@ func NewSMSConfigProjection(ctx context.Context, config crdb.StatementHandlerCon crdb.NewColumn(SMSTwilioConfigColumnSenderNumber, crdb.ColumnTypeText), crdb.NewColumn(SMSTwilioConfigColumnToken, crdb.ColumnTypeJSONB), }, - crdb.NewPrimaryKey(SMSTwilioConfigColumnSMSID), + crdb.NewPrimaryKey(SMSTwilioConfigColumnSMSID, SMSTwilioColumnInstanceID), smsTwilioTableSuffix, crdb.WithForeignKey(crdb.NewForeignKeyOfPublicKeys("fk_twilio_ref_sms")), ), diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index b614fbc7ed..647bf1ade1 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -4337,6 +4337,11 @@ message GetDefaultLoginPolicyResponse { } message AddCustomLoginPolicyRequest { + message IDP { + string idp_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + zitadel.idp.v1.IDPOwnerType ownerType = 2 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; + } + bool allow_username_password = 1; bool allow_register = 2; bool allow_external_idp = 3; @@ -4358,6 +4363,9 @@ message AddCustomLoginPolicyRequest { google.protobuf.Duration mfa_init_skip_lifetime = 11; google.protobuf.Duration second_factor_check_lifetime = 12; google.protobuf.Duration multi_factor_check_lifetime = 13; + repeated zitadel.policy.v1.SecondFactorType second_factors = 14; + repeated zitadel.policy.v1.MultiFactorType multi_factors = 15; + repeated IDP idps = 16; } message AddCustomLoginPolicyResponse { diff --git a/proto/zitadel/policy.proto b/proto/zitadel/policy.proto index 905cd38f35..71b1ba0d7e 100644 --- a/proto/zitadel/policy.proto +++ b/proto/zitadel/policy.proto @@ -1,6 +1,7 @@ syntax = "proto3"; import "zitadel/object.proto"; +import "zitadel/idp.proto"; import "google/protobuf/duration.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; @@ -170,7 +171,9 @@ message LoginPolicy { google.protobuf.Duration mfa_init_skip_lifetime = 13; google.protobuf.Duration second_factor_check_lifetime = 14; google.protobuf.Duration multi_factor_check_lifetime = 15; - + repeated SecondFactorType second_factors = 16; + repeated MultiFactorType multi_factors = 17; + repeated zitadel.idp.v1.IDPLoginPolicyLink idps = 18; } enum SecondFactorType {