diff --git a/cmd/serve.go b/cmd/serve.go index 38f0bf686f..7d2f26aec5 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -349,8 +349,6 @@ func buildAPIDependencies( if err := cfg.App.Consent.Validate(); err != nil { return api.Deps{}, err } - consentService := consent.NewService(cfg.App.Consent) - logConsentDocuments(logger, consentService.Documents()) var tokenKeySet jwk.Set if len(cfg.App.Authentication.Token.RSAPath) > 0 { @@ -399,6 +397,10 @@ func buildAPIDependencies( auditRecordRepository := postgres.NewAuditRecordRepository(dbc) + consentService := consent.NewService(logger, cfg.App.Consent, + postgres.NewUserConsentRepository(dbc), auditRecordRepository) + logConsentDocuments(logger, consentService.Documents()) + roleRepository := postgres.NewRoleRepository(dbc) policyPGRepository := postgres.NewPolicyRepository(dbc) userRepository := postgres.NewUserRepository(dbc) @@ -450,7 +452,8 @@ func buildAPIDependencies( userService := user.NewService(userRepository, relationService, sessionService, auditRecordRepository) patValidator := userpat.NewValidator(logger, userPATRepo, cfg.App.PAT) authnService := authenticate.NewService(logger, cfg.App.Authentication, - postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator) + postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator, + consentService, dbc) groupService := group.NewService(groupRepository, relationService, authnService, policyService) organizationService := organization.NewService(organizationRepository, relationService, userService, authnService, policyService, preferenceService, roleService) diff --git a/core/authenticate/mocks/consent_service.go b/core/authenticate/mocks/consent_service.go new file mode 100644 index 0000000000..52fe63f60c --- /dev/null +++ b/core/authenticate/mocks/consent_service.go @@ -0,0 +1,189 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + sqlx "github.com/jmoiron/sqlx" + + consent "github.com/raystack/frontier/core/consent" + mock "github.com/stretchr/testify/mock" +) + +// ConsentService is an autogenerated mock type for the ConsentService type +type ConsentService struct { + mock.Mock +} + +type ConsentService_Expecter struct { + mock *mock.Mock +} + +func (_m *ConsentService) EXPECT() *ConsentService_Expecter { + return &ConsentService_Expecter{mock: &_m.Mock} +} + +// Grant provides a mock function with given fields: ctx, tx, req +func (_m *ConsentService) Grant(ctx context.Context, tx *sqlx.Tx, req consent.GrantRequest) (consent.Consent, error) { + ret := _m.Called(ctx, tx, req) + + if len(ret) == 0 { + panic("no return value specified for Grant") + } + + var r0 consent.Consent + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *sqlx.Tx, consent.GrantRequest) (consent.Consent, error)); ok { + return rf(ctx, tx, req) + } + if rf, ok := ret.Get(0).(func(context.Context, *sqlx.Tx, consent.GrantRequest) consent.Consent); ok { + r0 = rf(ctx, tx, req) + } else { + r0 = ret.Get(0).(consent.Consent) + } + + if rf, ok := ret.Get(1).(func(context.Context, *sqlx.Tx, consent.GrantRequest) error); ok { + r1 = rf(ctx, tx, req) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ConsentService_Grant_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Grant' +type ConsentService_Grant_Call struct { + *mock.Call +} + +// Grant is a helper method to define mock.On call +// - ctx context.Context +// - tx *sqlx.Tx +// - req consent.GrantRequest +func (_e *ConsentService_Expecter) Grant(ctx interface{}, tx interface{}, req interface{}) *ConsentService_Grant_Call { + return &ConsentService_Grant_Call{Call: _e.mock.On("Grant", ctx, tx, req)} +} + +func (_c *ConsentService_Grant_Call) Run(run func(ctx context.Context, tx *sqlx.Tx, req consent.GrantRequest)) *ConsentService_Grant_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*sqlx.Tx), args[2].(consent.GrantRequest)) + }) + return _c +} + +func (_c *ConsentService_Grant_Call) Return(_a0 consent.Consent, _a1 error) *ConsentService_Grant_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ConsentService_Grant_Call) RunAndReturn(run func(context.Context, *sqlx.Tx, consent.GrantRequest) (consent.Consent, error)) *ConsentService_Grant_Call { + _c.Call.Return(run) + return _c +} + +// RecordGranted provides a mock function with given fields: ctx, granted +func (_m *ConsentService) RecordGranted(ctx context.Context, granted consent.Consent) { + _m.Called(ctx, granted) +} + +// ConsentService_RecordGranted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordGranted' +type ConsentService_RecordGranted_Call struct { + *mock.Call +} + +// RecordGranted is a helper method to define mock.On call +// - ctx context.Context +// - granted consent.Consent +func (_e *ConsentService_Expecter) RecordGranted(ctx interface{}, granted interface{}) *ConsentService_RecordGranted_Call { + return &ConsentService_RecordGranted_Call{Call: _e.mock.On("RecordGranted", ctx, granted)} +} + +func (_c *ConsentService_RecordGranted_Call) Run(run func(ctx context.Context, granted consent.Consent)) *ConsentService_RecordGranted_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(consent.Consent)) + }) + return _c +} + +func (_c *ConsentService_RecordGranted_Call) Return() *ConsentService_RecordGranted_Call { + _c.Call.Return() + return _c +} + +func (_c *ConsentService_RecordGranted_Call) RunAndReturn(run func(context.Context, consent.Consent)) *ConsentService_RecordGranted_Call { + _c.Run(run) + return _c +} + +// ResolveAll provides a mock function with given fields: ids +func (_m *ConsentService) ResolveAll(ids []string) ([]consent.Document, error) { + ret := _m.Called(ids) + + if len(ret) == 0 { + panic("no return value specified for ResolveAll") + } + + var r0 []consent.Document + var r1 error + if rf, ok := ret.Get(0).(func([]string) ([]consent.Document, error)); ok { + return rf(ids) + } + if rf, ok := ret.Get(0).(func([]string) []consent.Document); ok { + r0 = rf(ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]consent.Document) + } + } + + if rf, ok := ret.Get(1).(func([]string) error); ok { + r1 = rf(ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ConsentService_ResolveAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveAll' +type ConsentService_ResolveAll_Call struct { + *mock.Call +} + +// ResolveAll is a helper method to define mock.On call +// - ids []string +func (_e *ConsentService_Expecter) ResolveAll(ids interface{}) *ConsentService_ResolveAll_Call { + return &ConsentService_ResolveAll_Call{Call: _e.mock.On("ResolveAll", ids)} +} + +func (_c *ConsentService_ResolveAll_Call) Run(run func(ids []string)) *ConsentService_ResolveAll_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].([]string)) + }) + return _c +} + +func (_c *ConsentService_ResolveAll_Call) Return(_a0 []consent.Document, _a1 error) *ConsentService_ResolveAll_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *ConsentService_ResolveAll_Call) RunAndReturn(run func([]string) ([]consent.Document, error)) *ConsentService_ResolveAll_Call { + _c.Call.Return(run) + return _c +} + +// NewConsentService creates a new instance of ConsentService. 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 NewConsentService(t interface { + mock.TestingT + Cleanup(func()) +}) *ConsentService { + mock := &ConsentService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/authenticate/mocks/transactor.go b/core/authenticate/mocks/transactor.go new file mode 100644 index 0000000000..08507d3a30 --- /dev/null +++ b/core/authenticate/mocks/transactor.go @@ -0,0 +1,88 @@ +// Code generated by mockery v2.53.5. DO NOT EDIT. + +package mocks + +import ( + context "context" + + sql "database/sql" + + sqlx "github.com/jmoiron/sqlx" + + mock "github.com/stretchr/testify/mock" +) + +// Transactor is an autogenerated mock type for the Transactor type +type Transactor struct { + mock.Mock +} + +type Transactor_Expecter struct { + mock *mock.Mock +} + +func (_m *Transactor) EXPECT() *Transactor_Expecter { + return &Transactor_Expecter{mock: &_m.Mock} +} + +// WithTxn provides a mock function with given fields: ctx, txnOptions, txFunc +func (_m *Transactor) WithTxn(ctx context.Context, txnOptions sql.TxOptions, txFunc func(*sqlx.Tx) error) error { + ret := _m.Called(ctx, txnOptions, txFunc) + + if len(ret) == 0 { + panic("no return value specified for WithTxn") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, sql.TxOptions, func(*sqlx.Tx) error) error); ok { + r0 = rf(ctx, txnOptions, txFunc) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Transactor_WithTxn_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WithTxn' +type Transactor_WithTxn_Call struct { + *mock.Call +} + +// WithTxn is a helper method to define mock.On call +// - ctx context.Context +// - txnOptions sql.TxOptions +// - txFunc func(*sqlx.Tx) error +func (_e *Transactor_Expecter) WithTxn(ctx interface{}, txnOptions interface{}, txFunc interface{}) *Transactor_WithTxn_Call { + return &Transactor_WithTxn_Call{Call: _e.mock.On("WithTxn", ctx, txnOptions, txFunc)} +} + +func (_c *Transactor_WithTxn_Call) Run(run func(ctx context.Context, txnOptions sql.TxOptions, txFunc func(*sqlx.Tx) error)) *Transactor_WithTxn_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(sql.TxOptions), args[2].(func(*sqlx.Tx) error)) + }) + return _c +} + +func (_c *Transactor_WithTxn_Call) Return(_a0 error) *Transactor_WithTxn_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *Transactor_WithTxn_Call) RunAndReturn(run func(context.Context, sql.TxOptions, func(*sqlx.Tx) error) error) *Transactor_WithTxn_Call { + _c.Call.Return(run) + return _c +} + +// NewTransactor creates a new instance of Transactor. 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 NewTransactor(t interface { + mock.TestingT + Cleanup(func()) +}) *Transactor { + mock := &Transactor{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/authenticate/mocks/user_service.go b/core/authenticate/mocks/user_service.go index ee0b4c85d0..3f8aa8c147 100644 --- a/core/authenticate/mocks/user_service.go +++ b/core/authenticate/mocks/user_service.go @@ -5,6 +5,8 @@ package mocks import ( context "context" + sqlx "github.com/jmoiron/sqlx" + user "github.com/raystack/frontier/core/user" mock "github.com/stretchr/testify/mock" ) @@ -79,6 +81,64 @@ func (_c *UserService_Create_Call) RunAndReturn(run func(context.Context, user.U return _c } +// CreateWithTx provides a mock function with given fields: ctx, tx, toCreate +func (_m *UserService) CreateWithTx(ctx context.Context, tx *sqlx.Tx, toCreate user.User) (user.User, error) { + ret := _m.Called(ctx, tx, toCreate) + + if len(ret) == 0 { + panic("no return value specified for CreateWithTx") + } + + var r0 user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *sqlx.Tx, user.User) (user.User, error)); ok { + return rf(ctx, tx, toCreate) + } + if rf, ok := ret.Get(0).(func(context.Context, *sqlx.Tx, user.User) user.User); ok { + r0 = rf(ctx, tx, toCreate) + } else { + r0 = ret.Get(0).(user.User) + } + + if rf, ok := ret.Get(1).(func(context.Context, *sqlx.Tx, user.User) error); ok { + r1 = rf(ctx, tx, toCreate) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UserService_CreateWithTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateWithTx' +type UserService_CreateWithTx_Call struct { + *mock.Call +} + +// CreateWithTx is a helper method to define mock.On call +// - ctx context.Context +// - tx *sqlx.Tx +// - toCreate user.User +func (_e *UserService_Expecter) CreateWithTx(ctx interface{}, tx interface{}, toCreate interface{}) *UserService_CreateWithTx_Call { + return &UserService_CreateWithTx_Call{Call: _e.mock.On("CreateWithTx", ctx, tx, toCreate)} +} + +func (_c *UserService_CreateWithTx_Call) Run(run func(ctx context.Context, tx *sqlx.Tx, toCreate user.User)) *UserService_CreateWithTx_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*sqlx.Tx), args[2].(user.User)) + }) + return _c +} + +func (_c *UserService_CreateWithTx_Call) Return(_a0 user.User, _a1 error) *UserService_CreateWithTx_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *UserService_CreateWithTx_Call) RunAndReturn(run func(context.Context, *sqlx.Tx, user.User) (user.User, error)) *UserService_CreateWithTx_Call { + _c.Call.Return(run) + return _c +} + // GetByID provides a mock function with given fields: ctx, id func (_m *UserService) GetByID(ctx context.Context, id string) (user.User, error) { ret := _m.Called(ctx, id) diff --git a/core/authenticate/service.go b/core/authenticate/service.go index da8c1cf88e..4273023fa7 100644 --- a/core/authenticate/service.go +++ b/core/authenticate/service.go @@ -3,6 +3,7 @@ package authenticate import ( "bytes" "context" + "database/sql" "encoding/base64" "encoding/json" "fmt" @@ -11,6 +12,10 @@ import ( "strings" "time" + "github.com/jmoiron/sqlx" + + "github.com/raystack/frontier/core/consent" + "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" "github.com/raystack/frontier/pkg/metadata" @@ -61,14 +66,31 @@ var ( ErrOIDCTokenExchange = errors.New("failed to exchange oidc authorization code") ErrLoginUserNotFound = errors.New("no account for this email") ErrSignupUserExists = errors.New("an account already exists for this email") + ErrConsentRequired = errors.New("consent required for the configured documents") ) type UserService interface { GetByID(ctx context.Context, id string) (user.User, error) Create(context.Context, user.User) (user.User, error) + CreateWithTx(ctx context.Context, tx *sqlx.Tx, toCreate user.User) (user.User, error) Update(ctx context.Context, toUpdate user.User) (user.User, error) } +// ConsentService checks what a caller accepted against what the deployment +// configures, and writes the record for it. With app.consent disabled it +// resolves nothing, so an empty document set is the signal to write no record. +type ConsentService interface { + ResolveAll(ids []string) ([]consent.Document, error) + Grant(ctx context.Context, tx *sqlx.Tx, req consent.GrantRequest) (consent.Consent, error) + RecordGranted(ctx context.Context, granted consent.Consent) +} + +// Transactor opens a database transaction. pkg/db carries none on the context, +// so the two inserts are held together by passing one down explicitly. +type Transactor interface { + WithTxn(ctx context.Context, txnOptions sql.TxOptions, txFunc func(*sqlx.Tx) error) error +} + type ServiceUserService interface { Get(ctx context.Context, id string) (serviceuser.ServiceUser, error) GetByJWT(ctx context.Context, token string) (serviceuser.ServiceUser, error) @@ -116,12 +138,14 @@ type Service struct { userPATService UserPATService orgService OrgService webAuth *webauthn.WebAuthn + consentService ConsentService + transactor Transactor } func NewService(logger *slog.Logger, config Config, flowRepo FlowRepository, mailDialer mailer.Dialer, tokenService TokenService, sessionService SessionService, userService UserService, serviceUserService ServiceUserService, webAuthConfig *webauthn.WebAuthn, - userPATService UserPATService) *Service { + userPATService UserPATService, consentService ConsentService, transactor Transactor) *Service { r := &Service{ log: logger, cron: cron.New(cron.WithChain( @@ -140,6 +164,8 @@ func NewService(logger *slog.Logger, config Config, flowRepo FlowRepository, serviceUserService: serviceUserService, userPATService: userPATService, webAuth: webAuthConfig, + consentService: consentService, + transactor: transactor, } return r } @@ -853,7 +879,9 @@ func (s Service) getOrCreateUser(ctx context.Context, flow *Flow, email, title s if intent == FlowIntentSignup { return user.User{}, ErrSignupUserExists } - // user is already registered + // an existing user gets no record whatever the flow carries: one written + // outside a user creation would carry this moment's timestamp and IP for + // an agreement made elsewhere, which reads like evidence // TODO(kushsharma): should we update metadata like profile picture from social logins // for registered users every time the login? @@ -867,11 +895,7 @@ func (s Service) getOrCreateUser(ctx context.Context, flow *Flow, email, title s } // register a new user - newUser, err := s.userService.Create(ctx, user.User{ - Title: title, - Email: email, - Name: str.GenerateUserSlug(email), - }) + newUser, err := s.createUser(ctx, flow, email, title) if err != nil { return user.User{}, err } @@ -885,6 +909,79 @@ func (s Service) getOrCreateUser(ctx context.Context, flow *Flow, email, title s return newUser, nil } +// createUser writes the user row, and the consent record alongside it in one +// transaction when the deployment asks for consent. ResolveAll runs before the +// transaction opens, so an incomplete payload never starts one; inside, both +// inserts land or neither does. +func (s Service) createUser(ctx context.Context, flow *Flow, email, title string) (user.User, error) { + toCreate := user.User{ + Title: title, + Email: email, + Name: str.GenerateUserSlug(email), + } + + documents, err := s.resolveConsent(flow) + if err != nil { + return user.User{}, err + } + if len(documents) == 0 { + // nothing to record, so nothing to hold a transaction open for + return s.userService.Create(ctx, toCreate) + } + + // documents is non-empty, so the flow carried a consent that covered them + consented, _ := flow.Consent() + var newUser user.User + var granted consent.Consent + if err := s.transactor.WithTxn(ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error { + var txErr error + if newUser, txErr = s.userService.CreateWithTx(ctx, tx, toCreate); txErr != nil { + return txErr + } + granted, txErr = s.consentService.Grant(ctx, tx, consent.GrantRequest{ + UserID: newUser.ID, + UserEmail: newUser.Email, + Documents: documents, + Source: consent.SourceSignup, + // the flow's own word for how the consent came in + AuthStrategy: flow.Method, + // the IP and the time are from when the user accepted, not from now + IPAddress: consented.IPAddress, + ConsentedAt: consented.At, + }) + return txErr + }); err != nil { + return user.User{}, err + } + + // after the commit: the audit repository has no transactional create, so this + // is a breadcrumb and the consent record is the source of truth + s.consentService.RecordGranted(ctx, granted) + return newUser, nil +} + +// resolveConsent reports which documents this user creation has to record, and +// rejects it when the flow does not carry a complete consent. +// +// The check runs here under every intent, not for the error but as the invariant +// guarding the write: an unset intent is permissive for the login gate, never +// for consent. A nil flow is one of the paths that create a user without one, +// and stays exempt because no account holder is present to consent. +func (s Service) resolveConsent(flow *Flow) ([]consent.Document, error) { + if flow == nil || s.consentService == nil { + return nil, nil + } + + // an empty set is complete only when the deployment configures no documents + consented, _ := flow.Consent() + documents, err := s.consentService.ResolveAll(consented.AcceptedDocumentIDs) + if err != nil { + // the wrapped error names what is missing, for the log not the response + return nil, fmt.Errorf("%w: %w", ErrConsentRequired, err) + } + return documents, nil +} + func (s Service) GetPrincipal(ctx context.Context, assertions ...ClientAssertion) (Principal, error) { if metrics.ServiceOprLatency != nil { promCollect := metrics.ServiceOprLatency("authenticate", "GetPrincipal") diff --git a/core/authenticate/service_test.go b/core/authenticate/service_test.go index 747991bde9..9d27cc6ad9 100644 --- a/core/authenticate/service_test.go +++ b/core/authenticate/service_test.go @@ -2,6 +2,7 @@ package authenticate_test import ( "context" + "database/sql" "encoding/base64" "encoding/json" "errors" @@ -12,6 +13,10 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" + + "github.com/raystack/frontier/core/consent" + "github.com/go-webauthn/webauthn/webauthn" "golang.org/x/crypto/bcrypt" @@ -84,7 +89,7 @@ func TestService_GetPrincipal(t *testing.T) { }, wantErr: false, setup: func() *authenticate.Service { - return authenticate.NewService(nil, authenticate.Config{}, nil, nil, nil, nil, nil, nil, nil, nil) + return authenticate.NewService(nil, authenticate.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }, }, { @@ -120,7 +125,7 @@ func TestService_GetPrincipal(t *testing.T) { }, nil) return authenticate.NewService(nil, authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil, nil, nil) }, }, { @@ -144,7 +149,7 @@ func TestService_GetPrincipal(t *testing.T) { mockSessionService.EXPECT().ExtractFromContext(mock.Anything).Return(mockSess, nil) return authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil, nil, nil) }, }, { @@ -173,7 +178,7 @@ func TestService_GetPrincipal(t *testing.T) { }, nil) return authenticate.NewService(nil, authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil, nil, nil) }, }, { @@ -191,7 +196,7 @@ func TestService_GetPrincipal(t *testing.T) { mockTokenService.EXPECT().Parse(mock.Anything, tokenBytes).Return("", map[string]any{}, errors.New("invalid token")) return authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil, nil, nil) }, }, { @@ -219,7 +224,7 @@ func TestService_GetPrincipal(t *testing.T) { }, nil) return authenticate.NewService(nil, authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil, nil, nil) }, }, { @@ -237,7 +242,7 @@ func TestService_GetPrincipal(t *testing.T) { mockServiceUserService.EXPECT().GetByJWT(mock.Anything, string(tokenBytes)).Return(serviceuser.ServiceUser{}, errors.New("invalid")) return authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil, nil, nil) }, }, { @@ -265,7 +270,7 @@ func TestService_GetPrincipal(t *testing.T) { }, nil) return authenticate.NewService(nil, authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, nil, nil, nil) }, }, } @@ -339,7 +344,7 @@ func TestService_StartFlow(t *testing.T) { wantErr: authenticate.ErrUnsupportedMethod, setup: func() *authenticate.Service { return authenticate.NewService(nil, authenticate.Config{}, nil, nil, - nil, nil, nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil, nil, nil) }, }, { @@ -370,7 +375,7 @@ func TestService_StartFlow(t *testing.T) { TestUsers: testusers.Config{Enabled: true, OTP: "111111", Domain: "example.com"}, }, mockFlowRepo, mockDialer, nil, nil, - nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -402,7 +407,7 @@ func TestService_StartFlow(t *testing.T) { TestUsers: testusers.Config{Enabled: true, OTP: "111111", Domain: "example.com"}, }, mockFlowRepo, mockDialer, nil, nil, - nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -433,7 +438,7 @@ func TestService_StartFlow(t *testing.T) { MailOTP: authenticate.MailOTPConfig{}, }, mockFlowRepo, mockDialer, nil, nil, - nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -513,7 +518,7 @@ func TestService_FinishFlow(t *testing.T) { mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) mockUserService.EXPECT().GetByID(ctx, "test@example.com").Return(sampleUser, nil) srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, - nil, nil, mockUserService, nil, nil, nil) + nil, nil, mockUserService, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -541,7 +546,7 @@ func TestService_FinishFlow(t *testing.T) { return f.Metadata["attempt"] == 1 && f.Nonce == string(otpHash) })).Return(nil) srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, - nil, nil, nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -569,7 +574,7 @@ func TestService_FinishFlow(t *testing.T) { return f.Metadata["attempt"] == 1 && f.Nonce == "111111" })).Return(nil) srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, - nil, nil, nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -595,7 +600,7 @@ func TestService_FinishFlow(t *testing.T) { Return(mailOTPFlow(flowID, timeNow, string(otpHash), pkgMetadata.Metadata{"callback_url": "", "attempt": 2}), nil) mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, - nil, nil, nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -635,7 +640,7 @@ func TestService_FinishFlow_WrongThenRightOTP(t *testing.T) { mockUserService.EXPECT().GetByID(ctx, "test@example.com").Return(sampleUser, nil).Once() srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, - nil, nil, mockUserService, nil, nil, nil) + nil, nil, mockUserService, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -714,7 +719,7 @@ func TestService_FinishFlow_OTPAttemptCapAfterJSONRoundTrip(t *testing.T) { require.NoError(t, flowRepo.Set(ctx, mailOTPFlow(flowID, timeNow, string(otpHash), pkgMetadata.Metadata{"callback_url": ""}))) srv := authenticate.NewService(nil, authenticate.Config{}, flowRepo, nil, - nil, nil, nil, nil, nil, nil) + nil, nil, nil, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } @@ -759,7 +764,7 @@ func TestService_GetPrincipal_JWTGrantSkipsNonGrantToken(t *testing.T) { Return(user.User{ID: userID.String()}, nil) svc := authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, mockPATService) + mockFlow, nil, mockTokenService, mockSessionService, mockUserService, mockServiceUserService, nil, mockPATService, nil, nil) ctx := metadata.NewIncomingContext(context.Background(), map[string][]string{ consts.UserTokenGatewayKey: {patValue}, @@ -803,7 +808,7 @@ func TestService_GetPrincipal_RestrictsByAuthVia(t *testing.T) { {"authtoken rejects passthrough", authenticate.PassthroughHeaderClientAssertion, authTokenSet, true}, } - svc := authenticate.NewService(nil, authenticate.Config{}, nil, nil, nil, nil, nil, nil, nil, nil) + svc := authenticate.NewService(nil, authenticate.Config{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -848,7 +853,7 @@ func TestService_GetPrincipal_OrgStateGate(t *testing.T) { org := mocks.NewOrgService(t) org.EXPECT().IsEnabled(mock.Anything, orgID).Return(false, nil) s := authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - nil, nil, nil, nil, usr, nil, nil, pat) + nil, nil, nil, nil, usr, nil, nil, pat, nil, nil) s.SetOrgService(org) return s }, @@ -867,7 +872,7 @@ func TestService_GetPrincipal_OrgStateGate(t *testing.T) { usr.EXPECT().GetByID(mock.Anything, userID).Return(user.User{ID: userID}, nil) org := mocks.NewOrgService(t) s := authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - nil, nil, nil, nil, usr, nil, nil, pat) + nil, nil, nil, nil, usr, nil, nil, pat, nil, nil) s.SetOrgService(org) return s }, @@ -893,7 +898,7 @@ func TestService_GetPrincipal_OrgStateGate(t *testing.T) { org := mocks.NewOrgService(t) org.EXPECT().IsEnabled(mock.Anything, orgID).Return(true, nil) s := authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - nil, nil, nil, nil, usr, nil, nil, pat) + nil, nil, nil, nil, usr, nil, nil, pat, nil, nil) s.SetOrgService(org) return s }, @@ -911,7 +916,7 @@ func TestService_GetPrincipal_OrgStateGate(t *testing.T) { org := mocks.NewOrgService(t) org.EXPECT().IsEnabled(mock.Anything, orgID).Return(false, nil) s := authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - nil, nil, nil, nil, nil, su, nil, nil) + nil, nil, nil, nil, nil, su, nil, nil, nil, nil) s.SetOrgService(org) return s }, @@ -929,7 +934,7 @@ func TestService_GetPrincipal_OrgStateGate(t *testing.T) { org := mocks.NewOrgService(t) org.EXPECT().IsEnabled(mock.Anything, orgID).Return(false, nil) s := authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, - nil, nil, nil, nil, nil, su, nil, nil) + nil, nil, nil, nil, nil, su, nil, nil, nil, nil) s.SetOrgService(org) return s }, @@ -1019,7 +1024,7 @@ func TestService_BuildToken(t *testing.T) { t.Run(tt.name, func(t *testing.T) { mockToken := mocks.NewTokenService(t) mockToken.EXPECT().Build(tt.principal.ID, tt.wantClaims).Return([]byte("signed-token"), nil) - s := authenticate.NewService(nil, tt.config, nil, nil, mockToken, nil, nil, nil, nil, nil) + s := authenticate.NewService(nil, tt.config, nil, nil, mockToken, nil, nil, nil, nil, nil, nil, nil) got, err := s.BuildToken(context.Background(), tt.principal, map[string]string{}) assert.NoError(t, err) @@ -1247,7 +1252,7 @@ func TestService_StartFlow_Intent(t *testing.T) { MailOTP: authenticate.MailOTPConfig{Validity: 10 * time.Minute}, MailLink: authenticate.MailLinkConfig{Validity: 10 * time.Minute}, TestUsers: testusers.Config{Enabled: true, OTP: "111111", Domain: "example.com"}, - }, mockFlowRepo, mockDialer, nil, nil, mockUserService, nil, webAuth, nil) + }, mockFlowRepo, mockDialer, nil, nil, mockUserService, nil, webAuth, nil, nil, nil) got, err := srv.StartFlow(ctx, authenticate.RegistrationStartRequest{ Method: tt.method, @@ -1304,7 +1309,7 @@ func TestService_StartFlow_WritesIntentAndConsent(t *testing.T) { srv := authenticate.NewService(nil, authenticate.Config{ MailOTP: authenticate.MailOTPConfig{Validity: 10 * time.Minute}, TestUsers: testusers.Config{Enabled: true, OTP: "111111", Domain: "example.com"}, - }, mockFlowRepo, mailer.NewMockDialer(), nil, nil, mockUserService, nil, nil, nil) + }, mockFlowRepo, mailer.NewMockDialer(), nil, nil, mockUserService, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } _, err := srv.StartFlow(ctx, request) @@ -1483,7 +1488,7 @@ func TestService_FinishFlow_Intent(t *testing.T) { } srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, - nil, nil, mockUserService, nil, nil, nil) + nil, nil, mockUserService, nil, nil, nil, nil, nil) srv.Now = func() time.Time { return timeNow } got, err := srv.FinishFlow(ctx, authenticate.RegistrationFinishRequest{ @@ -1503,3 +1508,262 @@ func TestService_FinishFlow_Intent(t *testing.T) { }) } } + +// TestService_FinishFlow_Consent covers the invariant this feature rests on: a +// user row without a consent record is impossible. The rollback that backs it +// is exercised against a real database in +// internal/store/postgres/user_consent_repository_test.go; what is checked here +// is which of the three outcomes each request reaches, and what is written for +// it. +func TestService_FinishFlow_Consent(t *testing.T) { + timeNow := time.Now() + otpHash, err := bcrypt.GenerateFromPassword([]byte("111111"), bcrypt.MinCost) + require.NoError(t, err) + + const email = "test@example.com" + consentedAt := timeNow.Add(-time.Minute).UTC() + newUser := user.User{ID: "user-id", Email: email} + documents := []consent.Document{ + {ID: "privacy_policy", Title: "Privacy Policy", Version: "2026-04-01", URL: "https://example.org/p"}, + {ID: "terms_of_service", Title: "Terms & Conditions", Version: "2026-04-01", URL: "https://example.org/t"}, + } + acceptedIDs := []string{"privacy_policy", "terms_of_service"} + + // consentMetadata is what StartFlow wrote before the redirect, after a JSON + // round trip through the flows table. + consentMetadata := func() pkgMetadata.Metadata { + return pkgMetadata.Metadata{ + "callback_url": "", + "intent": authenticate.FlowIntentSignup.String(), + "consent": map[string]any{ + "accepted_document_ids": []any{"privacy_policy", "terms_of_service"}, + "ip_address": "203.0.113.9", + "at": consentedAt.Format(time.RFC3339Nano), + }, + } + } + + finish := func(t *testing.T, srv *authenticate.Service, ctx context.Context, flowID uuid.UUID) (*authenticate.RegistrationFinishResponse, error) { + t.Helper() + return srv.FinishFlow(ctx, authenticate.RegistrationFinishRequest{ + Method: authenticate.MailOTPAuthMethod.String(), + State: flowID.String(), + Code: "111111", + }) + } + + t.Run("a complete payload writes the user and the consent in one transaction", func(t *testing.T) { + ctx := context.Background() + flowID := uuid.New() + + mockFlowRepo, mockUserService, _, _, _ := createMocks(t) + mockFlowRepo.EXPECT().Get(ctx, flowID).Return(mailOTPFlow(flowID, timeNow, string(otpHash), consentMetadata()), nil) + mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) + mockUserService.EXPECT().GetByID(ctx, email).Return(user.User{}, errors.New("user not found")) + mockUserService.EXPECT().CreateWithTx(ctx, (*sqlx.Tx)(nil), mock.Anything).Return(newUser, nil) + + mockConsent := mocks.NewConsentService(t) + mockConsent.EXPECT().ResolveAll(acceptedIDs).Return(documents, nil) + + granted := consent.Consent{ID: "consent-id", UserID: newUser.ID} + var grantRequest consent.GrantRequest + mockConsent.EXPECT().Grant(ctx, (*sqlx.Tx)(nil), mock.Anything). + Run(func(_ context.Context, _ *sqlx.Tx, req consent.GrantRequest) { grantRequest = req }). + Return(granted, nil) + // after the commit, not inside it + mockConsent.EXPECT().RecordGranted(ctx, granted).Return() + + srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, + nil, nil, mockUserService, nil, nil, nil, mockConsent, fakeTransactor{}) + srv.Now = func() time.Time { return timeNow } + + got, err := finish(t, srv, ctx, flowID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, newUser, got.User) + + assert.Equal(t, newUser.ID, grantRequest.UserID) + assert.Equal(t, newUser.Email, grantRequest.UserEmail) + assert.Equal(t, documents, grantRequest.Documents) + assert.Equal(t, consent.SourceSignup, grantRequest.Source) + // the flow's own word for how the consent came in + assert.Equal(t, authenticate.MailOTPAuthMethod.String(), grantRequest.AuthStrategy) + // the IP and the time are from when the user accepted, not from now + assert.Equal(t, "203.0.113.9", grantRequest.IPAddress) + assert.True(t, consentedAt.Equal(grantRequest.ConsentedAt)) + }) + + t.Run("an incomplete payload writes neither row", func(t *testing.T) { + ctx := context.Background() + flowID := uuid.New() + + md := consentMetadata() + md["consent"] = map[string]any{ + "accepted_document_ids": []any{"privacy_policy"}, + "ip_address": "203.0.113.9", + "at": consentedAt.Format(time.RFC3339Nano), + } + + mockFlowRepo, mockUserService, _, _, _ := createMocks(t) + mockFlowRepo.EXPECT().Get(ctx, flowID).Return(mailOTPFlow(flowID, timeNow, string(otpHash), md), nil) + mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) + mockUserService.EXPECT().GetByID(ctx, email).Return(user.User{}, errors.New("user not found")) + + mockConsent := mocks.NewConsentService(t) + mockConsent.EXPECT().ResolveAll([]string{"privacy_policy"}). + Return(nil, consent.ErrMissingDocuments) + + // no transactor at all: an incomplete payload must never open one, and + // a nil one would panic if it did + srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, + nil, nil, mockUserService, nil, nil, nil, mockConsent, nil) + srv.Now = func() time.Time { return timeNow } + + got, err := finish(t, srv, ctx, flowID) + assert.ErrorIs(t, err, authenticate.ErrConsentRequired) + // the wrapped error still names what was missing + assert.ErrorIs(t, err, consent.ErrMissingDocuments) + assert.Nil(t, got) + mockUserService.AssertNotCalled(t, "Create", mock.Anything, mock.Anything) + mockUserService.AssertNotCalled(t, "CreateWithTx", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("a flow carrying no consent at all is rejected too", func(t *testing.T) { + // the check runs under every intent, not for the error but as the + // invariant guarding the write + ctx := context.Background() + flowID := uuid.New() + + mockFlowRepo, mockUserService, _, _, _ := createMocks(t) + mockFlowRepo.EXPECT().Get(ctx, flowID).Return( + mailOTPFlow(flowID, timeNow, string(otpHash), pkgMetadata.Metadata{"callback_url": ""}), nil) + mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) + mockUserService.EXPECT().GetByID(ctx, email).Return(user.User{}, errors.New("user not found")) + + mockConsent := mocks.NewConsentService(t) + mockConsent.EXPECT().ResolveAll([]string(nil)).Return(nil, consent.ErrMissingDocuments) + + srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, + nil, nil, mockUserService, nil, nil, nil, mockConsent, nil) + srv.Now = func() time.Time { return timeNow } + + got, err := finish(t, srv, ctx, flowID) + assert.ErrorIs(t, err, authenticate.ErrConsentRequired) + assert.Nil(t, got) + }) + + t.Run("an existing user gets no consent record", func(t *testing.T) { + // absolute: a record written outside a user creation would carry this + // moment's timestamp and IP for an agreement made elsewhere + ctx := context.Background() + flowID := uuid.New() + + md := consentMetadata() + delete(md, "intent") // a signup intent would be rejected by the gate first + + mockFlowRepo, mockUserService, _, _, _ := createMocks(t) + mockFlowRepo.EXPECT().Get(ctx, flowID).Return(mailOTPFlow(flowID, timeNow, string(otpHash), md), nil) + mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) + mockUserService.EXPECT().GetByID(ctx, email).Return(newUser, nil) + + // the consent service is never reached, so an unexpected call fails the + // test rather than passing silently + mockConsent := mocks.NewConsentService(t) + + srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, + nil, nil, mockUserService, nil, nil, nil, mockConsent, nil) + srv.Now = func() time.Time { return timeNow } + + got, err := finish(t, srv, ctx, flowID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, newUser, got.User) + }) + + t.Run("a deployment that asks for no consent creates the user as before", func(t *testing.T) { + // with app.consent disabled ResolveAll accepts anything and resolves + // nothing, and an empty set means write no record + ctx := context.Background() + flowID := uuid.New() + + mockFlowRepo, mockUserService, _, _, _ := createMocks(t) + mockFlowRepo.EXPECT().Get(ctx, flowID).Return(mailOTPFlow(flowID, timeNow, string(otpHash), consentMetadata()), nil) + mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) + mockUserService.EXPECT().GetByID(ctx, email).Return(user.User{}, errors.New("user not found")) + mockUserService.EXPECT().Create(ctx, mock.Anything).Return(newUser, nil) + + mockConsent := mocks.NewConsentService(t) + mockConsent.EXPECT().ResolveAll(acceptedIDs).Return(nil, nil) + + srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, + nil, nil, mockUserService, nil, nil, nil, mockConsent, nil) + srv.Now = func() time.Time { return timeNow } + + got, err := finish(t, srv, ctx, flowID) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, newUser, got.User) + }) + + t.Run("a failed consent write fails the signup", func(t *testing.T) { + ctx := context.Background() + flowID := uuid.New() + + mockFlowRepo, mockUserService, _, _, _ := createMocks(t) + mockFlowRepo.EXPECT().Get(ctx, flowID).Return(mailOTPFlow(flowID, timeNow, string(otpHash), consentMetadata()), nil) + mockFlowRepo.EXPECT().Delete(ctx, flowID).Return(nil) + mockUserService.EXPECT().GetByID(ctx, email).Return(user.User{}, errors.New("user not found")) + mockUserService.EXPECT().CreateWithTx(ctx, (*sqlx.Tx)(nil), mock.Anything).Return(newUser, nil) + + mockConsent := mocks.NewConsentService(t) + mockConsent.EXPECT().ResolveAll(acceptedIDs).Return(documents, nil) + mockConsent.EXPECT().Grant(ctx, (*sqlx.Tx)(nil), mock.Anything). + Return(consent.Consent{}, consent.ErrConsentExists) + + srv := authenticate.NewService(nil, authenticate.Config{}, mockFlowRepo, nil, + nil, nil, mockUserService, nil, nil, nil, mockConsent, fakeTransactor{}) + srv.Now = func() time.Time { return timeNow } + + got, err := finish(t, srv, ctx, flowID) + assert.ErrorIs(t, err, consent.ErrConsentExists) + assert.Nil(t, got) + // no breadcrumb for a consent that was rolled back + mockConsent.AssertNotCalled(t, "RecordGranted", mock.Anything, mock.Anything) + }) +} + +// fakeTransactor runs the function it is given without a database, which is all +// the service needs from it: the rollback that makes the two inserts atomic is +// Postgres's job, and is tested against a real one in +// internal/store/postgres/user_consent_repository_test.go. +type fakeTransactor struct{} + +func (fakeTransactor) WithTxn(_ context.Context, _ sql.TxOptions, txFunc func(*sqlx.Tx) error) error { + return txFunc(nil) +} + +// TestService_PassthroughHeader_Consent pins the exemption. Three paths create +// a user with no flow behind them, and they stay exempt because no account +// holder is present to consent. This is the one of the three that runs through +// getOrCreateUser, so it is the one that could have been gated by accident. +func TestService_PassthroughHeader_Consent(t *testing.T) { + const email = "passthrough@example.com" + ctx := authenticate.SetContextWithEmail(context.Background(), email) + newUser := user.User{ID: "user-id", Email: email} + + _, mockUserService, _, _, _ := createMocks(t) + mockUserService.EXPECT().GetByID(ctx, email).Return(user.User{}, errors.New("user not found")) + mockUserService.EXPECT().Create(ctx, mock.Anything).Return(newUser, nil) + + // the consent service is wired and enabled, and is still never reached: + // there is no flow, so there is nothing that could carry a consent + mockConsent := mocks.NewConsentService(t) + + srv := authenticate.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), authenticate.Config{}, + nil, nil, nil, nil, mockUserService, nil, nil, nil, mockConsent, nil) + + got, err := srv.GetPrincipal(ctx, authenticate.PassthroughHeaderClientAssertion) + require.NoError(t, err) + assert.Equal(t, newUser.ID, got.ID) + assert.Equal(t, schema.UserPrincipal, got.Type) +} diff --git a/core/consent/consent.go b/core/consent/consent.go index 7ef676adc4..36acf83d51 100644 --- a/core/consent/consent.go +++ b/core/consent/consent.go @@ -1,5 +1,7 @@ package consent +import "time" + // Document is one document a user has to accept before an account is created. // Version is opaque: compared for equality only, so dates, semver or SHAs all // work. Frontier never reads what is behind URL. @@ -9,3 +11,37 @@ type Document struct { Version string URL string } + +// SourceSignup is the only occasion a record is written on today. A later +// re-consent would separate itself with another source, not another write path. +const SourceSignup = "signup" + +// Consent is one record: the documents a user accepted and the act that accepted +// them. The grain is the act, not the document, so the email, IP and timestamp +// are stored once. A record is immutable and outlives the user it describes, +// which is why UserEmail and the document snapshots are copies. +type Consent struct { + ID string + UserID string + UserEmail string + Documents []Document + Source string + AuthStrategy string + // IPAddress is empty when the deployment sets no client IP header. + IPAddress string + // ConsentedAt is when the user accepted, not when the row was written. + ConsentedAt time.Time + CreatedAt time.Time +} + +// GrantRequest describes the act being recorded. Only the row id and created_at +// are made at write time. +type GrantRequest struct { + UserID string + UserEmail string + Documents []Document + Source string + AuthStrategy string + IPAddress string + ConsentedAt time.Time +} diff --git a/core/consent/errors.go b/core/consent/errors.go index c78043d737..d976ff208d 100644 --- a/core/consent/errors.go +++ b/core/consent/errors.go @@ -10,4 +10,12 @@ var ( // ErrMissingDocuments is returned when the ids do not cover every configured // document. All of them are required at signup. ErrMissingDocuments = errors.New("missing consent document ids") + + // ErrInvalidGrant is returned for a grant missing something the record cannot + // be written without, which beats a constraint violation from Postgres. + ErrInvalidGrant = errors.New("invalid consent grant") + + // ErrConsentExists is returned when a user already has a signup record. + // Nothing repairs one, so a second write is a bug and should fail. + ErrConsentExists = errors.New("a consent record already exists for this user") ) diff --git a/core/consent/service.go b/core/consent/service.go index d8017e068e..7b2503dcd2 100644 --- a/core/consent/service.go +++ b/core/consent/service.go @@ -1,20 +1,47 @@ package consent import ( + "context" "fmt" + "log/slog" "sort" "strings" + + "github.com/jmoiron/sqlx" + + "github.com/raystack/frontier/core/auditrecord/models" + "github.com/raystack/frontier/internal/bootstrap/schema" + pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" ) -// Service owns the document config, so it owns the checks that read it. Config -// is read at boot, so a version change needs a restart. +// Repository writes consent records and nothing else: the table is immutable. +// It takes the transaction rather than opening one, because the record has to +// land with the user row or not at all. +type Repository interface { + Create(ctx context.Context, tx *sqlx.Tx, cnst Consent) (Consent, error) +} + +type AuditRecordRepository interface { + Create(ctx context.Context, auditRecord models.AuditRecord) (models.AuditRecord, error) +} + +// Service owns the document config, so it owns the checks that read it, and the +// repository that writes the records those checks guard. Config is read at boot, +// so a version change needs a restart. type Service struct { - config Config + logger *slog.Logger + config Config + repository Repository + auditRecordRepository AuditRecordRepository } -func NewService(config Config) *Service { +func NewService(logger *slog.Logger, config Config, repository Repository, + auditRecordRepository AuditRecordRepository) *Service { return &Service{ - config: config, + logger: logger, + config: config, + repository: repository, + auditRecordRepository: auditRecordRepository, } } @@ -94,6 +121,92 @@ func (s Service) ResolveAll(ids []string) ([]Document, error) { return documents, nil } +// Grant writes one record for the documents it is given, inside the transaction +// creating the user. No completeness rule here — ResolveAll owns that — which +// leaves room for a later re-consent covering a subset. +func (s Service) Grant(ctx context.Context, tx *sqlx.Tx, req GrantRequest) (Consent, error) { + if req.Source == "" { + req.Source = SourceSignup + } + if err := validateGrant(req); err != nil { + return Consent{}, err + } + + return s.repository.Create(ctx, tx, Consent{ + UserID: req.UserID, + UserEmail: req.UserEmail, + Documents: req.Documents, + Source: req.Source, + AuthStrategy: req.AuthStrategy, + IPAddress: req.IPAddress, + ConsentedAt: req.ConsentedAt, + }) +} + +// RecordGranted writes the audit record for a granted consent, after the commit: +// AuditRecordRepository.Create has no transactional variant, so this cannot be +// atomic with the record it describes. The consent record is the source of truth +// and this is a breadcrumb, so a failure here is logged, not returned. +// +// Actor is set explicitly because these endpoints are skip-listed: the repository +// would enrich an empty actor from a context that has none, landing the nil UUID +// and the system actor for an act a person performed. +func (s Service) RecordGranted(ctx context.Context, granted Consent) { + documents := make([]map[string]string, 0, len(granted.Documents)) + for _, document := range granted.Documents { + documents = append(documents, map[string]string{ + "id": document.ID, + "version": document.Version, + }) + } + + if _, err := s.auditRecordRepository.Create(ctx, models.AuditRecord{ + Event: pkgAuditRecord.UserConsentGrantedEvent, + // the new user is both actor and resource: nobody else is present + Actor: models.Actor{ + ID: granted.UserID, + Type: schema.UserPrincipal, + Name: granted.UserEmail, + }, + Resource: models.Resource{ + ID: granted.UserID, + Type: pkgAuditRecord.UserType, + Name: granted.UserEmail, + }, + Target: &models.Target{ + ID: granted.ID, + Type: pkgAuditRecord.ConsentType, + Name: granted.Source, + Metadata: map[string]any{ + "documents": documents, + }, + }, + // the platform org, not a blank one, so readers need no special case + OrgID: schema.PlatformOrgID.String(), + // when the user accepted, not when this row was written. + OccurredAt: granted.ConsentedAt, + // IdempotencyKey is left empty: it is nullable, and a consent record is + // written once, so there is nothing to deduplicate. + }); err != nil && s.logger != nil { + s.logger.ErrorContext(ctx, "failed to write the audit record for a granted consent", + "consent_id", granted.ID, "user_id", granted.UserID, "error", err) + } +} + +func validateGrant(req GrantRequest) error { + switch { + case req.UserID == "": + return fmt.Errorf("%w: no user id", ErrInvalidGrant) + case req.UserEmail == "": + return fmt.Errorf("%w: no user email", ErrInvalidGrant) + case len(req.Documents) == 0: + return fmt.Errorf("%w: no documents", ErrInvalidGrant) + case req.ConsentedAt.IsZero(): + return fmt.Errorf("%w: no consented at", ErrInvalidGrant) + } + return nil +} + func (s Service) document(id string) Document { document := s.config.Documents[id] return Document{ @@ -104,8 +217,8 @@ func (s Service) document(id string) Document { } } -// uniqueSorted so the same accepted set produces the same document list -// whatever order the client sent it in. +// uniqueSorted removes duplicates and orders the ids, so the same accepted set +// produces the same document list whatever order the client sent it in. func uniqueSorted(ids []string) []string { seen := make(map[string]struct{}, len(ids)) for _, id := range ids { diff --git a/core/consent/service_test.go b/core/consent/service_test.go index e7e56b59a3..f908074590 100644 --- a/core/consent/service_test.go +++ b/core/consent/service_test.go @@ -1,11 +1,21 @@ package consent_test import ( + "context" + "errors" + "io" + "log/slog" "testing" + "time" - "github.com/raystack/frontier/core/consent" + "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/raystack/frontier/core/auditrecord/models" + "github.com/raystack/frontier/core/consent" + "github.com/raystack/frontier/internal/bootstrap/schema" + pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" ) // enabledConfig is the three-document set a deployment configures: terms, @@ -59,7 +69,7 @@ func allDocuments() []consent.Document { func TestService_Documents(t *testing.T) { t.Run("returns every configured document ordered by id", func(t *testing.T) { - documents := consent.NewService(enabledConfig()).Documents() + documents := consent.NewService(nil, enabledConfig(), nil, nil).Documents() assert.Equal(t, allDocuments(), documents) }) @@ -67,7 +77,7 @@ func TestService_Documents(t *testing.T) { t.Run("orders by id whatever order config was read in", func(t *testing.T) { // map iteration is randomised, so run it enough times that an // unsorted implementation cannot pass by luck - service := consent.NewService(enabledConfig()) + service := consent.NewService(nil, enabledConfig(), nil, nil) for i := 0; i < 20; i++ { assert.Equal(t, []string{"eula", "privacy_policy", "terms_of_service"}, ids(service.Documents())) } @@ -77,16 +87,16 @@ func TestService_Documents(t *testing.T) { config := enabledConfig() config.Enabled = false - assert.Empty(t, consent.NewService(config).Documents()) + assert.Empty(t, consent.NewService(nil, config, nil, nil).Documents()) }) t.Run("returns empty for the zero config", func(t *testing.T) { - assert.Empty(t, consent.NewService(consent.Config{}).Documents()) + assert.Empty(t, consent.NewService(nil, consent.Config{}, nil, nil).Documents()) }) } func TestService_Resolve(t *testing.T) { - service := consent.NewService(enabledConfig()) + service := consent.NewService(nil, enabledConfig(), nil, nil) t.Run("maps known ids to their config snapshots", func(t *testing.T) { documents, err := service.Resolve([]string{"terms_of_service"}) @@ -149,7 +159,7 @@ func TestService_Resolve(t *testing.T) { config := enabledConfig() config.Enabled = false - documents, err := consent.NewService(config).Resolve([]string{"anything_at_all"}) + documents, err := consent.NewService(nil, config, nil, nil).Resolve([]string{"anything_at_all"}) require.NoError(t, err) assert.Empty(t, documents) @@ -157,7 +167,7 @@ func TestService_Resolve(t *testing.T) { } func TestService_ResolveAll(t *testing.T) { - service := consent.NewService(enabledConfig()) + service := consent.NewService(nil, enabledConfig(), nil, nil) t.Run("accepts a set covering every configured document", func(t *testing.T) { documents, err := service.ResolveAll([]string{"privacy_policy", "terms_of_service", "eula"}) @@ -207,7 +217,7 @@ func TestService_ResolveAll(t *testing.T) { t.Run("ignores ids when disabled rather than rejecting them", func(t *testing.T) { config := enabledConfig() config.Enabled = false - service := consent.NewService(config) + service := consent.NewService(nil, config, nil, nil) documents, err := service.ResolveAll(nil) require.NoError(t, err) @@ -226,3 +236,190 @@ func ids(documents []consent.Document) []string { } return out } + +// fakeRepository stands in for the postgres repository. core/consent has no +// generated mocks, and the write is one method, so a fake here reads better +// than a mock and keeps the transaction out of the picture: what Grant hands +// the repository is the whole question. +type fakeRepository struct { + created []consent.Consent + id string + err error +} + +func (f *fakeRepository) Create(_ context.Context, _ *sqlx.Tx, cnst consent.Consent) (consent.Consent, error) { + if f.err != nil { + return consent.Consent{}, f.err + } + f.created = append(f.created, cnst) + cnst.ID = f.id + cnst.CreatedAt = time.Date(2026, 8, 30, 12, 0, 0, 0, time.UTC) + return cnst, nil +} + +type fakeAuditRecordRepository struct { + created []models.AuditRecord + err error +} + +func (f *fakeAuditRecordRepository) Create(_ context.Context, record models.AuditRecord) (models.AuditRecord, error) { + f.created = append(f.created, record) + return record, f.err +} + +func grantRequest() consent.GrantRequest { + return consent.GrantRequest{ + UserID: "8814cdf1-0000-0000-0000-000000000001", + UserEmail: "new@example.com", + Documents: allDocuments(), + Source: consent.SourceSignup, + AuthStrategy: "mailotp", + IPAddress: "203.0.113.9", + ConsentedAt: time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC), + } +} + +func TestService_Grant(t *testing.T) { + t.Run("writes one record carrying everything that describes the act", func(t *testing.T) { + repo := &fakeRepository{id: "consent-id"} + service := consent.NewService(nil, enabledConfig(), repo, nil) + + req := grantRequest() + granted, err := service.Grant(context.Background(), nil, req) + require.NoError(t, err) + + require.Len(t, repo.created, 1) + written := repo.created[0] + assert.Equal(t, req.UserID, written.UserID) + assert.Equal(t, req.UserEmail, written.UserEmail) + assert.Equal(t, allDocuments(), written.Documents) + assert.Equal(t, consent.SourceSignup, written.Source) + assert.Equal(t, "mailotp", written.AuthStrategy) + assert.Equal(t, "203.0.113.9", written.IPAddress) + assert.Equal(t, req.ConsentedAt, written.ConsentedAt) + + assert.Equal(t, "consent-id", granted.ID) + }) + + t.Run("has no completeness rule of its own", func(t *testing.T) { + // one document out of the three configured. ResolveAll is what decides + // a signup covers everything; keeping that out of Grant is what leaves + // room for a re-consent covering a subset without a second write path. + repo := &fakeRepository{id: "consent-id"} + service := consent.NewService(nil, enabledConfig(), repo, nil) + + req := grantRequest() + req.Documents = allDocuments()[:1] + _, err := service.Grant(context.Background(), nil, req) + require.NoError(t, err) + + require.Len(t, repo.created, 1) + assert.Equal(t, []string{"eula"}, ids(repo.created[0].Documents)) + }) + + t.Run("defaults the source to signup", func(t *testing.T) { + repo := &fakeRepository{id: "consent-id"} + service := consent.NewService(nil, enabledConfig(), repo, nil) + + req := grantRequest() + req.Source = "" + _, err := service.Grant(context.Background(), nil, req) + require.NoError(t, err) + + require.Len(t, repo.created, 1) + assert.Equal(t, consent.SourceSignup, repo.created[0].Source) + }) + + t.Run("rejects a request the record cannot be written from", func(t *testing.T) { + cases := map[string]func(*consent.GrantRequest){ + "no user id": func(r *consent.GrantRequest) { r.UserID = "" }, + "no user email": func(r *consent.GrantRequest) { r.UserEmail = "" }, + "no documents": func(r *consent.GrantRequest) { r.Documents = nil }, + "no consented at": func(r *consent.GrantRequest) { r.ConsentedAt = time.Time{} }, + } + for name, break_ := range cases { + t.Run(name, func(t *testing.T) { + repo := &fakeRepository{id: "consent-id"} + service := consent.NewService(nil, enabledConfig(), repo, nil) + + req := grantRequest() + break_(&req) + _, err := service.Grant(context.Background(), nil, req) + assert.ErrorIs(t, err, consent.ErrInvalidGrant) + // nothing reached the repository, so the surrounding + // transaction has nothing to roll back + assert.Empty(t, repo.created) + }) + } + }) + + t.Run("surfaces a repository failure so the transaction rolls back", func(t *testing.T) { + repo := &fakeRepository{err: consent.ErrConsentExists} + service := consent.NewService(nil, enabledConfig(), repo, nil) + + _, err := service.Grant(context.Background(), nil, grantRequest()) + assert.ErrorIs(t, err, consent.ErrConsentExists) + }) +} + +func TestService_RecordGranted(t *testing.T) { + granted := consent.Consent{ + ID: "consent-id", + UserID: "8814cdf1-0000-0000-0000-000000000001", + UserEmail: "new@example.com", + Documents: allDocuments(), + Source: consent.SourceSignup, + AuthStrategy: "mailotp", + ConsentedAt: time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC), + } + + t.Run("sets every field explicitly, the actor included", func(t *testing.T) { + auditRepo := &fakeAuditRecordRepository{} + service := consent.NewService(nil, enabledConfig(), nil, auditRepo) + + service.RecordGranted(context.Background(), granted) + + require.Len(t, auditRepo.created, 1) + record := auditRepo.created[0] + assert.Equal(t, pkgAuditRecord.UserConsentGrantedEvent, record.Event) + + // the actor cannot be left to enrichment: these endpoints are on the + // authentication skip list, so the context holds no actor and the + // record would land as the system actor for an act a person performed + assert.Equal(t, granted.UserID, record.Actor.ID) + assert.Equal(t, schema.UserPrincipal, record.Actor.Type) + assert.Equal(t, granted.UserEmail, record.Actor.Name) + + assert.Equal(t, granted.UserID, record.Resource.ID) + assert.Equal(t, pkgAuditRecord.UserType, record.Resource.Type) + + require.NotNil(t, record.Target) + assert.Equal(t, granted.ID, record.Target.ID) + assert.Equal(t, pkgAuditRecord.ConsentType, record.Target.Type) + assert.Equal(t, consent.SourceSignup, record.Target.Name) + assert.Equal(t, []map[string]string{ + {"id": "eula", "version": "2026-02-14"}, + {"id": "privacy_policy", "version": "2026-04-01"}, + {"id": "terms_of_service", "version": "2026-04-01"}, + }, record.Target.Metadata["documents"]) + + // when the user accepted, not when the row was written + assert.Equal(t, granted.ConsentedAt, record.OccurredAt) + assert.Equal(t, schema.PlatformOrgID.String(), record.OrgID) + // nullable, and a consent record is written once + assert.Empty(t, record.IdempotencyKey) + }) + + t.Run("carries on when the audit write fails", func(t *testing.T) { + // the audit record cannot be atomic with the consent record, which is + // why that record is the source of truth and this one is a breadcrumb + auditRepo := &fakeAuditRecordRepository{err: errors.New("audit is down")} + service := consent.NewService(slog.New(slog.NewTextHandler(io.Discard, nil)), + enabledConfig(), nil, auditRepo) + + assert.NotPanics(t, func() { + service.RecordGranted(context.Background(), granted) + }) + assert.Len(t, auditRepo.created, 1) + }) +} diff --git a/core/user/mocks/repository.go b/core/user/mocks/repository.go index b6740af235..6744cbb38d 100644 --- a/core/user/mocks/repository.go +++ b/core/user/mocks/repository.go @@ -8,6 +8,8 @@ import ( rql "github.com/raystack/salt/rql" mock "github.com/stretchr/testify/mock" + sqlx "github.com/jmoiron/sqlx" + user "github.com/raystack/frontier/core/user" ) @@ -81,6 +83,64 @@ func (_c *Repository_Create_Call) RunAndReturn(run func(context.Context, user.Us return _c } +// CreateWithTx provides a mock function with given fields: ctx, tx, _a2 +func (_m *Repository) CreateWithTx(ctx context.Context, tx *sqlx.Tx, _a2 user.User) (user.User, error) { + ret := _m.Called(ctx, tx, _a2) + + if len(ret) == 0 { + panic("no return value specified for CreateWithTx") + } + + var r0 user.User + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *sqlx.Tx, user.User) (user.User, error)); ok { + return rf(ctx, tx, _a2) + } + if rf, ok := ret.Get(0).(func(context.Context, *sqlx.Tx, user.User) user.User); ok { + r0 = rf(ctx, tx, _a2) + } else { + r0 = ret.Get(0).(user.User) + } + + if rf, ok := ret.Get(1).(func(context.Context, *sqlx.Tx, user.User) error); ok { + r1 = rf(ctx, tx, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Repository_CreateWithTx_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateWithTx' +type Repository_CreateWithTx_Call struct { + *mock.Call +} + +// CreateWithTx is a helper method to define mock.On call +// - ctx context.Context +// - tx *sqlx.Tx +// - _a2 user.User +func (_e *Repository_Expecter) CreateWithTx(ctx interface{}, tx interface{}, _a2 interface{}) *Repository_CreateWithTx_Call { + return &Repository_CreateWithTx_Call{Call: _e.mock.On("CreateWithTx", ctx, tx, _a2)} +} + +func (_c *Repository_CreateWithTx_Call) Run(run func(ctx context.Context, tx *sqlx.Tx, _a2 user.User)) *Repository_CreateWithTx_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*sqlx.Tx), args[2].(user.User)) + }) + return _c +} + +func (_c *Repository_CreateWithTx_Call) Return(_a0 user.User, _a1 error) *Repository_CreateWithTx_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *Repository_CreateWithTx_Call) RunAndReturn(run func(context.Context, *sqlx.Tx, user.User) (user.User, error)) *Repository_CreateWithTx_Call { + _c.Call.Return(run) + return _c +} + // Delete provides a mock function with given fields: ctx, id func (_m *Repository) Delete(ctx context.Context, id string) error { ret := _m.Called(ctx, id) diff --git a/core/user/service.go b/core/user/service.go index 740e60d03a..838ad1ce65 100644 --- a/core/user/service.go +++ b/core/user/service.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/jmoiron/sqlx" "github.com/raystack/salt/rql" "github.com/raystack/frontier/pkg/utils" @@ -82,14 +83,25 @@ func (s Service) GetByEmail(ctx context.Context, email string) (User, error) { } func (s Service) Create(ctx context.Context, user User) (User, error) { - return s.repository.Create(ctx, User{ + return s.repository.Create(ctx, toCreate(user)) +} + +// CreateWithTx is Create inside a transaction the caller opened, so the user row +// and the consent record land together or not at all. +func (s Service) CreateWithTx(ctx context.Context, tx *sqlx.Tx, user User) (User, error) { + return s.repository.CreateWithTx(ctx, tx, toCreate(user)) +} + +// toCreate normalises a user the same way for both create paths. +func toCreate(user User) User { + return User{ Name: strings.ToLower(user.Name), Email: strings.ToLower(user.Email), State: Enabled, Avatar: user.Avatar, Title: user.Title, Metadata: user.Metadata, - }) + } } func (s Service) List(ctx context.Context, flt Filter) ([]User, error) { diff --git a/core/user/service_test.go b/core/user/service_test.go index e63b61d915..f2a04ed346 100644 --- a/core/user/service_test.go +++ b/core/user/service_test.go @@ -8,6 +8,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/raystack/frontier/core/auditrecord/models" "github.com/raystack/frontier/core/relation" "github.com/raystack/frontier/core/user" @@ -15,7 +16,9 @@ import ( "github.com/raystack/frontier/internal/bootstrap/schema" pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord" "github.com/raystack/frontier/pkg/str" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) func mockService(t *testing.T) (*mocks.Repository, *mocks.RelationService, *mocks.SessionService, *mocks.AuditRecordRepository) { @@ -1018,3 +1021,54 @@ func TestService_UnSudo(t *testing.T) { }) } } + +// TestService_CreateWithTx pins that the transactional create normalises a user +// exactly as Create does. The two paths share one helper for that reason, and +// this is what keeps them from drifting apart again. +func TestService_CreateWithTx(t *testing.T) { + toCreate := user.User{ + ID: "test-id", + Name: "TEST", + Email: "TEST@email.com", + State: "enable", + Avatar: "abc", + Title: "tesT", + } + normalised := user.User{ + Name: "test", + Email: "test@email.com", + State: user.Enabled, + Avatar: "abc", + Title: "tesT", + } + created := user.User{ + ID: "test-id", + Name: "test", + Email: "test@email.com", + State: user.Enabled, + Avatar: "abc", + Title: "tesT", + } + + t.Run("passes the transaction and the normalised user to the repository", func(t *testing.T) { + repo, relationService, sessionService, auditRecordRepository := mockService(t) + // a nil transaction is enough here: what the repository does with it is + // exercised against a real database in internal/store/postgres. + repo.EXPECT().CreateWithTx(mock.Anything, (*sqlx.Tx)(nil), normalised).Return(created, nil) + + svc := user.NewService(repo, relationService, sessionService, auditRecordRepository) + got, err := svc.CreateWithTx(context.Background(), nil, toCreate) + require.NoError(t, err) + assert.Equal(t, created, got) + }) + + t.Run("surfaces the repository error so the transaction rolls back", func(t *testing.T) { + repo, relationService, sessionService, auditRecordRepository := mockService(t) + repo.EXPECT().CreateWithTx(mock.Anything, (*sqlx.Tx)(nil), normalised). + Return(user.User{}, errors.New("failed to create")) + + svc := user.NewService(repo, relationService, sessionService, auditRecordRepository) + _, err := svc.CreateWithTx(context.Background(), nil, toCreate) + assert.Error(t, err) + }) +} diff --git a/core/user/user.go b/core/user/user.go index fc03f296b2..7146803da1 100644 --- a/core/user/user.go +++ b/core/user/user.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/jmoiron/sqlx" "github.com/raystack/frontier/pkg/metadata" "github.com/raystack/salt/rql" ) @@ -25,6 +26,9 @@ type Repository interface { GetByIDs(ctx context.Context, userIds []string) ([]User, error) GetByName(ctx context.Context, name string) (User, error) Create(ctx context.Context, user User) (User, error) + // CreateWithTx is Create inside a transaction the caller opened, so the row + // can be written together with something outside this domain. + CreateWithTx(ctx context.Context, tx *sqlx.Tx, user User) (User, error) List(ctx context.Context, flt Filter) ([]User, error) UpdateByID(ctx context.Context, toUpdate User) (User, error) UpdateByName(ctx context.Context, toUpdate User) (User, error) diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go index 645bec8224..eb3dadea6d 100644 --- a/internal/store/postgres/postgres.go +++ b/internal/store/postgres/postgres.go @@ -56,6 +56,7 @@ const ( TABLE_WEBHOOK_ENDPOINTS = "webhook_endpoints" TABLE_PROSPECTS = "prospects" TABLE_USER_PATS = "user_pats" + TABLE_USER_CONSENTS = "user_consents" ) func checkPostgresError(err error) error { diff --git a/internal/store/postgres/user_consent.go b/internal/store/postgres/user_consent.go new file mode 100644 index 0000000000..0eecfae323 --- /dev/null +++ b/internal/store/postgres/user_consent.go @@ -0,0 +1,77 @@ +package postgres + +import ( + "database/sql" + "encoding/json" + "time" + + "github.com/raystack/frontier/core/consent" +) + +// UserConsent is a row of user_consents: immutable, so no updated_at and no +// deleted_at, and it outlives the user, so no foreign key back to users. +type UserConsent struct { + ID string `db:"id" goqu:"skipinsert"` + UserID string `db:"user_id"` + UserEmail string `db:"user_email"` + Documents []byte `db:"documents"` + Source string `db:"source"` + AuthStrategy sql.NullString `db:"auth_strategy"` + IPAddress sql.NullString `db:"ip_address"` + ConsentedAt time.Time `db:"consented_at"` + CreatedAt time.Time `db:"created_at" goqu:"skipinsert"` +} + +// ConsentDocument is one entry in the documents JSONB array, copied from config +// at write time so a record stays correct after the document leaves config. +type ConsentDocument struct { + ID string `json:"id"` + Title string `json:"title"` + Version string `json:"version"` + URL string `json:"url"` +} + +func (from UserConsent) transformToConsent() (consent.Consent, error) { + var documents []ConsentDocument + if len(from.Documents) > 0 { + if err := json.Unmarshal(from.Documents, &documents); err != nil { + return consent.Consent{}, err + } + } + + transformed := make([]consent.Document, 0, len(documents)) + for _, document := range documents { + transformed = append(transformed, consent.Document{ + ID: document.ID, + Title: document.Title, + Version: document.Version, + URL: document.URL, + }) + } + + return consent.Consent{ + ID: from.ID, + UserID: from.UserID, + UserEmail: from.UserEmail, + Documents: transformed, + Source: from.Source, + AuthStrategy: from.AuthStrategy.String, + IPAddress: from.IPAddress.String, + ConsentedAt: from.ConsentedAt, + CreatedAt: from.CreatedAt, + }, nil +} + +// marshalConsentDocuments renders the document list for the JSONB column. +func marshalConsentDocuments(documents []consent.Document) ([]byte, error) { + rows := make([]ConsentDocument, 0, len(documents)) + for _, document := range documents { + rows = append(rows, ConsentDocument{ + ID: document.ID, + Title: document.Title, + Version: document.Version, + URL: document.URL, + }) + } + return json.Marshal(rows) +} diff --git a/internal/store/postgres/user_consent_repository.go b/internal/store/postgres/user_consent_repository.go new file mode 100644 index 0000000000..12d45a2e01 --- /dev/null +++ b/internal/store/postgres/user_consent_repository.go @@ -0,0 +1,75 @@ +package postgres + +import ( + "context" + "fmt" + + "github.com/jmoiron/sqlx" + "github.com/pkg/errors" + + "github.com/raystack/frontier/core/consent" + "github.com/raystack/frontier/pkg/db" +) + +// UserConsentRepository writes consent records and nothing else: the table has +// BEFORE UPDATE and BEFORE DELETE triggers, so there is no other operation to +// offer. Reads are left to reporting tools, which query the table directly. +type UserConsentRepository struct { + dbc *db.Client +} + +func NewUserConsentRepository(dbc *db.Client) *UserConsentRepository { + return &UserConsentRepository{ + dbc: dbc, + } +} + +// Create writes one record inside the transaction it is given rather than +// opening its own, because it has to land with the user row or not at all. +// Rolling back is the caller's job: the transaction is wider than this insert. +func (r UserConsentRepository) Create(ctx context.Context, tx *sqlx.Tx, cnst consent.Consent) (consent.Consent, error) { + // a nil transaction is a wiring mistake, and a panic is a poor way to report it + if tx == nil { + return consent.Consent{}, fmt.Errorf("%w: no transaction", consent.ErrInvalidGrant) + } + + documents, err := marshalConsentDocuments(cnst.Documents) + if err != nil { + return consent.Consent{}, fmt.Errorf("%w: %w", errParse, err) + } + + createQuery, params, err := dialect.Insert(TABLE_USER_CONSENTS).Rows(UserConsent{ + UserID: cnst.UserID, + UserEmail: cnst.UserEmail, + Documents: documents, + Source: cnst.Source, + // both nullable: a deployment that sets no client IP header stores no IP + // rather than failing the signup + AuthStrategy: toNullString(cnst.AuthStrategy), + IPAddress: toNullString(cnst.IPAddress), + ConsentedAt: cnst.ConsentedAt, + }).Returning(&UserConsent{}).ToSQL() + if err != nil { + return consent.Consent{}, fmt.Errorf("%w: %w", errQuery, err) + } + + var consentModel UserConsent + if err = r.dbc.WithTimeout(ctx, TABLE_USER_CONSENTS, "Create", func(ctx context.Context) error { + return tx.QueryRowxContext(ctx, createQuery, params...).StructScan(&consentModel) + }); err != nil { + err = checkPostgresError(err) + switch { + case errors.Is(err, ErrDuplicateKey): + // the partial unique index: a second signup write is a bug + return consent.Consent{}, consent.ErrConsentExists + default: + return consent.Consent{}, fmt.Errorf("%w: %w", errDB, err) + } + } + + transformedConsent, err := consentModel.transformToConsent() + if err != nil { + return consent.Consent{}, fmt.Errorf("%w: %w", errParse, err) + } + return transformedConsent, nil +} diff --git a/internal/store/postgres/user_consent_repository_test.go b/internal/store/postgres/user_consent_repository_test.go new file mode 100644 index 0000000000..31c35b13df --- /dev/null +++ b/internal/store/postgres/user_consent_repository_test.go @@ -0,0 +1,285 @@ +package postgres_test + +import ( + "context" + "database/sql" + "fmt" + "io" + "log/slog" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/ory/dockertest" + "github.com/stretchr/testify/suite" + + "github.com/raystack/frontier/core/consent" + "github.com/raystack/frontier/core/user" + "github.com/raystack/frontier/internal/store/postgres" + "github.com/raystack/frontier/pkg/db" +) + +type UserConsentRepositoryTestSuite struct { + suite.Suite + ctx context.Context + client *db.Client + pool *dockertest.Pool + resource *dockertest.Resource + repository *postgres.UserConsentRepository + userRepository *postgres.UserRepository +} + +func (s *UserConsentRepositoryTestSuite) SetupSuite() { + var err error + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + s.client, s.pool, s.resource, err = newTestClient(logger) + if err != nil { + s.T().Fatal(err) + } + + s.ctx = context.TODO() + s.repository = postgres.NewUserConsentRepository(s.client) + s.userRepository = postgres.NewUserRepository(s.client) +} + +func (s *UserConsentRepositoryTestSuite) TearDownSuite() { + if err := purgeDocker(s.pool, s.resource); err != nil { + s.T().Fatal(err) + } +} + +func (s *UserConsentRepositoryTestSuite) TearDownTest() { + if err := s.cleanup(); err != nil { + s.T().Fatal(err) + } +} + +func (s *UserConsentRepositoryTestSuite) cleanup() error { + // TRUNCATE is not a DELETE, so the row level BEFORE DELETE trigger that + // makes the table immutable does not fire on it. + queries := []string{ + fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", postgres.TABLE_USER_CONSENTS), + fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY CASCADE", postgres.TABLE_USERS), + } + return execQueries(context.TODO(), s.client, queries) +} + +func testDocuments() []consent.Document { + return []consent.Document{ + { + ID: "privacy_policy", + Title: "Privacy Policy", + Version: "2026-04-01", + URL: "https://example.org/legal/privacy/2026-04-01", + }, + { + ID: "terms_of_service", + Title: "Terms & Conditions", + Version: "2026-04-01", + URL: "https://example.org/legal/terms/2026-04-01", + }, + } +} + +func newUser(email string) user.User { + return user.User{ + Name: email, + Email: email, + Title: "Consenting User", + State: user.Enabled, + } +} + +func (s *UserConsentRepositoryTestSuite) countConsents(userID string) int { + var count int + query := fmt.Sprintf("SELECT count(*) FROM %s WHERE user_id = $1", postgres.TABLE_USER_CONSENTS) + s.Require().NoError(s.client.DB.QueryRowxContext(s.ctx, query, userID).Scan(&count)) + return count +} + +// TestCreate covers the write itself: what the record keeps, and the two rules +// the table enforces on it. +func (s *UserConsentRepositoryTestSuite) TestCreate() { + consentedAt := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC) + + s.Run("should write a consent record inside the transaction it is given", func() { + defer func() { s.Require().NoError(s.cleanup()) }() + + createdUser, err := s.userRepository.Create(s.ctx, newUser("complete@example.com")) + s.Require().NoError(err) + + var granted consent.Consent + err = s.client.WithTxn(s.ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error { + var txErr error + granted, txErr = s.repository.Create(s.ctx, tx, consent.Consent{ + UserID: createdUser.ID, + UserEmail: createdUser.Email, + Documents: testDocuments(), + Source: consent.SourceSignup, + AuthStrategy: "mailotp", + IPAddress: "203.0.113.9", + ConsentedAt: consentedAt, + }) + return txErr + }) + s.Require().NoError(err) + + s.Assert().NotEmpty(granted.ID) + s.Assert().Equal(createdUser.ID, granted.UserID) + s.Assert().Equal(createdUser.Email, granted.UserEmail) + s.Assert().Equal(consent.SourceSignup, granted.Source) + s.Assert().Equal("mailotp", granted.AuthStrategy) + s.Assert().Equal("203.0.113.9", granted.IPAddress) + s.Assert().True(consentedAt.Equal(granted.ConsentedAt)) + s.Assert().False(granted.CreatedAt.IsZero()) + // every document keeps all four fields, copied from config at write + // time, so the record stays readable after the document leaves config + s.Assert().Equal(testDocuments(), granted.Documents) + }) + + s.Run("should store a missing strategy and ip as null rather than failing", func() { + defer func() { s.Require().NoError(s.cleanup()) }() + + createdUser, err := s.userRepository.Create(s.ctx, newUser("noip@example.com")) + s.Require().NoError(err) + + var granted consent.Consent + err = s.client.WithTxn(s.ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error { + var txErr error + granted, txErr = s.repository.Create(s.ctx, tx, consent.Consent{ + UserID: createdUser.ID, + UserEmail: createdUser.Email, + Documents: testDocuments(), + Source: consent.SourceSignup, + ConsentedAt: consentedAt, + }) + return txErr + }) + s.Require().NoError(err) + s.Assert().Empty(granted.AuthStrategy) + s.Assert().Empty(granted.IPAddress) + }) + + s.Run("should reject a second signup consent for the same user", func() { + defer func() { s.Require().NoError(s.cleanup()) }() + + createdUser, err := s.userRepository.Create(s.ctx, newUser("twice@example.com")) + s.Require().NoError(err) + + write := func() error { + return s.client.WithTxn(s.ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error { + _, txErr := s.repository.Create(s.ctx, tx, consent.Consent{ + UserID: createdUser.ID, + UserEmail: createdUser.Email, + Documents: testDocuments(), + Source: consent.SourceSignup, + ConsentedAt: consentedAt, + }) + return txErr + }) + } + s.Require().NoError(write()) + + // the partial unique index: nothing repairs a record, so a second + // signup write is a bug and has to fail rather than leave two rows + s.Assert().ErrorIs(write(), consent.ErrConsentExists) + s.Assert().Equal(1, s.countConsents(createdUser.ID)) + }) + + s.Run("should reject a record that names no document", func() { + defer func() { s.Require().NoError(s.cleanup()) }() + + createdUser, err := s.userRepository.Create(s.ctx, newUser("empty@example.com")) + s.Require().NoError(err) + + err = s.client.WithTxn(s.ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error { + _, txErr := s.repository.Create(s.ctx, tx, consent.Consent{ + UserID: createdUser.ID, + UserEmail: createdUser.Email, + Source: consent.SourceSignup, + ConsentedAt: consentedAt, + }) + return txErr + }) + s.Assert().Error(err) + s.Assert().Equal(0, s.countConsents(createdUser.ID)) + }) + + s.Run("should refuse to write without a transaction", func() { + _, err := s.repository.Create(s.ctx, nil, consent.Consent{}) + s.Assert().ErrorIs(err, consent.ErrInvalidGrant) + }) +} + +// TestCreateIsAtomicWithTheUserRow is the invariant this whole feature rests +// on: a user row without a consent record is impossible. It runs against a real +// database on purpose — a mocked transaction can only pretend to roll back. +func (s *UserConsentRepositoryTestSuite) TestCreateIsAtomicWithTheUserRow() { + consentedAt := time.Date(2026, 8, 30, 10, 0, 0, 0, time.UTC) + + s.Run("should keep both rows when both inserts succeed", func() { + defer func() { s.Require().NoError(s.cleanup()) }() + + var createdUser user.User + err := s.client.WithTxn(s.ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error { + var txErr error + createdUser, txErr = s.userRepository.CreateWithTx(s.ctx, tx, newUser("both@example.com")) + if txErr != nil { + return txErr + } + _, txErr = s.repository.Create(s.ctx, tx, consent.Consent{ + UserID: createdUser.ID, + UserEmail: createdUser.Email, + Documents: testDocuments(), + Source: consent.SourceSignup, + AuthStrategy: "mailotp", + ConsentedAt: consentedAt, + }) + return txErr + }) + s.Require().NoError(err) + + fetched, err := s.userRepository.GetByID(s.ctx, createdUser.ID) + s.Require().NoError(err) + s.Assert().Equal("both@example.com", fetched.Email) + s.Assert().Equal(1, s.countConsents(createdUser.ID)) + }) + + s.Run("should roll the user row back when the consent insert fails", func() { + defer func() { s.Require().NoError(s.cleanup()) }() + + var createdUser user.User + err := s.client.WithTxn(s.ctx, sql.TxOptions{}, func(tx *sqlx.Tx) error { + var txErr error + createdUser, txErr = s.userRepository.CreateWithTx(s.ctx, tx, newUser("rollback@example.com")) + if txErr != nil { + return txErr + } + // an empty document list violates documents_not_empty, so this is + // a real failure from Postgres inside a real transaction + _, txErr = s.repository.Create(s.ctx, tx, consent.Consent{ + UserID: createdUser.ID, + UserEmail: createdUser.Email, + Source: consent.SourceSignup, + ConsentedAt: consentedAt, + }) + return txErr + }) + s.Require().Error(err) + s.Require().NotEmpty(createdUser.ID) + + _, err = s.userRepository.GetByID(s.ctx, createdUser.ID) + s.Assert().ErrorIs(err, user.ErrNotExist) + s.Assert().Equal(0, s.countConsents(createdUser.ID)) + + var count int + query := fmt.Sprintf("SELECT count(*) FROM %s WHERE email = $1", postgres.TABLE_USERS) + s.Require().NoError(s.client.DB.QueryRowxContext(s.ctx, query, "rollback@example.com").Scan(&count)) + s.Assert().Zero(count) + }) +} + +func TestUserConsentRepository(t *testing.T) { + suite.Run(t, new(UserConsentRepositoryTestSuite)) +} diff --git a/internal/store/postgres/user_repository.go b/internal/store/postgres/user_repository.go index cc1660d08e..d4fc5bd2b3 100644 --- a/internal/store/postgres/user_repository.go +++ b/internal/store/postgres/user_repository.go @@ -110,11 +110,8 @@ func (r UserRepository) GetByName(ctx context.Context, name string) (user.User, return transformedUser, nil } -func (r UserRepository) Create(ctx context.Context, usr user.User) (user.User, error) { - if strings.TrimSpace(usr.Email) == "" || strings.TrimSpace(usr.Name) == "" { - return user.User{}, user.ErrInvalidDetails - } - +// buildUserInsertQuery is shared by both create paths so they cannot drift. +func buildUserInsertQuery(usr user.User) (string, []any, error) { insertRow := goqu.Record{ "name": strings.ToLower(usr.Name), "email": strings.ToLower(usr.Email), @@ -126,14 +123,59 @@ func (r UserRepository) Create(ctx context.Context, usr user.User) (user.User, e if usr.Metadata != nil { marshaledMetadata, err := json.Marshal(usr.Metadata) if err != nil { - return user.User{}, fmt.Errorf("%w: %w", errParse, err) + return "", nil, fmt.Errorf("%w: %w", errParse, err) } insertRow["metadata"] = marshaledMetadata } if usr.State != "" { insertRow["state"] = usr.State } - createQuery, params, err := dialect.Insert(TABLE_USERS).Rows(insertRow).Returning(&User{}).ToSQL() + return dialect.Insert(TABLE_USERS).Rows(insertRow).Returning(&User{}).ToSQL() +} + +// CreateWithTx creates a user inside the transaction it is given, so a signup can +// write the user row and the consent record together. Rolling back is the +// caller's job: the transaction is wider than this insert. +func (r UserRepository) CreateWithTx(ctx context.Context, tx *sqlx.Tx, usr user.User) (user.User, error) { + // a nil transaction is a wiring mistake, and a panic is a poor way to report it + if tx == nil { + return user.User{}, fmt.Errorf("%w: no transaction", errQuery) + } + if strings.TrimSpace(usr.Email) == "" || strings.TrimSpace(usr.Name) == "" { + return user.User{}, user.ErrInvalidDetails + } + + createQuery, params, err := buildUserInsertQuery(usr) + if err != nil { + return user.User{}, fmt.Errorf("%w: %w", errQuery, err) + } + + var userModel User + if err = r.dbc.WithTimeout(ctx, TABLE_USERS, "CreateWithTx", func(ctx context.Context) error { + return tx.QueryRowxContext(ctx, createQuery, params...).StructScan(&userModel) + }); err != nil { + err = checkPostgresError(err) + switch { + case errors.Is(err, ErrDuplicateKey): + return user.User{}, user.ErrConflict + default: + return user.User{}, err + } + } + + transformedUser, err := userModel.transformToUser() + if err != nil { + return user.User{}, fmt.Errorf("%w: %w", errParse, err) + } + return transformedUser, nil +} + +func (r UserRepository) Create(ctx context.Context, usr user.User) (user.User, error) { + if strings.TrimSpace(usr.Email) == "" || strings.TrimSpace(usr.Name) == "" { + return user.User{}, user.ErrInvalidDetails + } + + createQuery, params, err := buildUserInsertQuery(usr) if err != nil { return user.User{}, fmt.Errorf("%w: %w", errQuery, err) } diff --git a/pkg/auditrecord/consts.go b/pkg/auditrecord/consts.go index cb2078a16f..db630dabbc 100644 --- a/pkg/auditrecord/consts.go +++ b/pkg/auditrecord/consts.go @@ -76,6 +76,9 @@ const ( // Resource Events ResourceCreatedEvent Event = "resource.created" + // User Events + UserConsentGrantedEvent Event = "user.consent_granted" + // PAT Events PATCreatedEvent Event = "pat.created" PATUpdatedEvent Event = "pat.updated" @@ -102,6 +105,7 @@ const ( BillingTransactionType EntityType = "billing_transaction" SessionType EntityType = "session" PATType EntityType = "pat" + ConsentType EntityType = "consent" PlatformType EntityType = "platform" )