From c396eaa320c1e832a5361548ea8f144190ec3b6a Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Sun, 30 Aug 2026 20:26:30 +0530 Subject: [PATCH] feat(consent): record consent in the same transaction as the user The invariant this establishes: a user row without a consent record is impossible. ResolveAll runs before the transaction opens, so an incomplete payload never starts one; inside it, the user insert and the consent insert both land or neither does. user.Repository and the new user_consents repository each gain a Create that takes a *sqlx.Tx. pkg/db has WithTxn but carries no transaction on the context, so the transaction is threaded through explicitly rather than found on one. Both are additive, and the user repository change is the one place this feature reaches outside its own domain. The consent repository has Create and nothing else, because the table is immutable. consent.Grant writes one record for the documents it is given and has no completeness rule of its own. ResolveAll is what decides a signup covers every configured document, and keeping that out of Grant leaves room for a later re-consent covering a subset without a second write path. getOrCreateUser now has three outcomes for a new user. A complete payload writes both rows in one transaction. An incomplete one returns ErrConsentRequired and writes nothing. An existing user gets no record at all, which is absolute: a record written outside a user creation would carry that moment's timestamp and IP for an agreement made elsewhere, which is worse than no record because it reads like evidence. A nil flow means one of the paths that create a user without one, and those stay exempt because no account holder is present to consent. The completeness check runs at user creation under every intent, not for the error but as the invariant guarding the write. An unset intent is permissive for the login gate but never for consent. With app.consent disabled ResolveAll resolves nothing, and an empty document set means write no record, so nothing changes for a deployment that does not ask for consent. Each signup also writes one audit record, with UserConsentGrantedEvent and ConsentType added to pkg/auditrecord following the entity.verb naming already there. It goes through the repository with the actor filled in, as userpat does for its PAT events: the repository enriches an empty actor from the context, and these endpoints are on the authentication skip list with no actor in it, so the record would otherwise land as the system actor for an act a person performed. It is written after the commit, since the audit repository has no transactional create, so it cannot be atomic with the record it describes. That is why the consent record is the source of truth and this one is a breadcrumb: a failure is logged and the signup stands. The rollback is tested against a real Postgres rather than a mocked transaction, since a mock can only pretend to roll back. See docs/rfcs/0002-explicit-consent-at-signup.md, Enforcement and Storage. --- cmd/serve.go | 9 +- core/authenticate/mocks/consent_service.go | 189 ++++++++++ core/authenticate/mocks/transactor.go | 88 +++++ core/authenticate/mocks/user_service.go | 60 ++++ core/authenticate/service.go | 111 +++++- core/authenticate/service_test.go | 322 ++++++++++++++++-- core/consent/consent.go | 36 ++ core/consent/errors.go | 8 + core/consent/service.go | 127 ++++++- core/consent/service_test.go | 215 +++++++++++- core/user/mocks/repository.go | 60 ++++ core/user/service.go | 16 +- core/user/service_test.go | 54 +++ core/user/user.go | 4 + internal/store/postgres/postgres.go | 1 + internal/store/postgres/user_consent.go | 77 +++++ .../store/postgres/user_consent_repository.go | 75 ++++ .../postgres/user_consent_repository_test.go | 285 ++++++++++++++++ internal/store/postgres/user_repository.go | 56 ++- pkg/auditrecord/consts.go | 4 + 20 files changed, 1733 insertions(+), 64 deletions(-) create mode 100644 core/authenticate/mocks/consent_service.go create mode 100644 core/authenticate/mocks/transactor.go create mode 100644 internal/store/postgres/user_consent.go create mode 100644 internal/store/postgres/user_consent_repository.go create mode 100644 internal/store/postgres/user_consent_repository_test.go 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" )