diff --git a/internal/api/grpc/management/integration_test/passwordless_test.go b/internal/api/grpc/management/integration_test/passwordless_test.go new file mode 100644 index 000000000..836abefa0 --- /dev/null +++ b/internal/api/grpc/management/integration_test/passwordless_test.go @@ -0,0 +1,80 @@ +//go:build integration + +package management_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/management" +) + +// TestPasswordlessRegistration_CrossOrg simulates issuing a passwordless enrollment link for a +// user of a foreign organization through the deprecated v1 management API. +// +// The caller passes only its own organization, which the interceptor is happy with because that +// is where the caller holds the permission. The write lands on the target's aggregate regardless, +// so the denial has to come from the command, authorizing against the target's real owner. +func TestPasswordlessRegistration_CrossOrg(t *testing.T) { + orgB := Instance.CreateOrganization(IAMOwnerCTX, integration.OrganizationName(), integration.Email()) + victimID := Instance.CreateHumanUserVerified( + IAMOwnerCTX, orgB.GetOrganizationId(), integration.Email(), integration.Phone(), + ).GetUserId() + + // Either code is acceptable: the default permission backend reports the caller's missing + // membership in the target organization as not found, PermissionCheckV2 as permission denied. + deniedCodes := []codes.Code{codes.NotFound, codes.PermissionDenied} + + t.Run("org owner of another org is denied", func(t *testing.T) { + // ReturnCode equivalent: on success the link with the plaintext code is in the response, + // so asserting it stays empty proves nothing leaked. + got, err := Client.AddPasswordlessRegistration(OrgCTX, &management.AddPasswordlessRegistrationRequest{ + UserId: victimID, + }) + require.Error(t, err) + assert.Contains(t, deniedCodes, status.Code(err)) + assert.Empty(t, got.GetLink()) + + _, err = Client.SendPasswordlessRegistration(OrgCTX, &management.SendPasswordlessRegistrationRequest{ + UserId: victimID, + }) + require.Error(t, err) + assert.Contains(t, deniedCodes, status.Code(err)) + }) + + t.Run("org owner of the same org is allowed", func(t *testing.T) { + ownUserID := Instance.CreateHumanUserVerified( + IAMOwnerCTX, Instance.DefaultOrg.GetId(), integration.Email(), integration.Phone(), + ).GetUserId() + + got, err := Client.AddPasswordlessRegistration(OrgCTX, &management.AddPasswordlessRegistrationRequest{ + UserId: ownUserID, + }) + require.NoError(t, err) + assert.NotEmpty(t, got.GetLink()) + + _, err = Client.SendPasswordlessRegistration(OrgCTX, &management.SendPasswordlessRegistrationRequest{ + UserId: ownUserID, + }) + require.NoError(t, err) + }) + + // SendPasswordlessRegistration is annotated with user.write, which IAM_USER_MANAGER holds + // instance wide. Gating the command on user.credential.write instead - the annotation of the + // sibling AddPasswordlessRegistration - would lock this role out of the endpoint. + t.Run("instance wide user manager may still send", func(t *testing.T) { + _, pat, err := Instance.CreateMachineUserPATWithMembership(IAMOwnerCTX, "IAM_USER_MANAGER") + require.NoError(t, err) + managerCTX := integration.WithAuthorizationToken(CTX, pat) + + _, err = Client.SendPasswordlessRegistration(managerCTX, &management.SendPasswordlessRegistrationRequest{ + UserId: victimID, + }) + require.NoError(t, err) + }) +} diff --git a/internal/api/grpc/user/v2/integration_test/passkey_test.go b/internal/api/grpc/user/v2/integration_test/passkey_test.go index 96dc394ed..549ad3bd0 100644 --- a/internal/api/grpc/user/v2/integration_test/passkey_test.go +++ b/internal/api/grpc/user/v2/integration_test/passkey_test.go @@ -10,6 +10,8 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -322,6 +324,51 @@ func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { } } +// TestServer_CreatePasskeyRegistrationLink_CrossOrg simulates the cross-organization passkey +// enrollment code issuance: the RPC's auth annotation carries user.passkey.write with no +// org_field, so the interceptor only checks the caller's permission in the request-header org. +// An org owner of one organization must not be able to mint an enrollment code - and with it a +// takeover of the account - for a user in another organization. +func TestServer_CreatePasskeyRegistrationLink_CrossOrg(t *testing.T) { + // The victim lives in another organization than the caller. + orgB := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), integration.Email()) + victimID := Instance.CreateHumanUserVerified( + IamCTX, orgB.GetOrganizationId(), integration.Email(), integration.Phone(), + ).GetUserId() + + t.Run("org owner of another org is denied", func(t *testing.T) { + // The attacker is ORG_OWNER of the default org only, and pins the request-header org + // to it - exactly the scope the interceptor verifies. + attackerCTX := integration.SetOrgID(OrgCTX, Instance.DefaultOrg.GetId()) + + got, err := Client.CreatePasskeyRegistrationLink(attackerCTX, &user.CreatePasskeyRegistrationLinkRequest{ + UserId: victimID, + Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, + }) + require.Error(t, err) + // The default permission path reports "membership not found" (NotFound) because the + // caller has no membership in the victim's org at all; the v2 permission check reports + // PermissionDenied. Either is a correct denial. + assert.Contains(t, []codes.Code{codes.NotFound, codes.PermissionDenied}, status.Code(err)) + assert.Empty(t, got.GetCode().GetCode()) + }) + + t.Run("instance wide user manager is allowed", func(t *testing.T) { + // IAM_USER_MANAGER holds user.passkey.write instance wide, so it must keep working + // across organizations - the check narrows the org, it must not narrow the role set. + _, pat, err := Instance.CreateMachineUserPATWithMembership(IamCTX, "IAM_USER_MANAGER") + require.NoError(t, err) + managerCTX := integration.WithAuthorizationToken(CTX, pat) + + got, err := Client.CreatePasskeyRegistrationLink(managerCTX, &user.CreatePasskeyRegistrationLinkRequest{ + UserId: victimID, + Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, + }) + require.NoError(t, err) + assert.NotEmpty(t, got.GetCode().GetId()) + }) +} + func userWithPasskeyRegistered(t *testing.T) (string, *user.RegisterPasskeyResponse) { userID := Instance.CreateHumanUser(OrgCTX).GetUserId() return userID, passkeyRegister(t, userID) diff --git a/internal/command/crypto_test.go b/internal/command/crypto_test.go index 4b4e6ed75..c1149f8de 100644 --- a/internal/command/crypto_test.go +++ b/internal/command/crypto_test.go @@ -34,6 +34,15 @@ func mockEncryptedCode(code string, exp time.Duration) encrypedCodeFunc { } } +// newEncryptedCodeNoCall fails the test if a code is generated, e.g. before a permission check +// denied the request. +func newEncryptedCodeNoCall(t *testing.T) encrypedCodeFunc { + return func(context.Context, preparation.FilterToQueryReducer, domain.SecretGeneratorType, crypto.EncryptionAlgorithm) (*EncryptedCode, error) { + t.Error("unexpected code generation") + return nil, nil + } +} + func mockEncryptedCodeWithDefault(code string, exp time.Duration) encryptedCodeWithDefaultFunc { return func(ctx context.Context, filter preparation.FilterToQueryReducer, _ domain.SecretGeneratorType, alg crypto.EncryptionAlgorithm, _ *crypto.GeneratorConfig) (*EncryptedCode, error) { return &EncryptedCode{ diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 9e32df164..ea9629a1b 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -72,6 +72,14 @@ func (c *Commands) checkPermissionUpdateUserCredentials(ctx context.Context, res return c.checkPermissionOnUser(ctx, domain.PermissionUserCredentialWrite, true)(resourceOwner, userID) } +// checkPermissionUpdateUserPasskey checks the same permission the passkey RPCs declare in their +// auth annotation, so that no role loses access. The API interceptor only verifies it in the +// caller-supplied request-header org, therefore commands must re-check it against the target +// user's actual resource owner. +func (c *Commands) checkPermissionUpdateUserPasskey(ctx context.Context, resourceOwner, userID string) error { + return c.checkPermissionOnUser(ctx, domain.PermissionUserPasskeyWrite, true)(resourceOwner, userID) +} + func (c *Commands) checkPermissionCreateProject(ctx context.Context, resourceOwner, projectID string) error { return c.newPermissionCheck(ctx, domain.PermissionProjectCreate, project.AggregateType)(resourceOwner, projectID) } diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go index db1731693..41aa35d71 100644 --- a/internal/command/user_human_webauthn.go +++ b/internal/command/user_human_webauthn.go @@ -486,7 +486,22 @@ func (c *Commands) HumanRemovePasswordless(ctx context.Context, userID, webAuthN return c.removeHumanWebAuthN(ctx, userID, webAuthNID, resourceOwner, event) } +// HumanAddPasswordlessInitCode issues a passwordless init code and returns it to the caller. +// +// The permission is deliberately checked with an empty resource owner. Callers pass their own +// organization, which only scopes the write model's read: for a user of another organization +// that read matches no events, so the write model keeps the caller's organization while the +// pushed event is re-owned to the target's organization by the eventstore. Handing an empty +// resource owner to the check makes it resolve the target's real owner instead, and fail with +// not found when the user does not exist at all. +// +// user.credential.write mirrors the auth annotation of the management RPC calling this +// (AddPasswordlessRegistration), so no role gains or loses access. Self management stays +// allowed for the auth API's AddMyPasswordlessLink. func (c *Commands) HumanAddPasswordlessInitCode(ctx context.Context, userID, resourceOwner string, passwordlessCodeGenerator crypto.Generator) (*domain.PasswordlessInitCode, error) { + if err := c.checkPermissionUpdateUserCredentials(ctx, "", userID); err != nil { + return nil, err + } codeEvent, initCode, code, err := c.humanAddPasswordlessInitCode(ctx, userID, resourceOwner, true, passwordlessCodeGenerator) if err != nil { return nil, err @@ -502,7 +517,16 @@ func (c *Commands) HumanAddPasswordlessInitCode(ctx context.Context, userID, res return writeModelToPasswordlessInitCode(initCode, code), nil } +// HumanSendPasswordlessInitCode issues a passwordless init code and has it mailed to the user. +// +// It checks user.write, the annotation of the management RPC calling this +// (SendPasswordlessRegistration), which a wider set of roles holds than the +// user.credential.write of HumanAddPasswordlessInitCode. See there for why the resource owner +// passed to the check is empty. func (c *Commands) HumanSendPasswordlessInitCode(ctx context.Context, userID, resourceOwner string, passwordlessCodeGenerator crypto.Generator) (*domain.PasswordlessInitCode, error) { + if err := c.checkPermissionUpdateUser(ctx, "", userID, true); err != nil { + return nil, err + } codeEvent, initCode, code, err := c.humanAddPasswordlessInitCode(ctx, userID, resourceOwner, false, passwordlessCodeGenerator) if err != nil { return nil, err @@ -518,6 +542,9 @@ func (c *Commands) HumanSendPasswordlessInitCode(ctx context.Context, userID, re return writeModelToPasswordlessInitCode(initCode, code), nil } +// humanAddPasswordlessInitCode is unauthorized on purpose: importHuman calls it while creating +// the user, whose aggregate has no events yet, so the target's owner cannot be resolved here. +// Authorization belongs to the exported callers. func (c *Commands) humanAddPasswordlessInitCode(ctx context.Context, userID, resourceOwner string, direct bool, passwordlessCodeGenerator crypto.Generator) (eventstore.Command, *HumanPasswordlessInitCodeWriteModel, string, error) { if userID == "" { return nil, nil, "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-GVfg3", "Errors.IDMissing") diff --git a/internal/command/user_human_webauthn_permission_test.go b/internal/command/user_human_webauthn_permission_test.go new file mode 100644 index 000000000..63ad1259b --- /dev/null +++ b/internal/command/user_human_webauthn_permission_test.go @@ -0,0 +1,109 @@ +package command + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" + id_mock "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// victimHumanAddedEvent is a human in org2, targeted by a caller acting in org1. +func victimHumanAddedEvent() *user.HumanAddedEvent { + return user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org2").Aggregate, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + ) +} + +// TestCommands_HumanPasswordlessInitCode_permission guards cross-organization issuance of v1 +// passwordless init codes. The deprecated management RPCs pass the *caller's* organization as +// resource owner, which only scopes the read: the init code write model then matches no events +// for a user of another organization and keeps the caller's organization, while the pushed event +// is re-owned to the target's organization by the eventstore. +// +// Authorizing against that write model would therefore authorize against the attacker's own +// organization. The assertion on the organization handed to checkPermission is what this test +// exists for - asserting only that a denial happened would pass even against the wrong org. +func TestCommands_HumanPasswordlessInitCode_permission(t *testing.T) { + tests := []struct { + name string + // wantPermission mirrors the auth annotation of the RPC calling the command; a change + // here means roles gained or lost access. + wantPermission string + call func(*Commands) error + }{ + { + name: "HumanAddPasswordlessInitCode", + wantPermission: domain.PermissionUserCredentialWrite, + call: func(c *Commands) error { + _, err := c.HumanAddPasswordlessInitCode(context.Background(), "user1", "org1", nil) + return err + }, + }, + { + name: "HumanSendPasswordlessInitCode", + wantPermission: domain.PermissionUserWrite, + call: func(c *Commands) error { + _, err := c.HumanSendPasswordlessInitCode(context.Background(), "user1", "org1", nil) + return err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPermission, gotOrgID, gotResourceID string + c := &Commands{ + checkPermission: func(_ context.Context, permission, orgID, resourceID string) error { + gotPermission, gotOrgID, gotResourceID = permission, orgID, resourceID + return zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") + }, + // Only the resource owner resolution is expected: a denied caller gets no code + // generated and no event pushed. + eventstore: expectEventstore(expectFilter(eventFromEventPusher(victimHumanAddedEvent())))(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t), + } + + err := tt.call(c) + assert.True(t, zerrors.IsPermissionDenied(err), "want permission denied, got %v", err) + assert.Equal(t, tt.wantPermission, gotPermission) + assert.Equal(t, "org2", gotOrgID, "must authorize against the target user's org, not the caller-supplied one") + assert.Equal(t, "user1", gotResourceID) + }) + } + + // internal/api/grpc/auth/passwordless.go passes ctxData.UserID, so self management has to + // short-circuit before any permission lookup. Filtering is failed on purpose to end the call + // right after the check, which spares this test mocking the whole push. + t.Run("self management needs no permission", func(t *testing.T) { + ctx := authz.SetCtxData(context.Background(), authz.CtxData{UserID: "user1"}) + filterErr := zerrors.ThrowInternal(nil, "TEST-Ei0oh", "filter failed") + checked := false + c := &Commands{ + checkPermission: func(context.Context, string, string, string) error { + checked = true + return zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") + }, + eventstore: expectEventstore(expectFilterError(filterErr))(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "code1"), + } + + _, err := c.HumanAddPasswordlessInitCode(ctx, "user1", "org1", nil) + assert.False(t, checked, "self management must not require a permission") + assert.True(t, zerrors.IsInternal(err), "want the call to proceed past the permission check, got %v", err) + }) +} diff --git a/internal/command/user_v2_passkey.go b/internal/command/user_v2_passkey.go index a2d59f7a3..6d9e74b10 100644 --- a/internal/command/user_v2_passkey.go +++ b/internal/command/user_v2_passkey.go @@ -128,19 +128,31 @@ func (c *Commands) AddUserPasskeyCodeReturn(ctx context.Context, userID, resourc } func (c *Commands) addUserPasskeyCode(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm, urlTmpl string, returnCode bool) (*domain.PasskeyCodeDetails, error) { + // The code ID only serves to match events of an already existing code, of which there are + // none for a code that is about to be created. It is therefore generated after the + // permission check, so an unauthorized caller triggers neither ID generation nor the + // secret generator lookup behind newPasskeyCode. + wm := NewHumanPasswordlessInitCodeWriteModel(userID, "", resourceOwner) + err := c.eventstore.FilterToQueryReducer(ctx, wm) + if err != nil { + return nil, err + } + // Authorize against the target user's actual resource owner (resolved from the write + // model), not a caller-supplied org. The API auth interceptor only verifies the + // permission in the request-header org, so without this check an ORG_OWNER of one org + // could mint a passkey enrollment code for a user in another org. + if err := c.checkPermissionUpdateUserPasskey(ctx, wm.ResourceOwner, userID); err != nil { + return nil, err + } codeID, err := c.idGenerator.Next() if err != nil { return nil, err } + wm.CodeID = codeID code, err := c.newPasskeyCode(ctx, c.eventstore.Filter, alg) if err != nil { return nil, err } - wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner) - err = c.eventstore.FilterToQueryReducer(ctx, wm) - if err != nil { - return nil, err - } agg := UserAggregateFromWriteModel(&wm.WriteModel) if urlTmpl == "" { diff --git a/internal/command/user_v2_passkey_permission_test.go b/internal/command/user_v2_passkey_permission_test.go new file mode 100644 index 000000000..f0e439941 --- /dev/null +++ b/internal/command/user_v2_passkey_permission_test.go @@ -0,0 +1,94 @@ +package command + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" + id_mock "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +// TestCommands_addUserPasskeyCode_permission guards the cross-organization passkey enrollment +// code issuance. The v2 gRPC handlers pass an empty resourceOwner and the RPC's auth annotation +// carries no org_field, so the API interceptor only verifies the caller's permission in the +// request-header org. The authorization therefore has to happen here, against the target user's +// actual resource owner resolved from the write model - not against anything the caller supplied. +func TestCommands_addUserPasskeyCode_permission(t *testing.T) { + alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t)) + + // The victim lives in org2. The caller supplies no resource owner at all, exactly like + // internal/api/grpc/user/v2/passkey.go does. + victimAgg := &user.NewAggregate("user1", "org2").Aggregate + victimAdded := eventFromEventPusher(user.NewHumanAddedEvent(context.Background(), + victimAgg, + "username", + "firstname", + "lastname", + "nickname", + "displayname", + language.German, + domain.GenderUnspecified, + "email@test.ch", + true, + )) + + const urlTmpl = "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}" + + tests := []struct { + name string + call func(*Commands) error + }{ + { + name: "AddUserPasskeyCode", + call: func(c *Commands) error { + _, err := c.AddUserPasskeyCode(context.Background(), "user1", "", alg) + return err + }, + }, + { + name: "AddUserPasskeyCodeURLTemplate", + call: func(c *Commands) error { + _, err := c.AddUserPasskeyCodeURLTemplate(context.Background(), "user1", "", alg, urlTmpl) + return err + }, + }, + { + name: "AddUserPasskeyCodeReturn", + call: func(c *Commands) error { + _, err := c.AddUserPasskeyCodeReturn(context.Background(), "user1", "", alg) + return err + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPermission, gotOrgID, gotResourceID string + c := &Commands{ + checkPermission: func(_ context.Context, permission, orgID, resourceID string) error { + gotPermission, gotOrgID, gotResourceID = permission, orgID, resourceID + return zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied") + }, + // No push is expected: an unauthorized caller must not get a code issued. + // Neither ID generation nor code generation runs before the check, so both + // mocks are declared without any expected call. + eventstore: expectEventstore(expectFilter(victimAdded))(t), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t), + newEncryptedCode: newEncryptedCodeNoCall(t), + loginPaths: expectLoginPathsNoCall(t), + } + + err := tt.call(c) + assert.True(t, zerrors.IsPermissionDenied(err), "want permission denied, got %v", err) + assert.Equal(t, domain.PermissionUserPasskeyWrite, gotPermission) + assert.Equal(t, "org2", gotOrgID, "must authorize against the target user's org, not the caller-supplied one") + assert.Equal(t, "user1", gotResourceID) + }) + } +} diff --git a/internal/command/user_v2_passkey_test.go b/internal/command/user_v2_passkey_test.go index 13182cd13..c71e7626f 100644 --- a/internal/command/user_v2_passkey_test.go +++ b/internal/command/user_v2_passkey_test.go @@ -542,7 +542,7 @@ func TestCommands_AddUserPasskeyCode(t *testing.T) { name: "id generator error", fields: fields{ newCode: mockEncryptedCode("passkey1", time.Hour), - eventstore: expectEventstore(), + eventstore: expectEventstore(expectFilter()), // init code write model idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe), loginPaths: expectLoginPathsNoCall, }, @@ -640,6 +640,7 @@ func TestCommands_AddUserPasskeyCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ + checkPermission: newMockPermissionCheckAllowed(), newEncryptedCode: tt.fields.newCode, eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -690,7 +691,7 @@ func TestCommands_AddUserPasskeyCodeURLTemplate(t *testing.T) { name: "id generator error", fields: fields{ newCode: newEncryptedCode, - eventstore: expectEventstore(), + eventstore: expectEventstore(expectFilter()), // init code write model idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe), }, args: args{ @@ -749,6 +750,7 @@ func TestCommands_AddUserPasskeyCodeURLTemplate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ + checkPermission: newMockPermissionCheckAllowed(), newEncryptedCode: tt.fields.newCode, eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -784,7 +786,7 @@ func TestCommands_AddUserPasskeyCodeReturn(t *testing.T) { name: "id generator error", fields: fields{ newCode: newEncryptedCode, - eventstore: expectEventstore(), + eventstore: expectEventstore(expectFilter()), // init code write model idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe), loginPaths: expectLoginPathsNoCall, }, @@ -890,6 +892,7 @@ func TestCommands_AddUserPasskeyCodeReturn(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ + checkPermission: newMockPermissionCheckAllowed(), newEncryptedCode: tt.fields.newCode, eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, @@ -931,7 +934,7 @@ func TestCommands_addUserPasskeyCode(t *testing.T) { name: "id generator error", fields: fields{ newCode: newEncryptedCode, - eventstore: expectEventstore(), + eventstore: expectEventstore(expectFilter()), // init code write model idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe), loginPaths: expectLoginPathsNoCall, }, @@ -944,8 +947,11 @@ func TestCommands_addUserPasskeyCode(t *testing.T) { { name: "crypto error", fields: fields{ - newCode: newEncryptedCode, - eventstore: expectEventstore(expectFilterError(io.ErrClosedPipe)), + newCode: newEncryptedCode, + eventstore: expectEventstore( + expectFilter(), // init code write model + expectFilterError(io.ErrClosedPipe), + ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"), loginPaths: expectLoginPathsNoCall, }, @@ -1107,6 +1113,7 @@ func TestCommands_addUserPasskeyCode(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { c := &Commands{ + checkPermission: newMockPermissionCheckAllowed(), newEncryptedCode: tt.fields.newCode, eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, diff --git a/internal/domain/permission.go b/internal/domain/permission.go index f4f1b7ed5..ce08d52ef 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -32,6 +32,7 @@ const ( PermissionUserDelete = "user.delete" PermissionUserDeleteSelf = "user.self.delete" PermissionUserCredentialWrite = "user.credential.write" + PermissionUserPasskeyWrite = "user.passkey.write" PermissionSessionWrite = "session.write" PermissionSessionRead = "session.read" PermissionSessionLink = "session.link"