ADR-001: Invitation-Based User Registration Architecture
Status: Accepted
Context
During implementation of the User Management Service, we encountered AWS Cognito credential issues when attempting to create and update users programmatically via the AWS SDK. The original design had the backend service using AWS admin APIs (AdminCreateUser, AdminUpdateUserAttributes) to manage user accounts in Cognito directly.
Problems with Original Approach
- Authentication Complexity: Backend service required AWS IAM credentials with Cognito admin permissions
- Local Development Issues: Developers needed AWS credentials configured locally, creating friction
- Security Concerns: Service required elevated IAM permissions beyond whatβs needed for JWT validation
- API Test Failures: Bruno API contract tests failed due to missing AWS credentials (0/14 tests passing)
- Architectural Mismatch: Mixing authentication (Cognitoβs job) with user data management (serviceβs job)
Key Constraint
AWS Cognito User Pool APIs (AdminCreateUser, AdminUpdateUserAttributes) require IAM credentials and cannot be called with user JWT tokens, even for users in privileged groups like βorganizerβ.
Decision
We have decided to implement a database-centric user management architecture with the following principles:
Database as Source of Truth
- PostgreSQL database stores all user profile data (username, email, first name, last name, bio, roles, preferences, settings)
- AWS Cognito is used ONLY for authentication (JWT token generation and validation)
- NO Cognito Groups: Roles stored exclusively in database
role_assignmentstable - Unidirectional sync: Cognito β Database only (via Lambda triggers)
Two User Creation Paths
Path 1: Self-Registration (Story 1.2.3)
βββββββββββββββ Registration ββββββββββββββββββββ
β New User βββββββββββββββββββ>β Cognito SignUp β
β β {email, pw, ...} β β
βββββββββββββββ ββββββββββ¬ββββββββββ
β
v
ββββββββββββββββββββββ
β PostConfirmation β
β Lambda Trigger β
β β
β 1. Create DB user β
β 2. Assign ATTENDEE β
β role (default) β
ββββββββββββββββββββββ
Path 2: Admin Invitation (Future Story)
βββββββββββββββ POST /users ββββββββββββββββββββ
β Organizer ββββββββββββββββββββββ>β User Service β
β (Admin) β {email, name...} β β
βββββββββββββββ β 1. Create DB β
β record β
β 2. cognitoUserIdβ
β = NULL β
ββββββββββ¬ββββββββββ
β
v
ββββββββββββββββββ
β Send Invite β
β Email (TODO) β
ββββββββββ¬ββββββββ
β
βββββββββββββββββββββββββββββββββββββββββ
β
v
βββββββββββββββ Click Link ββββββββββββββββββββ
β New User βββββββββββββββββββ>β Registration Pageβ
β β β (Frontend) β
βββββββββββββββ ββββββββββ¬ββββββββββ
β
v
ββββββββββββββββββββββ
β AWS Cognito β
β (SignUp API) β
β β
β Creates auth β
β account β
ββββββββββ¬ββββββββββββ
β
v
ββββββββββββββββββββββ
β PreTokenGeneration β
β Lambda Hook β
β β
β Links Cognito ID β
β to DB record β
ββββββββββββββββββββββ
Technical Implementation
Self-Registration Path (Story 1.2.3 + 1.2.5)
-
User Model (
User.java):java@Column(name = "cognito_user_id", nullable = false, unique = true) private String cognitoUserId; // Always populated for self-registered users -
PostConfirmation Lambda (Story 1.2.5):
javascriptexports.handler = async (event) => { const { sub, email, given_name, family_name } = event.request.userAttributes; // Create user in database await db.query(` INSERT INTO user_profiles (cognito_user_id, email, first_name, last_name, username) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (cognito_user_id) DO NOTHING `, [sub, email, given_name, family_name, generateUsername(given_name, family_name)]); // Assign default ATTENDEE role const userId = await getUserId(sub); await db.query(` INSERT INTO role_assignments (user_id, role) VALUES ($1, 'ATTENDEE') `, [userId]); return event; }; -
PreTokenGeneration Lambda (Story 1.2.5):
javascriptexports.handler = async (event) => { const cognitoUserId = event.request.userAttributes.sub; // Fetch roles from database const roles = await db.query(` SELECT r.role FROM role_assignments r JOIN user_profiles u ON r.user_id = u.id WHERE u.cognito_user_id = $1 `, [cognitoUserId]); // Add roles to JWT as custom claim (NO Cognito Groups) event.response = { claimsOverrideDetails: { claimsToAddOrOverride: { 'custom:role': roles.map(r => r.role).join(',') } } }; return event; }; -
Spring Security JWT Configuration:
java@Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); converter.setJwtGrantedAuthoritiesConverter(jwt -> { String rolesString = jwt.getClaimAsString("custom:role"); return Arrays.stream(rolesString.split(",")) .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); }); return converter; }
Admin Invitation Path (Future)
-
User Service (
UserService.java):createUser(): Creates database record withcognitoUserId = null(for invited users not yet registered)updateCurrentUser(): Updates database only (no Cognito sync)- No AWS SDK calls for user management
-
Cognito Integration Service (
CognitoIntegrationServiceImpl.java):syncUserAttributes(): No-op (logs debug message)createCognitoUser(): No-op (logs debug message)
-
PreTokenGeneration Lambda (Enhancement):
javascript// Link invited user to Cognito account by email if (user.cognitoUserId === null) { await db.query(` UPDATE user_profiles SET cognito_user_id = $1 WHERE email = $2 `, [cognitoUserId, email]); }
Consequences
Positive
- No AWS Credentials Required: Service works without IAM credentials for local development
- Simplified Security Model: Service only needs database access and JWT validation
- Improved Testability: API tests pass without AWS configuration
- Clear Separation of Concerns:
- Authentication = Cognitoβs responsibility
- User data + roles = Database responsibility
- NO dual storage (Cognito Groups eliminated)
- Better Developer Experience: No AWS credential configuration needed for local development
- Cost Reduction: No Cognito API calls for user/role management
- Single Source of Truth: Database only for user data and roles (no sync drift)
- Self-Service Registration: Users can register directly (Story 1.2.3) without admin intervention
Negative
- Lambda Triggers Required: PostConfirmation and PreTokenGeneration hooks are critical infrastructure
- Database Dependency: JWT generation requires database availability (graceful degradation needed)
- Future Invitation Flow: Will require additional implementation for admin-invited users
- Migration Complexity: Need to handle both self-registered (cognitoUserId populated) and invited (cognitoUserId=null) users
Neutral
- User Attributes in JWT: Custom claims (roles) added to JWT via PreTokenGeneration Lambda
- Role Management: Roles in database only, synced to JWT at login time (not stored in Cognito Groups)
- Two Registration Paths: Self-registration (immediate) and invitation-based (future) supported
Implementation Status
Completed (Story 1.14-2)
- β
Database schema:
user_profilestable withcognito_user_id NOT NULL - β
Database schema:
role_assignmentstable for user roles - β User Service: Database-only user management (no Cognito SDK calls)
- β
Event model supports
cognitoUserIdfield - β API tests passing (POST /users returns 201 Created)
In Progress (Story 1.2.5)
- β³ PostConfirmation Lambda: Create user in database on Cognito signup
- β³ PreTokenGeneration Lambda: Add
custom:roleclaim from database - β³ Spring Security: JWT converter to extract roles from
custom:roleclaim
Planned (Story 1.2.3)
- β³ Frontend registration wizard (Step 1: credentials, Step 2: review)
- β³ Cognito SignUp integration (frontend β Cognito direct)
- β³ Email verification flow
Future (Invitation System)
- β³ Invitation email system (token generation, email template)
- β³ Registration page with invitation token validation
- β³ PreTokenGeneration enhancement: Link invited users by email
Alternatives Considered
Alternative 1: Use Userβs JWT Token for Cognito APIs
Rejected: Cognito Admin APIs donβt accept user tokens, even for privileged users. Only IAM credentials work.
Alternative 2: Service Account with IAM Credentials
Rejected: Adds complexity, requires credential management, violates principle of least privilege, breaks local dev workflow.
Alternative 3: Cognito as Source of Truth
Rejected:
- Forces all user data queries to go through Cognito API
- Poor query performance for complex filtering
- Limited data model (Cognito attributes are flat key-value)
- Expensive at scale (Cognito charges per API call)
Alternative 4: Dual-Write (Sync Database β Cognito)
Rejected:
- Adds complexity with bidirectional synchronization
- Consistency issues between two systems
- Still requires IAM credentials for Cognito writes
- Doesnβt solve the core credential problem
References
- AWS Cognito Admin API Documentation
- Cognito Lambda Triggers
- Story 1.14-2: User Management Service Foundation
- Story 1.16.2: Dual-identifier pattern (UUID internal, username public)
Related Files
Story 1.14-2 (User Management Service):
services/company-user-management-service/src/main/java/ch/batbern/companyuser/domain/User.java:51-cognitoUserIdfieldservices/company-user-management-service/src/main/resources/db/migration/V4__create_user_profiles_table.sql- Database schemaservices/company-user-management-service/src/main/resources/db/migration/V5__create_role_assignments_table.sql- Roles schemaservices/company-user-management-service/src/main/java/ch/batbern/companyuser/service/UserService.java- Database-only user management
Story 1.2.5 (User Sync):
infrastructure/lib/lambda/triggers/post-confirmation.ts- Creates DB user on signupinfrastructure/lib/lambda/triggers/pre-token-generation.ts- Adds roles to JWTdocs/stories/1.2.5-user-sync-reconciliation-implementation.md- Implementation story
Story 1.2.3 (Account Creation):
docs/stories/1.2.3-implement-account-creation-flow.md- Registration UI story
Notes
This decision represents a shift from a Cognito-centric to a database-centric user management architecture.
Key Architectural Principles
- Database as Single Source of Truth: All user data and roles stored in PostgreSQL
- Cognito for Authentication Only: JWT generation and validation only
- NO Cognito Groups: Roles managed exclusively via
role_assignmentstable - Unidirectional Sync: Cognito β Database via Lambda triggers (PostConfirmation, PreTokenGeneration)
- Two Registration Paths:
- Self-registration (Story 1.2.3): User signs up β Cognito β Lambda creates DB record
- Admin invitation (Future): Admin creates DB record β User completes Cognito signup β Lambda links via email
Benefits
- Simplified implementation: No AWS SDK credentials needed for local development
- Better testability: API tests pass without AWS configuration
- No sync drift: Single source of truth eliminates consistency issues
- Scalable queries: Complex user queries in PostgreSQL, not limited by Cognito API
- Cost-effective: No Cognito API calls for user/role management
The database-centric approach provides a solid foundation for complex user management while keeping authentication concerns properly separated in Cognito.