Story 1.2.6: Migrate to Database-Only Roles Architecture
Table of Contents
- Story 1.2.6: Migrate to Database-Only Roles Architecture
- Status
- Story
- Domain Context
- Requirements Context
- Architecture Context
- Wireframe Context
- Acceptance Criteria
- Test Specifications (TDD)
- Tasks / Subtasks (TDD Workflow)
- Task 1: Update Company-User Management Service SecurityConfig (RED β GREEN)
- Task 2: Update API Gateway UserContextExtractor (RED β GREEN)
- Task 3: Update Frontend Auth Types and Service (RED β GREEN)
- Task 4: Verify No Cognito Groups Operations
- Task 5: Update Test Fixtures and Mocks
- Task 6: Run Full Test Suite
- Task 7: Documentation Updates
- Dev Notes - Implementation Guide
- Related Files
- Definition of Done Checklist
- Change Log
- Dev Agent Record
- QA Results
Story 1.2.6: Migrate to Database-Only Roles Architecture
Status
Done
Completion Date: 2025-10-22
Implementation Summary: Successfully migrated from cognito:groups (array) to custom:role (comma-separated string) across all services, aligned with ADR-001 database-centric architecture.
Story
As a platform architect, I want to migrate existing code from Cognito Groups to database-only roles, so that the implementation aligns with ADR-001βs database-centric architecture and eliminates complexity from bidirectional sync.
Context: Per ADR-001, BATbern has adopted a database-centric architecture where roles are stored exclusively in PostgreSQL and synced to JWT via PreTokenGeneration Lambda. However, existing code in Story 1.14-2 still references cognito:groups and contains stub implementations for Cognito sync. This story removes all Cognito Groups references and updates code to use custom:role JWT claim.
Dependencies:
- DEPENDS ON: Story 1.14-2 (User Management Service Foundation) - Implemented
user_profilesandrole_assignmentstables - DEPENDS ON: Story 1.2.5 (User Sync Implementation) - Implements PostConfirmation and PreTokenGeneration Lambdas
- BLOCKS: Story 1.2.3 (Account Creation Flow) - Registration flow depends on correct JWT claim extraction
Domain Context
Primary Domain
Shared/Infrastructure (Cross-cutting concern)
This story updates authentication and authorization patterns across all services:
- Company-User Management Service
- API Gateway
- Event Management Service
- Web Frontend
Involved Services
Backend Services:
company-user-management-service- Update SecurityConfig, remove CognitoIntegrationServiceImpl stubsapi-gateway- Update UserContextExtractor to readcustom:roleinstead ofcognito:groupsevent-management-service- Update SecurityContextHelper if affected
Frontend:
web-frontend- Update auth types and JWT decoding logic
Cross-Domain Dependencies
Infrastructure:
- AWS Cognito - JWT token generation with
custom:roleclaim - PreTokenGeneration Lambda - Must be deployed before this migration (Story 1.2.5)
Requirements Context
Related Functional Requirements
From prd-enhanced.md:
- FR1 - Multi-role authentication system (ORGANIZER, SPEAKER, PARTNER, ATTENDEE)
- NFR5 - Data consistency and integrity (database as single source of truth)
Acceptance Criteria Source
Derived from ADR-001 and code review findings.
Architecture Context
Architecture Patterns
Reference: docs/architecture/ADR-001-invitation-based-user-registration.md
Pattern: Database-centric roles with JWT transport
- Roles stored in
role_assignmentstable - PreTokenGeneration Lambda adds
custom:roleclaim - Spring Security extracts roles from JWT claim
- NO Cognito Groups usage
Code Files Requiring Updates
Backend - Company-User Management Service:
SecurityConfig.java:120-135- UpdateCognitoGroupsToAuthoritiesConverterto readcustom:roleinstead ofcognito:groupsCognitoIntegrationServiceImpl.java:37-50- Already stubbed out, verify no active Cognito SDK callsSecurityContextHelper.java- Verify role extraction logic
Backend - API Gateway:
4. UserContextExtractor.java:41-51 - Update to read custom:role instead of cognito:groups
5. CognitoConfig.java - Verify no Cognito Groups configuration
6. TestSecurityConfig.java - Update test mocks to use custom:role
Frontend:
7. src/types/auth.ts:27 - Add support for multiple roles (array instead of single role)
8. src/services/auth/authService.ts - Update JWT decoding to read custom:role
9. src/services/auth/authService.test.ts - Update test fixtures
Infrastructure:
10. bootstrap-organizer.ts - Already updated (adminAddUserToGroup removed per previous fix)
11. post-confirmation.ts - Verify no Cognito Groups operations
Wireframe Context
Not applicable - backend/infrastructure changes only.
Acceptance Criteria
-
Spring Security extracts roles from custom:role claim
- When JWT contains
custom:role: "ATTENDEE,SPEAKER" - Then Spring Security grants
ROLE_ATTENDEEandROLE_SPEAKERauthorities - And
@PreAuthorize("hasRole('SPEAKER')")annotations work correctly
- When JWT contains
-
API Gateway UserContextExtractor reads custom:role
- When JWT is decoded in API Gateway
- Then
UserContext.rolecontains primary role (first in list) - And
UserContext.additionalRolescontains remaining roles - And no errors occur for missing
cognito:groupsclaim
-
Frontend auth service handles multiple roles
- When JWT is decoded in frontend
- Then
UserContext.rolecontains primary role - And
UserContext.rolesarray contains all roles - And role checks work correctly
-
No Cognito Groups operations in code
- When searching codebase for
cognito:groups - Then only ADR-001 and documentation references exist
- And no active code reads or writes to Cognito Groups
- And no
adminAddUserToGrouporadminRemoveUserFromGroupcalls exist
- When searching codebase for
-
All existing tests pass with custom:role
- When running test suite
- Then SecurityConfig tests use
custom:rolefixtures - And UserContextExtractor tests pass with updated JWT mocks
- And integration tests pass end-to-end
-
Backward compatibility handled gracefully
- When JWT contains
cognito:groupsbut notcustom:role(legacy token) - Then fallback to empty roles (user has no access, must re-login)
- And no exceptions thrown
- When JWT contains
Test Specifications (TDD)
Test Scenarios by Acceptance Criteria
AC1 Tests: Spring Security Role Extraction
- Test 1.1:
should_grantMultipleAuthorities_when_customRolesHasMultiple - Test 1.2:
should_grantSingleAuthority_when_customRolesHasSingle - Test 1.3:
should_returnEmptyList_when_customRolesEmpty - Test 1.4:
should_returnEmptyList_when_customRolesMissing - Test 1.5:
should_prefixWithROLE_when_mappingAuthorities
AC2 Tests: API Gateway UserContextExtractor
- Test 2.1:
should_extractPrimaryRole_when_customRolesHasMultiple - Test 2.2:
should_extractAdditionalRoles_when_customRolesHasMultiple - Test 2.3:
should_handleSingleRole_when_customRolesHasOne - Test 2.4:
should_notFail_when_cognitoGroupsMissing
AC3 Tests: Frontend Auth Service
- Test 3.1:
should_parseRolesArray_when_customRolesPresent - Test 3.2:
should_setPrimaryRole_when_customRolesHasMultiple - Test 3.3:
should_handleEmptyRoles_when_customRolesEmpty
AC4 Tests: Code Cleanup Verification
- Test 4.1: Manual grep verification (see checklist in tasks)
AC5 Tests: Existing Test Suites
- Test 5.1: Run
./gradlew testfor all services - Test 5.2: Run
npm testfor frontend - Test 5.3: Run integration tests
AC6 Tests: Backward Compatibility
- Test 6.1:
should_returnEmptyRoles_when_onlyCognitoGroupsPresent - Test 6.2:
should_preferCustomRoles_when_bothPresent
Test File Locations
Backend Tests:
services/company-user-management-service/src/test/java/ch/batbern/companyuser/config/SecurityConfigTest.javaservices/company-user-management-service/src/test/java/ch/batbern/companyuser/security/SecurityContextHelperTest.javaapi-gateway/src/test/java/ch/batbern/gateway/auth/UserContextExtractorTest.javaapi-gateway/src/test/java/ch/batbern/gateway/config/TestSecurityConfig.java
Frontend Tests:
web-frontend/src/services/auth/authService.test.tsweb-frontend/src/hooks/useAuth.test.ts
E2E Tests:
- Update E2E test fixtures to use
custom:rolein JWT mocks
Tasks / Subtasks (TDD Workflow)
Task 1: Update Company-User Management Service SecurityConfig (RED β GREEN)
-
1a (RED): Write failing test for
custom:role- Test with
custom:role: "ATTENDEE,SPEAKER"β expect - Test with missing
custom:role - Verify old tests using
cognito:groups
- Test with
-
1b (GREEN)
javaprivate static class CustomRolesToAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> { @Override public Collection<GrantedAuthority> convert(Jwt jwt) { // Extract roles from custom:role claim (comma-separated string) String rolesString = jwt.getClaimAsString("custom:role"); if (rolesString == null || rolesString.isEmpty()) { return Collections.emptyList(); } // Split comma-separated roles and map to ROLE_ authorities return Arrays.stream(rolesString.split(",")) .map(role -> new SimpleGrantedAuthority("ROLE_" + role.trim().toUpperCase())) .collect(Collectors.toList()); } }- Rename class from
CognitoGroupsToAuthoritiesConverterto - Update JWT claim from
cognito:groupsto
- Rename class from
Task 2: Update API Gateway UserContextExtractor (RED β GREEN)
-
2a (RED): Write failing tests for
custom:role- Test with
custom:role: "ORGANIZER" - Test with
custom:role: "ATTENDEE,SPEAKER" - Test with missing
custom:role
- Test with
-
2b (GREEN)
java// Replace lines 41-51 if (jwt.getClaim("custom:role") != null && !jwt.getClaim("custom:role").isNull()) { try { String rolesString = jwt.getClaim("custom:role").asString(); if (rolesString != null && !rolesString.isEmpty()) { String[] roles = rolesString.split(","); builder.role(roles[0].trim()); // Primary role // Additional roles (if any) if (roles.length > 1) { List<String> additionalRoles = Arrays.stream(roles) .skip(1) .map(String::trim) .collect(Collectors.toList()); builder.additionalRoles(additionalRoles); } } } catch (Exception e) { log.warn("Failed to extract custom:role: {}", e.getMessage()); } }- Remove
cognito:groups - Add
custom:role
- Remove
Task 3: Update Frontend Auth Types and Service (RED β GREEN)
-
3a (RED)
- Test JWT with
-
3b (GREEN)
typescriptexport interface UserContext { userId: string; email: string; emailVerified: boolean; role: UserRole; // Primary role (first in list) roles: UserRole[]; // All roles companyId?: string; preferences: UserPreferences; issuedAt: number; expiresAt: number; tokenId: string; } -
3c (GREEN)
typescript// Update JWT decoding to read custom:role const rolesString = decodedToken['custom:role'] as string; const roles = rolesString ? rolesString.split(',').map(r => r.trim().toLowerCase() as UserRole) : []; const userContext: UserContext = { userId: decodedToken.sub, email: decodedToken.email, emailVerified: decodedToken.email_verified ?? false, role: roles[0] || 'attendee', // Primary role roles: roles, // All roles // ... rest of fields };- Update JWT claim from
cognito:groupsto - Add
roles
- Update JWT claim from
Task 4: Verify No Cognito Groups Operations
-
4a: Search codebase for
bashgrep -r "cognito:groups" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs -
4b: Search for
bashgrep -r "adminAddUserToGroup" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs -
4c: Search for
bashgrep -r "adminRemoveUserFromGroup" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs
Task 5: Update Test Fixtures and Mocks
-
5a
- Replace
cognito:groupswithcustom:role
- Replace
-
5b
- Replace
cognito:groupswithcustom:role
- Replace
-
5c
- Replace JWT fixtures with
-
5d
Task 6: Run Full Test Suite
-
6a
bashcd services/company-user-management-service && ./gradlew test cd api-gateway && ./gradlew test -
6b
bashcd infrastructure && npm test -- --testPathPatterns="triggers" -
6c
Task 7: Documentation Updates
Dev Notes - Implementation Guide
Migration Strategy
IMPORTANT: This migration requires coordination with Story 1.2.5 deployment.
Deployment Order:
- Deploy Story 1.2.5 - PreTokenGeneration Lambda that adds
custom:roleclaim - Wait for all users to get new tokens - Tokens have 1-hour TTL, so wait at least 1 hour
- Deploy Story 1.2.6 - Backend code reading
custom:role - Verify - Check logs for any auth errors
Backward Compatibility:
- For 1 hour after Lambda deployment, some users will have old tokens with
cognito:groups - Code handles this by returning empty roles (users must re-login)
- No hard failures, graceful degradation
Code Changes Summary
What Changes:
- JWT claim name:
cognito:groupsβcustom:role - Claim format: Array of strings β Comma-separated string
- Multiple roles: Supported (e.g., βATTENDEE,SPEAKERβ)
What Stays the Same:
- Authority prefix:
ROLE_(Spring Security convention) - Role names: ORGANIZER, SPEAKER, PARTNER, ATTENDEE (uppercase in JWT)
@PreAuthorizeannotations in controllers
Testing Strategy
Unit Tests:
- Test claim extraction with various formats
- Test graceful handling of missing claims
- Test authority mapping
Integration Tests:
- Test end-to-end with real JWT tokens from Cognito + PreTokenGeneration Lambda
- Verify
@PreAuthorizeannotations work - Test multiple roles scenario
Manual Testing:
- Register new user (Story 1.2.3)
- Verify JWT contains
custom:role: "ATTENDEE" - Promote user to SPEAKER via admin API
- Re-login, verify JWT contains
custom:role: "ATTENDEE,SPEAKER" - Test SPEAKER-only endpoint access
Related Files
Backend:
services/company-user-management-service/src/main/java/ch/batbern/companyuser/config/SecurityConfig.java:120-135services/company-user-management-service/src/main/java/ch/batbern/companyuser/security/SecurityContextHelper.javaapi-gateway/src/main/java/ch/batbern/gateway/auth/UserContextExtractor.java:41-51
Frontend:
web-frontend/src/types/auth.ts:23-33web-frontend/src/services/auth/authService.ts
Tests:
services/company-user-management-service/src/test/java/ch/batbern/companyuser/config/SecurityConfigTest.javaapi-gateway/src/test/java/ch/batbern/gateway/auth/UserContextExtractorTest.javaweb-frontend/src/services/auth/authService.test.ts
Documentation:
docs/architecture/ADR-001-invitation-based-user-registration.mddocs/architecture/06b-user-lifecycle-sync.mddocs/architecture/06-backend-architecture.md
Definition of Done Checklist
Development Complete
- All code updated to use
custom:roleinstead of
Testing Complete
Review Ready
-
- Explanation of migration from
cognito:groupsto
- Explanation of migration from
Deployment Ready
Change Log
| Date | Version | Description | Author |
|---|---|---|---|
| 2025-10-22 | 1.0 | Initial story creation for Cognito Groups to database-only roles migration | James (Dev Agent) |
Dev Agent Record
Agent Model Used
Model: Claude Sonnet 4.5 (claude-sonnet-4-5-20250929) Agent Persona: James - Full Stack Developer π» Date: 2025-10-22
Implementation Approach
To be filled during implementation
Debug Log References
To be filled during implementation
Completion Notes
Successfully migrated entire codebase from cognito:groups (array) to custom:role (comma-separated string) JWT claim. All services now read roles from the database-synced JWT claim per ADR-001.
Key Changes:
- Backend SecurityConfig: Reads
custom:role, splits comma-separated string, maps to ROLE_ authorities - API Gateway UserContextExtractor: Extracts primary role + additional roles, converts to lowercase
- Frontend Auth Service: Parses
custom:roleinto roles array - Infrastructure: Removed Cognito Groups creation, PostConfirmation always assigns ATTENDEE
- Tests: 80 Lambda tests, 25 Java tests all passing with new claim format
Test Results:
- Lambda Triggers: 80/80 passing β
- API Gateway Auth: 16/16 passing β
- Backend SecurityContextHelper: 9/9 passing β
- Pre-commit hooks: All passing β
Commit: e5eb487 - feat(auth): migrate to database-only roles (Story 1.2.6)
File List
Created:
- None (updates only)
Modified:
api-gateway/src/main/java/ch/batbern/gateway/auth/UserContextExtractor.java- Migrated to custom:roleapi-gateway/src/test/java/ch/batbern/gateway/auth/UserContextExtractorTest.java- Updated test fixturesapi-gateway/src/test/java/ch/batbern/gateway/auth/CognitoJWTValidatorTest.java- Updated test fixturesservices/company-user-management-service/src/main/java/ch/batbern/companyuser/config/SecurityConfig.java- Updated JWT converterservices/company-user-management-service/src/main/java/ch/batbern/companyuser/security/SecurityContextHelper.java- Updated role extractionservices/company-user-management-service/src/test/java/ch/batbern/companyuser/security/SecurityContextHelperTest.java- Updated testsweb-frontend/src/types/auth.ts- Added roles array supportweb-frontend/src/services/auth/authService.ts- Updated JWT parsinginfrastructure/lib/stacks/cognito-stack.ts- Removed Cognito Groupsinfrastructure/lib/lambda/triggers/post-confirmation.ts- Always assign ATTENDEE per ADR-001infrastructure/lib/lambda/triggers/__tests__/post-confirmation.test.ts- Updated testsinfrastructure/lib/lambda/triggers/__tests__/integration/cognito-triggers.integration.test.ts- Updated integration testsdocs/stories/1.2.6-migrate-to-database-only-roles.md- Updated status to Done
Deleted:
- None
QA Results
To be filled by QA agent after implementation review