Story 1.2.6: Migrate to Database-Only Roles Architecture

Last updated: November 23, 2025 Reading time: 13 minutes File size: 20.93 KB

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_profiles and role_assignments tables
  • 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 stubs
  • api-gateway - Update UserContextExtractor to read custom:role instead of cognito:groups
  • event-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:role claim
  • PreTokenGeneration Lambda - Must be deployed before this migration (Story 1.2.5)

Requirements Context

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_assignments table
  • PreTokenGeneration Lambda adds custom:role claim
  • Spring Security extracts roles from JWT claim
  • NO Cognito Groups usage

Code Files Requiring Updates

Backend - Company-User Management Service:

  1. SecurityConfig.java:120-135 - Update CognitoGroupsToAuthoritiesConverter to read custom:role instead of cognito:groups
  2. CognitoIntegrationServiceImpl.java:37-50 - Already stubbed out, verify no active Cognito SDK calls
  3. SecurityContextHelper.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

  1. Spring Security extracts roles from custom:role claim

    • When JWT contains custom:role: "ATTENDEE,SPEAKER"
    • Then Spring Security grants ROLE_ATTENDEE and ROLE_SPEAKER authorities
    • And @PreAuthorize("hasRole('SPEAKER')") annotations work correctly
  2. API Gateway UserContextExtractor reads custom:role

    • When JWT is decoded in API Gateway
    • Then UserContext.role contains primary role (first in list)
    • And UserContext.additionalRoles contains remaining roles
    • And no errors occur for missing cognito:groups claim
  3. Frontend auth service handles multiple roles

    • When JWT is decoded in frontend
    • Then UserContext.role contains primary role
    • And UserContext.roles array contains all roles
    • And role checks work correctly
  4. 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 adminAddUserToGroup or adminRemoveUserFromGroup calls exist
  5. All existing tests pass with custom:role

    • When running test suite
    • Then SecurityConfig tests use custom:role fixtures
    • And UserContextExtractor tests pass with updated JWT mocks
    • And integration tests pass end-to-end
  6. Backward compatibility handled gracefully

    • When JWT contains cognito:groups but not custom:role (legacy token)
    • Then fallback to empty roles (user has no access, must re-login)
    • And no exceptions thrown

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 test for all services
  • Test 5.2: Run npm test for 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.java
  • services/company-user-management-service/src/test/java/ch/batbern/companyuser/security/SecurityContextHelperTest.java
  • api-gateway/src/test/java/ch/batbern/gateway/auth/UserContextExtractorTest.java
  • api-gateway/src/test/java/ch/batbern/gateway/config/TestSecurityConfig.java

Frontend Tests:

  • web-frontend/src/services/auth/authService.test.ts
  • web-frontend/src/hooks/useAuth.test.ts

E2E Tests:

  • Update E2E test fixtures to use custom:role in 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
  • 1b (GREEN)

    java
    private 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 CognitoGroupsToAuthoritiesConverter to
    • Update JWT claim from cognito:groups to

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
  • 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

Task 3: Update Frontend Auth Types and Service (RED β†’ GREEN)

  • 3a (RED)

    • Test JWT with
  • 3b (GREEN)

    typescript
    export 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:groups to
    • Add roles

Task 4: Verify No Cognito Groups Operations

  • 4a: Search codebase for

    bash
    grep -r "cognito:groups" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs
    
  • 4b: Search for

    bash
    grep -r "adminAddUserToGroup" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs
    
  • 4c: Search for

    bash
    grep -r "adminRemoveUserFromGroup" --exclude-dir=node_modules --exclude-dir=.git --exclude-dir=docs
    

Task 5: Update Test Fixtures and Mocks

  • 5a

    • Replace cognito:groups with custom:role
  • 5b

    • Replace cognito:groups with custom:role
  • 5c

    • Replace JWT fixtures with
  • 5d

Task 6: Run Full Test Suite

  • 6a

    bash
    cd services/company-user-management-service && ./gradlew test
    cd api-gateway && ./gradlew test
    
  • 6b

    bash
    cd 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:

  1. Deploy Story 1.2.5 - PreTokenGeneration Lambda that adds custom:role claim
  2. Wait for all users to get new tokens - Tokens have 1-hour TTL, so wait at least 1 hour
  3. Deploy Story 1.2.6 - Backend code reading custom:role
  4. 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:

  1. JWT claim name: cognito:groups β†’ custom:role
  2. Claim format: Array of strings β†’ Comma-separated string
  3. Multiple roles: Supported (e.g., β€œATTENDEE,SPEAKER”)

What Stays the Same:

  1. Authority prefix: ROLE_ (Spring Security convention)
  2. Role names: ORGANIZER, SPEAKER, PARTNER, ATTENDEE (uppercase in JWT)
  3. @PreAuthorize annotations 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 @PreAuthorize annotations work
  • Test multiple roles scenario

Manual Testing:

  1. Register new user (Story 1.2.3)
  2. Verify JWT contains custom:role: "ATTENDEE"
  3. Promote user to SPEAKER via admin API
  4. Re-login, verify JWT contains custom:role: "ATTENDEE,SPEAKER"
  5. Test SPEAKER-only endpoint access

Backend:

  • services/company-user-management-service/src/main/java/ch/batbern/companyuser/config/SecurityConfig.java:120-135
  • services/company-user-management-service/src/main/java/ch/batbern/companyuser/security/SecurityContextHelper.java
  • api-gateway/src/main/java/ch/batbern/gateway/auth/UserContextExtractor.java:41-51

Frontend:

  • web-frontend/src/types/auth.ts:23-33
  • web-frontend/src/services/auth/authService.ts

Tests:

  • services/company-user-management-service/src/test/java/ch/batbern/companyuser/config/SecurityConfigTest.java
  • api-gateway/src/test/java/ch/batbern/gateway/auth/UserContextExtractorTest.java
  • web-frontend/src/services/auth/authService.test.ts

Documentation:

  • docs/architecture/ADR-001-invitation-based-user-registration.md
  • docs/architecture/06b-user-lifecycle-sync.md
  • docs/architecture/06-backend-architecture.md

Definition of Done Checklist

Development Complete

  • All code updated to use custom:role instead of

Testing Complete

Review Ready

    • Explanation of migration from cognito:groups to

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:role into 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:role
  • api-gateway/src/test/java/ch/batbern/gateway/auth/UserContextExtractorTest.java - Updated test fixtures
  • api-gateway/src/test/java/ch/batbern/gateway/auth/CognitoJWTValidatorTest.java - Updated test fixtures
  • services/company-user-management-service/src/main/java/ch/batbern/companyuser/config/SecurityConfig.java - Updated JWT converter
  • services/company-user-management-service/src/main/java/ch/batbern/companyuser/security/SecurityContextHelper.java - Updated role extraction
  • services/company-user-management-service/src/test/java/ch/batbern/companyuser/security/SecurityContextHelperTest.java - Updated tests
  • web-frontend/src/types/auth.ts - Added roles array support
  • web-frontend/src/services/auth/authService.ts - Updated JWT parsing
  • infrastructure/lib/stacks/cognito-stack.ts - Removed Cognito Groups
  • infrastructure/lib/lambda/triggers/post-confirmation.ts - Always assign ATTENDEE per ADR-001
  • infrastructure/lib/lambda/triggers/__tests__/post-confirmation.test.ts - Updated tests
  • infrastructure/lib/lambda/triggers/__tests__/integration/cognito-triggers.integration.test.ts - Updated integration tests
  • docs/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