diff --git a/.mockery.yaml b/.mockery.yaml index 5d589f2..680eb0b 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -127,3 +127,10 @@ packages: pkgname: authkitmocks dir: mocks/authkit filename: identity_linker.go + IdentityProvisioner: + config: + template: testify + structname: IdentityProvisioner + pkgname: authkitmocks + dir: mocks/authkit + filename: identity_provisioner.go diff --git a/mocks/authkit/identity_provisioner.go b/mocks/authkit/identity_provisioner.go new file mode 100644 index 0000000..28dec6d --- /dev/null +++ b/mocks/authkit/identity_provisioner.go @@ -0,0 +1,105 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package authkitmocks + +import ( + "context" + + "github.com/meigma/authkit" + mock "github.com/stretchr/testify/mock" +) + +// NewIdentityProvisioner creates a new instance of IdentityProvisioner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewIdentityProvisioner(t interface { + mock.TestingT + Cleanup(func()) +}) *IdentityProvisioner { + mock := &IdentityProvisioner{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// IdentityProvisioner is an autogenerated mock type for the IdentityProvisioner type +type IdentityProvisioner struct { + mock.Mock +} + +type IdentityProvisioner_Expecter struct { + mock *mock.Mock +} + +func (_m *IdentityProvisioner) EXPECT() *IdentityProvisioner_Expecter { + return &IdentityProvisioner_Expecter{mock: &_m.Mock} +} + +// ProvisionIdentity provides a mock function for the type IdentityProvisioner +func (_mock *IdentityProvisioner) ProvisionIdentity(ctx context.Context, req authkit.ProvisionIdentityRequest) (authkit.ProvisionIdentityResult, error) { + ret := _mock.Called(ctx, req) + + if len(ret) == 0 { + panic("no return value specified for ProvisionIdentity") + } + + var r0 authkit.ProvisionIdentityResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, authkit.ProvisionIdentityRequest) (authkit.ProvisionIdentityResult, error)); ok { + return returnFunc(ctx, req) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, authkit.ProvisionIdentityRequest) authkit.ProvisionIdentityResult); ok { + r0 = returnFunc(ctx, req) + } else { + r0 = ret.Get(0).(authkit.ProvisionIdentityResult) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, authkit.ProvisionIdentityRequest) error); ok { + r1 = returnFunc(ctx, req) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// IdentityProvisioner_ProvisionIdentity_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ProvisionIdentity' +type IdentityProvisioner_ProvisionIdentity_Call struct { + *mock.Call +} + +// ProvisionIdentity is a helper method to define mock.On call +// - ctx context.Context +// - req authkit.ProvisionIdentityRequest +func (_e *IdentityProvisioner_Expecter) ProvisionIdentity(ctx interface{}, req interface{}) *IdentityProvisioner_ProvisionIdentity_Call { + return &IdentityProvisioner_ProvisionIdentity_Call{Call: _e.mock.On("ProvisionIdentity", ctx, req)} +} + +func (_c *IdentityProvisioner_ProvisionIdentity_Call) Run(run func(ctx context.Context, req authkit.ProvisionIdentityRequest)) *IdentityProvisioner_ProvisionIdentity_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 authkit.ProvisionIdentityRequest + if args[1] != nil { + arg1 = args[1].(authkit.ProvisionIdentityRequest) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *IdentityProvisioner_ProvisionIdentity_Call) Return(provisionIdentityResult authkit.ProvisionIdentityResult, err error) *IdentityProvisioner_ProvisionIdentity_Call { + _c.Call.Return(provisionIdentityResult, err) + return _c +} + +func (_c *IdentityProvisioner_ProvisionIdentity_Call) RunAndReturn(run func(ctx context.Context, req authkit.ProvisionIdentityRequest) (authkit.ProvisionIdentityResult, error)) *IdentityProvisioner_ProvisionIdentity_Call { + _c.Call.Return(run) + return _c +} diff --git a/onboarding/attach.go b/onboarding/attach.go new file mode 100644 index 0000000..ec5c371 --- /dev/null +++ b/onboarding/attach.go @@ -0,0 +1,79 @@ +package onboarding + +import ( + "context" + "errors" + "fmt" + + "github.com/meigma/authkit" +) + +// Attacher links a verified external identity to an existing internal principal. +type Attacher struct { + finder authkit.PrincipalFinder + linker authkit.IdentityLinker +} + +// NewAttacher constructs an Attacher from the ports it requires. +func NewAttacher(finder authkit.PrincipalFinder, linker authkit.IdentityLinker) *Attacher { + return &Attacher{ + finder: finder, + linker: linker, + } +} + +// AttachIdentity finds the named principal then links the verified identity to it. +// +// Both steps must succeed: a missing principal short-circuits before any link +// is recorded, so a failed attach never leaves a dangling external identity. +func (a *Attacher) AttachIdentity( + ctx context.Context, + req AttachIdentityRequest, +) (AttachIdentityResult, error) { + // Validate external input before touching any port so a malformed identity + // never reaches the store and never produces a partial side effect. + if err := validateIdentity(req.Identity); err != nil { + return AttachIdentityResult{}, err + } + if req.PrincipalID == "" { + return AttachIdentityResult{}, errors.New("onboarding: principal ID is required") + } + + principal, err := a.finder.FindPrincipal(ctx, req.PrincipalID) + if err != nil { + return AttachIdentityResult{}, fmt.Errorf("onboarding: find principal: %w", err) + } + + link, err := a.linker.LinkIdentity(ctx, authkit.LinkIdentityRequest{ + Provider: req.Identity.Provider, + Subject: req.Identity.Subject, + PrincipalID: req.PrincipalID, + }) + if err != nil { + return AttachIdentityResult{}, fmt.Errorf("onboarding: link identity: %w", err) + } + + return AttachIdentityResult{ + Principal: principal, + Link: link, + }, nil +} + +// AttachIdentityRequest describes a request to attach a verified identity to +// an existing principal. +type AttachIdentityRequest struct { + // Identity is the verified external identity to attach. + Identity authkit.Identity + + // PrincipalID identifies the existing principal receiving the identity link. + PrincipalID string +} + +// AttachIdentityResult describes the outcome of attaching an identity to a principal. +type AttachIdentityResult struct { + // Principal is the existing principal that received the identity link. + Principal authkit.Principal + + // Link is the external identity link recorded for Principal. + Link authkit.ExternalIdentity +} diff --git a/onboarding/attach_test.go b/onboarding/attach_test.go new file mode 100644 index 0000000..ffccf8a --- /dev/null +++ b/onboarding/attach_test.go @@ -0,0 +1,128 @@ +package onboarding_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + authkitmocks "github.com/meigma/authkit/mocks/authkit" + "github.com/meigma/authkit/onboarding" +) + +func TestAttachIdentityValidatesRequest(t *testing.T) { + tests := []struct { + name string + req onboarding.AttachIdentityRequest + want string + }{ + { + name: "missing identity provider", + req: onboarding.AttachIdentityRequest{ + Identity: authkit.Identity{ + Subject: testSubject, + }, + PrincipalID: testPrincipalID, + }, + want: "onboarding: identity provider is required", + }, + { + name: "missing identity subject", + req: onboarding.AttachIdentityRequest{ + Identity: authkit.Identity{ + Provider: testProvider, + }, + PrincipalID: testPrincipalID, + }, + want: "onboarding: identity subject is required", + }, + { + name: "missing principal ID", + req: onboarding.AttachIdentityRequest{ + Identity: verifiedIdentity(), + }, + want: "onboarding: principal ID is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mocks pass through unused — a stray port call would panic the test. + finder := authkitmocks.NewPrincipalFinder(t) + linker := authkitmocks.NewIdentityLinker(t) + attacher := onboarding.NewAttacher(finder, linker) + + result, err := attacher.AttachIdentity(context.Background(), tt.req) + + require.EqualError(t, err, tt.want) + assert.Empty(t, result) + }) + } +} + +func TestAttachIdentityFindsPrincipalThenLinksIdentity(t *testing.T) { + finder := authkitmocks.NewPrincipalFinder(t) + finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID).Return(testPrincipal(), nil) + linker := authkitmocks.NewIdentityLinker(t) + linker.EXPECT().LinkIdentity(mock.Anything, authkit.LinkIdentityRequest{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: testPrincipalID, + }).Return(testLink(), nil) + attacher := onboarding.NewAttacher(finder, linker) + + result, err := attacher.AttachIdentity(context.Background(), attachRequest()) + + require.NoError(t, err) + assert.Equal(t, testPrincipal(), result.Principal) + assert.Equal(t, testLink(), result.Link) +} + +func TestAttachIdentityPropagatesPrincipalNotFound(t *testing.T) { + finder := authkitmocks.NewPrincipalFinder(t) + finder.EXPECT(). + FindPrincipal(mock.Anything, testPrincipalID). + Return(authkit.Principal{}, authkit.ErrPrincipalNotFound) + // Linker stays unconfigured: a stray LinkIdentity call panics the test. + linker := authkitmocks.NewIdentityLinker(t) + attacher := onboarding.NewAttacher(finder, linker) + + result, err := attacher.AttachIdentity(context.Background(), attachRequest()) + + require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) + assert.Empty(t, result) +} + +func TestAttachIdentityPropagatesLinkConflict(t *testing.T) { + finder := authkitmocks.NewPrincipalFinder(t) + finder.EXPECT().FindPrincipal(mock.Anything, testPrincipalID).Return(testPrincipal(), nil) + linker := authkitmocks.NewIdentityLinker(t) + linker.EXPECT(). + LinkIdentity(mock.Anything, mock.Anything). + Return(authkit.ExternalIdentity{}, errLinkConflict) + attacher := onboarding.NewAttacher(finder, linker) + + result, err := attacher.AttachIdentity(context.Background(), attachRequest()) + + require.ErrorIs(t, err, errLinkConflict) + assert.Empty(t, result) +} + +func TestAttacherPropagatesContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + finder := authkitmocks.NewPrincipalFinder(t) + finder.EXPECT(). + FindPrincipal(mock.Anything, testPrincipalID). + Return(authkit.Principal{}, context.Canceled) + linker := authkitmocks.NewIdentityLinker(t) + attacher := onboarding.NewAttacher(finder, linker) + + _, err := attacher.AttachIdentity(ctx, attachRequest()) + + require.ErrorIs(t, err, context.Canceled) +} diff --git a/onboarding/doc.go b/onboarding/doc.go index 76ce9cc..ad38f5e 100644 --- a/onboarding/doc.go +++ b/onboarding/doc.go @@ -1,7 +1,15 @@ -// Package onboarding coordinates explicit identity attachment and principal provisioning. +// Package onboarding binds verified external identities to internal principals. // -// Credential method packages authenticate or verify method-specific material and -// return authkit.Identity values. Package onboarding helps applications bind -// those verified identities to principals without adding side effects to normal -// request authentication. +// Credential proof packages authenticate or verify provider-specific material +// and return authkit.Identity values. Onboarding then offers two alternative +// workflows for turning those identities into principals: +// +// - Attacher links a verified identity to an existing principal. Use it +// when a signed-in user adds another credential to their account. +// - Provisioner creates a new principal for a verified identity. Use it +// as the explicit entry point when an app admits a new user on first proof. +// +// Both flows validate the identity before any side effects. They are +// alternatives, not collaborators, so each type takes only the ports it +// needs. package onboarding diff --git a/onboarding/helpers_test.go b/onboarding/helpers_test.go new file mode 100644 index 0000000..e66d6c0 --- /dev/null +++ b/onboarding/helpers_test.go @@ -0,0 +1,60 @@ +package onboarding_test + +import ( + "errors" + + "github.com/meigma/authkit" + "github.com/meigma/authkit/onboarding" +) + +const ( + testPrincipalID = "principal_1" + testProvider = "passkey" + testSubject = "user-handle-1" + testPrincipalName = "Test User" + testRoleID = "reader" +) + +var errLinkConflict = errors.New("identity already linked to another principal") + +func verifiedIdentity() authkit.Identity { + return authkit.Identity{ + Provider: testProvider, + Subject: testSubject, + CredentialID: "credential_1", + } +} + +func testPrincipal() authkit.Principal { + return authkit.Principal{ + ID: testPrincipalID, + Kind: authkit.PrincipalKindUser, + DisplayName: testPrincipalName, + } +} + +func testLink() authkit.ExternalIdentity { + return authkit.ExternalIdentity{ + Provider: testProvider, + Subject: testSubject, + PrincipalID: testPrincipalID, + } +} + +func attachRequest() onboarding.AttachIdentityRequest { + return onboarding.AttachIdentityRequest{ + Identity: verifiedIdentity(), + PrincipalID: testPrincipalID, + } +} + +func provisionRequest() authkit.ProvisionIdentityRequest { + return authkit.ProvisionIdentityRequest{ + Identity: verifiedIdentity(), + Principal: authkit.CreatePrincipalRequest{ + Kind: authkit.PrincipalKindUser, + DisplayName: testPrincipalName, + }, + InitialRoleIDs: []string{testRoleID}, + } +} diff --git a/onboarding/provision.go b/onboarding/provision.go new file mode 100644 index 0000000..4169aa3 --- /dev/null +++ b/onboarding/provision.go @@ -0,0 +1,45 @@ +package onboarding + +import ( + "context" + "fmt" + + "github.com/meigma/authkit" +) + +// Provisioner creates a new internal principal for a verified external identity. +// +// Provisioner is the explicit/unconditional entry point: callers have already +// decided the identity should become a new principal. Compare with +// provisioning.Resolver, which auto-provisions only after a CEL-gated +// resolver miss. +type Provisioner struct { + provisioner authkit.IdentityProvisioner +} + +// NewProvisioner constructs a Provisioner from the port it requires. +func NewProvisioner(provisioner authkit.IdentityProvisioner) *Provisioner { + return &Provisioner{ + provisioner: provisioner, + } +} + +// ProvisionIdentity validates the identity then delegates atomic create-and-link +// to the configured IdentityProvisioner. +func (p *Provisioner) ProvisionIdentity( + ctx context.Context, + req authkit.ProvisionIdentityRequest, +) (authkit.ProvisionIdentityResult, error) { + // Validate external input before touching the port so malformed identities + // never reach the store. + if err := validateIdentity(req.Identity); err != nil { + return authkit.ProvisionIdentityResult{}, err + } + + result, err := p.provisioner.ProvisionIdentity(ctx, req) + if err != nil { + return authkit.ProvisionIdentityResult{}, fmt.Errorf("onboarding: provision identity: %w", err) + } + + return result, nil +} diff --git a/onboarding/provision_test.go b/onboarding/provision_test.go new file mode 100644 index 0000000..cc71ddd --- /dev/null +++ b/onboarding/provision_test.go @@ -0,0 +1,86 @@ +package onboarding_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/meigma/authkit" + authkitmocks "github.com/meigma/authkit/mocks/authkit" + "github.com/meigma/authkit/onboarding" +) + +func TestProvisionIdentityValidatesRequest(t *testing.T) { + tests := []struct { + name string + req authkit.ProvisionIdentityRequest + want string + }{ + { + name: "missing identity provider", + req: authkit.ProvisionIdentityRequest{ + Identity: authkit.Identity{ + Subject: testSubject, + }, + }, + want: "onboarding: identity provider is required", + }, + { + name: "missing identity subject", + req: authkit.ProvisionIdentityRequest{ + Identity: authkit.Identity{ + Provider: testProvider, + }, + }, + want: "onboarding: identity subject is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Mock stays unused — a stray port call would panic the test. + fake := authkitmocks.NewIdentityProvisioner(t) + provisioner := onboarding.NewProvisioner(fake) + + result, err := provisioner.ProvisionIdentity(context.Background(), tt.req) + + require.EqualError(t, err, tt.want) + assert.Empty(t, result) + }) + } +} + +func TestProvisionIdentityDelegatesToIdentityProvisioner(t *testing.T) { + want := authkit.ProvisionIdentityResult{ + Principal: testPrincipal(), + Link: testLink(), + Created: true, + } + req := provisionRequest() + fake := authkitmocks.NewIdentityProvisioner(t) + fake.EXPECT().ProvisionIdentity(mock.Anything, req).Return(want, nil) + provisioner := onboarding.NewProvisioner(fake) + + result, err := provisioner.ProvisionIdentity(context.Background(), req) + + require.NoError(t, err) + assert.Equal(t, want, result) +} + +func TestProvisionerPropagatesContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + fake := authkitmocks.NewIdentityProvisioner(t) + fake.EXPECT(). + ProvisionIdentity(mock.Anything, mock.Anything). + Return(authkit.ProvisionIdentityResult{}, context.Canceled) + provisioner := onboarding.NewProvisioner(fake) + + _, err := provisioner.ProvisionIdentity(ctx, provisionRequest()) + + require.ErrorIs(t, err, context.Canceled) +} diff --git a/onboarding/service.go b/onboarding/service.go deleted file mode 100644 index d4cbd03..0000000 --- a/onboarding/service.go +++ /dev/null @@ -1,102 +0,0 @@ -package onboarding - -import ( - "context" - "errors" - "fmt" - - "github.com/meigma/authkit" -) - -// Service composes explicit identity attachment and principal provisioning flows. -type Service struct { - principalFinder authkit.PrincipalFinder - identityLinker authkit.IdentityLinker - identityProvisioner authkit.IdentityProvisioner -} - -// NewService constructs an onboarding service from opts. -func NewService(opts Options) *Service { - return &Service{ - principalFinder: opts.PrincipalFinder, - identityLinker: opts.IdentityLinker, - identityProvisioner: opts.IdentityProvisioner, - } -} - -// AttachIdentity links a verified identity to an existing principal. -func (s *Service) AttachIdentity( - ctx context.Context, - req AttachIdentityRequest, -) (AttachIdentityResult, error) { - if s.principalFinder == nil { - return AttachIdentityResult{}, errors.New("onboarding: principal finder is required") - } - if s.identityLinker == nil { - return AttachIdentityResult{}, errors.New("onboarding: identity linker is required") - } - if err := validateIdentity(req.Identity); err != nil { - return AttachIdentityResult{}, err - } - if req.PrincipalID == "" { - return AttachIdentityResult{}, errors.New("onboarding: principal ID is required") - } - - principal, err := s.principalFinder.FindPrincipal(ctx, req.PrincipalID) - if err != nil { - return AttachIdentityResult{}, fmt.Errorf("onboarding: find principal: %w", err) - } - - link, err := s.identityLinker.LinkIdentity(ctx, authkit.LinkIdentityRequest{ - Provider: req.Identity.Provider, - Subject: req.Identity.Subject, - PrincipalID: req.PrincipalID, - }) - if err != nil { - return AttachIdentityResult{}, fmt.Errorf("onboarding: link identity: %w", err) - } - - return AttachIdentityResult{ - Principal: principal, - Link: link, - }, nil -} - -// ProvisionPrincipal creates or resolves a principal for a verified identity. -func (s *Service) ProvisionPrincipal( - ctx context.Context, - req ProvisionPrincipalRequest, -) (ProvisionPrincipalResult, error) { - if s.identityProvisioner == nil { - return ProvisionPrincipalResult{}, errors.New("onboarding: identity provisioner is required") - } - if err := validateIdentity(req.Identity); err != nil { - return ProvisionPrincipalResult{}, err - } - - result, err := s.identityProvisioner.ProvisionIdentity(ctx, authkit.ProvisionIdentityRequest{ - Identity: req.Identity, - Principal: req.Principal, - InitialRoleIDs: req.InitialRoleIDs, - }) - if err != nil { - return ProvisionPrincipalResult{}, fmt.Errorf("onboarding: provision principal: %w", err) - } - - return ProvisionPrincipalResult{ - Principal: result.Principal, - Link: result.Link, - Created: result.Created, - }, nil -} - -func validateIdentity(identity authkit.Identity) error { - if identity.Provider == "" { - return errors.New("onboarding: identity provider is required") - } - if identity.Subject == "" { - return errors.New("onboarding: identity subject is required") - } - - return nil -} diff --git a/onboarding/service_test.go b/onboarding/service_test.go deleted file mode 100644 index 69da072..0000000 --- a/onboarding/service_test.go +++ /dev/null @@ -1,428 +0,0 @@ -package onboarding_test - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/meigma/authkit" - "github.com/meigma/authkit/onboarding" -) - -const ( - testPrincipalID = "principal_1" - testProvider = "passkey" - testSubject = "user-handle-1" - testPrincipalName = "Test User" - testRoleID = "reader" -) - -var errLinkConflict = errors.New("identity already linked to another principal") - -func TestNewServiceAllowsSparseOptions(t *testing.T) { - tests := []struct { - name string - opts onboarding.Options - }{ - { - name: "no collaborators", - }, - { - name: "attach collaborators", - opts: onboarding.Options{ - PrincipalFinder: newFakePrincipalFinder(), - IdentityLinker: newFakeIdentityLinker(), - }, - }, - { - name: "provision collaborator", - opts: onboarding.Options{ - IdentityProvisioner: newFakeIdentityProvisioner(), - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - service := onboarding.NewService(tt.opts) - - assert.NotNil(t, service) - }) - } -} - -func TestServiceMethodsRequirePorts(t *testing.T) { - tests := []struct { - name string - opts onboarding.Options - run func(*onboarding.Service) error - want string - }{ - { - name: "attach identity missing principal finder", - run: func(service *onboarding.Service) error { - _, err := service.AttachIdentity(context.Background(), attachRequest()) - - return err - }, - want: "onboarding: principal finder is required", - }, - { - name: "attach identity missing identity linker", - opts: onboarding.Options{ - PrincipalFinder: newFakePrincipalFinder(), - }, - run: func(service *onboarding.Service) error { - _, err := service.AttachIdentity(context.Background(), attachRequest()) - - return err - }, - want: "onboarding: identity linker is required", - }, - { - name: "provision principal missing identity provisioner", - run: func(service *onboarding.Service) error { - _, err := service.ProvisionPrincipal(context.Background(), provisionRequest()) - - return err - }, - want: "onboarding: identity provisioner is required", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - service := onboarding.NewService(tt.opts) - - err := tt.run(service) - - require.Error(t, err) - assert.EqualError(t, err, tt.want) - }) - } -} - -func TestAttachIdentityValidatesRequest(t *testing.T) { - service := onboarding.NewService(onboarding.Options{ - PrincipalFinder: newFakePrincipalFinder(), - IdentityLinker: newFakeIdentityLinker(), - }) - - tests := []struct { - name string - req onboarding.AttachIdentityRequest - want string - }{ - { - name: "missing identity provider", - req: onboarding.AttachIdentityRequest{ - Identity: authkit.Identity{ - Subject: testSubject, - }, - PrincipalID: testPrincipalID, - }, - want: "onboarding: identity provider is required", - }, - { - name: "missing identity subject", - req: onboarding.AttachIdentityRequest{ - Identity: authkit.Identity{ - Provider: testProvider, - }, - PrincipalID: testPrincipalID, - }, - want: "onboarding: identity subject is required", - }, - { - name: "missing principal ID", - req: onboarding.AttachIdentityRequest{ - Identity: verifiedIdentity(), - }, - want: "onboarding: principal ID is required", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := service.AttachIdentity(context.Background(), tt.req) - - require.EqualError(t, err, tt.want) - assert.Empty(t, result) - }) - } -} - -func TestAttachIdentityFindsPrincipalThenLinksIdentity(t *testing.T) { - finder := newFakePrincipalFinder() - linker := newFakeIdentityLinker() - service := onboarding.NewService(onboarding.Options{ - PrincipalFinder: finder, - IdentityLinker: linker, - }) - req := attachRequest() - - result, err := service.AttachIdentity(context.Background(), req) - - require.NoError(t, err) - assert.Equal(t, finder.principal, result.Principal) - assert.Equal(t, linker.link, result.Link) - assert.Equal(t, []string{testPrincipalID}, finder.findIDs) - assert.Equal(t, []authkit.LinkIdentityRequest{ - { - Provider: testProvider, - Subject: testSubject, - PrincipalID: testPrincipalID, - }, - }, linker.requests) -} - -func TestAttachIdentityPropagatesPrincipalNotFound(t *testing.T) { - finder := newFakePrincipalFinder() - finder.err = authkit.ErrPrincipalNotFound - linker := newFakeIdentityLinker() - service := onboarding.NewService(onboarding.Options{ - PrincipalFinder: finder, - IdentityLinker: linker, - }) - - result, err := service.AttachIdentity(context.Background(), attachRequest()) - - require.ErrorIs(t, err, authkit.ErrPrincipalNotFound) - assert.Empty(t, result) - assert.Empty(t, linker.requests) -} - -func TestAttachIdentityPropagatesLinkConflict(t *testing.T) { - linker := newFakeIdentityLinker() - linker.err = errLinkConflict - service := onboarding.NewService(onboarding.Options{ - PrincipalFinder: newFakePrincipalFinder(), - IdentityLinker: linker, - }) - - result, err := service.AttachIdentity(context.Background(), attachRequest()) - - require.ErrorIs(t, err, errLinkConflict) - assert.Empty(t, result) -} - -func TestProvisionPrincipalValidatesIdentity(t *testing.T) { - service := onboarding.NewService(onboarding.Options{ - IdentityProvisioner: newFakeIdentityProvisioner(), - }) - - tests := []struct { - name string - req onboarding.ProvisionPrincipalRequest - want string - }{ - { - name: "missing identity provider", - req: onboarding.ProvisionPrincipalRequest{ - Identity: authkit.Identity{ - Subject: testSubject, - }, - }, - want: "onboarding: identity provider is required", - }, - { - name: "missing identity subject", - req: onboarding.ProvisionPrincipalRequest{ - Identity: authkit.Identity{ - Provider: testProvider, - }, - }, - want: "onboarding: identity subject is required", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := service.ProvisionPrincipal(context.Background(), tt.req) - - require.EqualError(t, err, tt.want) - assert.Empty(t, result) - }) - } -} - -func TestProvisionPrincipalDelegatesToIdentityProvisioner(t *testing.T) { - provisioner := newFakeIdentityProvisioner() - service := onboarding.NewService(onboarding.Options{ - IdentityProvisioner: provisioner, - }) - req := provisionRequest() - - result, err := service.ProvisionPrincipal(context.Background(), req) - - require.NoError(t, err) - assert.Equal(t, provisioner.result.Principal, result.Principal) - assert.Equal(t, provisioner.result.Link, result.Link) - assert.True(t, result.Created) - assert.Equal(t, []authkit.ProvisionIdentityRequest{ - { - Identity: req.Identity, - Principal: req.Principal, - InitialRoleIDs: req.InitialRoleIDs, - }, - }, provisioner.requests) -} - -func TestServicePropagatesContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - t.Run("attach identity", func(t *testing.T) { - service := onboarding.NewService(onboarding.Options{ - PrincipalFinder: newFakePrincipalFinder(), - IdentityLinker: newFakeIdentityLinker(), - }) - - _, err := service.AttachIdentity(ctx, attachRequest()) - - require.ErrorIs(t, err, context.Canceled) - }) - - t.Run("provision principal", func(t *testing.T) { - service := onboarding.NewService(onboarding.Options{ - IdentityProvisioner: newFakeIdentityProvisioner(), - }) - - _, err := service.ProvisionPrincipal(ctx, provisionRequest()) - - require.ErrorIs(t, err, context.Canceled) - }) -} - -func verifiedIdentity() authkit.Identity { - return authkit.Identity{ - Provider: testProvider, - Subject: testSubject, - CredentialID: "credential_1", - } -} - -func testPrincipal() authkit.Principal { - return authkit.Principal{ - ID: testPrincipalID, - Kind: authkit.PrincipalKindUser, - DisplayName: testPrincipalName, - } -} - -func testLink() authkit.ExternalIdentity { - return authkit.ExternalIdentity{ - Provider: testProvider, - Subject: testSubject, - PrincipalID: testPrincipalID, - } -} - -func attachRequest() onboarding.AttachIdentityRequest { - return onboarding.AttachIdentityRequest{ - Identity: verifiedIdentity(), - PrincipalID: testPrincipalID, - } -} - -func provisionRequest() onboarding.ProvisionPrincipalRequest { - return onboarding.ProvisionPrincipalRequest{ - Identity: verifiedIdentity(), - Principal: authkit.CreatePrincipalRequest{ - Kind: authkit.PrincipalKindUser, - DisplayName: testPrincipalName, - }, - InitialRoleIDs: []string{testRoleID}, - } -} - -type fakePrincipalFinder struct { - findIDs []string - principal authkit.Principal - err error -} - -func newFakePrincipalFinder() *fakePrincipalFinder { - return &fakePrincipalFinder{ - principal: testPrincipal(), - } -} - -func (f *fakePrincipalFinder) FindPrincipal( - ctx context.Context, - id string, -) (authkit.Principal, error) { - if err := ctx.Err(); err != nil { - return authkit.Principal{}, err - } - - f.findIDs = append(f.findIDs, id) - if f.err != nil { - return authkit.Principal{}, f.err - } - - return f.principal, nil -} - -type fakeIdentityLinker struct { - requests []authkit.LinkIdentityRequest - link authkit.ExternalIdentity - err error -} - -func newFakeIdentityLinker() *fakeIdentityLinker { - return &fakeIdentityLinker{ - link: testLink(), - } -} - -func (f *fakeIdentityLinker) LinkIdentity( - ctx context.Context, - req authkit.LinkIdentityRequest, -) (authkit.ExternalIdentity, error) { - if err := ctx.Err(); err != nil { - return authkit.ExternalIdentity{}, err - } - - f.requests = append(f.requests, req) - if f.err != nil { - return authkit.ExternalIdentity{}, f.err - } - - return f.link, nil -} - -type fakeIdentityProvisioner struct { - requests []authkit.ProvisionIdentityRequest - result authkit.ProvisionIdentityResult - err error -} - -func newFakeIdentityProvisioner() *fakeIdentityProvisioner { - return &fakeIdentityProvisioner{ - result: authkit.ProvisionIdentityResult{ - Principal: testPrincipal(), - Link: testLink(), - Created: true, - }, - } -} - -func (f *fakeIdentityProvisioner) ProvisionIdentity( - ctx context.Context, - req authkit.ProvisionIdentityRequest, -) (authkit.ProvisionIdentityResult, error) { - if err := ctx.Err(); err != nil { - return authkit.ProvisionIdentityResult{}, err - } - - f.requests = append(f.requests, req) - if f.err != nil { - return authkit.ProvisionIdentityResult{}, f.err - } - - return f.result, nil -} diff --git a/onboarding/types.go b/onboarding/types.go deleted file mode 100644 index 824de45..0000000 --- a/onboarding/types.go +++ /dev/null @@ -1,57 +0,0 @@ -package onboarding - -import "github.com/meigma/authkit" - -// Options configures a Service. -type Options struct { - // PrincipalFinder finds existing principals before identity attachment. - PrincipalFinder authkit.PrincipalFinder - - // IdentityLinker links verified identities to existing principals. - IdentityLinker authkit.IdentityLinker - - // IdentityProvisioner creates and links principals for verified identities. - IdentityProvisioner authkit.IdentityProvisioner -} - -// AttachIdentityRequest describes a request to attach a verified identity to an existing principal. -type AttachIdentityRequest struct { - // Identity is the verified external identity to attach. - Identity authkit.Identity - - // PrincipalID identifies the existing principal receiving the identity link. - PrincipalID string -} - -// AttachIdentityResult describes the outcome of attaching an identity to a principal. -type AttachIdentityResult struct { - // Principal is the existing principal that received the identity link. - Principal authkit.Principal - - // Link is the external identity link created or confirmed for Principal. - Link authkit.ExternalIdentity -} - -// ProvisionPrincipalRequest describes a request to provision a principal for a verified identity. -type ProvisionPrincipalRequest struct { - // Identity is the verified external identity to provision. - Identity authkit.Identity - - // Principal describes the internal principal to create when Identity is not linked. - Principal authkit.CreatePrincipalRequest - - // InitialRoleIDs are local roles assigned only when a new principal is created. - InitialRoleIDs []string -} - -// ProvisionPrincipalResult describes the outcome of provisioning a principal for an identity. -type ProvisionPrincipalResult struct { - // Principal is the internal principal linked to the identity. - Principal authkit.Principal - - // Link is the external identity link for Principal. - Link authkit.ExternalIdentity - - // Created reports whether this call created a new principal and identity link. - Created bool -} diff --git a/onboarding/validation.go b/onboarding/validation.go new file mode 100644 index 0000000..72dbdd4 --- /dev/null +++ b/onboarding/validation.go @@ -0,0 +1,20 @@ +package onboarding + +import ( + "errors" + + "github.com/meigma/authkit" +) + +// validateIdentity rejects identities missing the provider/subject pair that +// every onboarding workflow needs to address the external account. +func validateIdentity(identity authkit.Identity) error { + if identity.Provider == "" { + return errors.New("onboarding: identity provider is required") + } + if identity.Subject == "" { + return errors.New("onboarding: identity subject is required") + } + + return nil +}