Skip to content

Commit fa677e9

Browse files
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. Its target metadata carries the whole document snapshot rather than the id and the version alone, so a reader working from the audit trail can say what was accepted without reading back a record they may not have access to. 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.
1 parent 2ef789b commit fa677e9

21 files changed

Lines changed: 1756 additions & 65 deletions

cmd/serve.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,6 @@ func buildAPIDependencies(
349349
if err := cfg.App.Consent.Validate(); err != nil {
350350
return api.Deps{}, err
351351
}
352-
consentService := consent.NewService(cfg.App.Consent)
353-
logConsentDocuments(logger, consentService.Documents())
354352

355353
var tokenKeySet jwk.Set
356354
if len(cfg.App.Authentication.Token.RSAPath) > 0 {
@@ -399,6 +397,10 @@ func buildAPIDependencies(
399397

400398
auditRecordRepository := postgres.NewAuditRecordRepository(dbc)
401399

400+
consentService := consent.NewService(logger, cfg.App.Consent,
401+
postgres.NewUserConsentRepository(dbc), auditRecordRepository)
402+
logConsentDocuments(logger, consentService.Documents())
403+
402404
roleRepository := postgres.NewRoleRepository(dbc)
403405
policyPGRepository := postgres.NewPolicyRepository(dbc)
404406
userRepository := postgres.NewUserRepository(dbc)
@@ -450,7 +452,8 @@ func buildAPIDependencies(
450452
userService := user.NewService(userRepository, relationService, sessionService, auditRecordRepository)
451453
patValidator := userpat.NewValidator(logger, userPATRepo, cfg.App.PAT)
452454
authnService := authenticate.NewService(logger, cfg.App.Authentication,
453-
postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator)
455+
postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator,
456+
consentService, dbc)
454457
groupService := group.NewService(groupRepository, relationService, authnService, policyService)
455458
organizationService := organization.NewService(organizationRepository, relationService, userService,
456459
authnService, policyService, preferenceService, roleService)

core/authenticate/mocks/consent_service.go

Lines changed: 189 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/authenticate/mocks/transactor.go

Lines changed: 88 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/authenticate/mocks/user_service.go

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)