mirror of
https://github.com/zitadel/zitadel.git
synced 2026-11-03 20:56:50 -06:00
fix(command): enforce permission check when issuing passkey enrollment codes
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 8a61aa4f36)
This commit is contained in:
1 parent
c3a4c9f0cd
commit
5e4e63ab0f
10 files changed
+405
-11
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -71,6 +71,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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user