diff --git a/README.md b/README.md index 239e48d..e66eb0f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,11 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard). the ability to easily link anonymous accounts to permanent ones. Focus on user experience while `ht_api` handles the security complexities. +* ⚡️ **Flexible Role-Based Access Control (RBAC):** Implement granular + permissions with a flexible, multi-role system. Assign multiple roles to + users (e.g., 'admin', 'publisher', 'premium_user') to precisely control + access to different API features and data management capabilities. + * ⚙️ **Synchronized App Settings:** Ensure a consistent and personalized user experience across devices by effortlessly syncing application preferences like theme, language, font styles, and more. diff --git a/lib/src/rbac/permission_service.dart b/lib/src/rbac/permission_service.dart index b95cb74..63561a2 100644 --- a/lib/src/rbac/permission_service.dart +++ b/lib/src/rbac/permission_service.dart @@ -5,8 +5,8 @@ import 'package:ht_shared/ht_shared.dart'; /// Service responsible for checking if a user has a specific permission. /// /// This service uses the predefined [rolePermissions] map to determine -/// a user's access rights based on their [UserRole]. It also includes -/// an explicit check for the [UserRole.admin], granting them all permissions. +/// a user's access rights based on their roles. It also includes +/// an explicit check for the 'admin' role, granting them all permissions. /// {@endtemplate} class PermissionService { /// {@macro permission_service} @@ -20,22 +20,24 @@ class PermissionService { /// - [user]: The authenticated user. /// - [permission]: The permission string to check (e.g., `headline.read`). bool hasPermission(User user, String permission) { - // Administrators have all permissions - if (user.role == UserRole.admin) { + // Administrators implicitly have all permissions. + if (user.roles.contains(UserRoles.admin)) { return true; } - // Check if the user's role is in the map and has the permission - return rolePermissions[user.role]?.contains(permission) ?? false; + // Check if any of the user's roles grant the required permission. + return user.roles.any( + (role) => rolePermissions[role]?.contains(permission) ?? false, + ); } - /// Checks if the given [user] has the [UserRole.admin] role. + /// Checks if the given [user] has the 'admin' role. /// /// This is a convenience method for checks that are strictly limited /// to administrators, bypassing the permission map. /// /// - [user]: The authenticated user. bool isAdmin(User user) { - return user.role == UserRole.admin; + return user.roles.contains(UserRoles.admin); } } diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index dfd8cab..7a17895 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -1,4 +1,3 @@ -import 'package:ht_api/src/rbac/permission_service.dart' show PermissionService; import 'package:ht_api/src/rbac/permissions.dart'; import 'package:ht_shared/ht_shared.dart'; @@ -25,6 +24,13 @@ final Set _standardUserPermissions = { // but this set can be expanded later for premium-specific features. final Set _premiumUserPermissions = {..._standardUserPermissions}; +final Set _publisherPermissions = { + ..._standardUserPermissions, + Permissions.headlineCreate, + Permissions.headlineUpdate, + Permissions.headlineDelete, +}; + final Set _adminPermissions = { ..._standardUserPermissions, Permissions.headlineCreate, @@ -48,16 +54,17 @@ final Set _adminPermissions = { /// Defines the mapping between user roles and the permissions they possess. /// /// This map is the core of the Role-Based Access Control (RBAC) system. -/// Each key is a [UserRole], and the associated value is a [Set] of +/// Each key is a role string, and the associated value is a [Set] of /// [Permissions] strings that users with that role are granted. /// /// Note: Administrators typically have implicit access to all resources /// regardless of this map, but including their permissions here can aid -/// documentation and clarity. The [PermissionService] should handle the +/// documentation and clarity. The `PermissionService` should handle the /// explicit admin bypass if desired. -final Map> rolePermissions = { - UserRole.guestUser: _guestUserPermissions, - UserRole.standardUser: _standardUserPermissions, - UserRole.premiumUser: _premiumUserPermissions, - UserRole.admin: _adminPermissions, +final Map> rolePermissions = { + UserRoles.guestUser: _guestUserPermissions, + UserRoles.standardUser: _standardUserPermissions, + UserRoles.premiumUser: _premiumUserPermissions, + UserRoles.publisher: _publisherPermissions, + UserRoles.admin: _adminPermissions, }; diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 0898181..3706214 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -76,6 +76,7 @@ class AuthService { String email, String code, { User? currentAuthUser, // Parameter for potential future linking logic + String? clientType, // e.g., 'dashboard', 'mobile_app' }) async { // 1. Validate the code for standard sign-in final isValidCode = await _verificationCodeStorageService @@ -100,7 +101,7 @@ class AuthService { User user; try { if (currentAuthUser != null && - currentAuthUser.role == UserRole.guestUser) { + currentAuthUser.roles.contains(UserRoles.guestUser)) { // This is an anonymous user linking their account. // Migrate their existing data to the new permanent user. print( @@ -139,7 +140,7 @@ class AuthService { // Update the existing anonymous user to be permanent user = currentAuthUser.copyWith( email: email, - role: UserRole.standardUser, + roles: [UserRoles.standardUser], ); user = await _userRepository.update(id: user.id, item: user); print( @@ -197,10 +198,15 @@ class AuthService { } else { // User not found, create a new one print('User not found for $email, creating new user.'); + // Assign roles based on client type. New users from the dashboard + // could be granted publisher rights, for example. + final roles = (clientType == 'dashboard') + ? [UserRoles.standardUser, UserRoles.publisher] + : [UserRoles.standardUser]; user = User( id: _uuid.v4(), // Generate new ID email: email, - role: UserRole.standardUser, // Email verified user is standard user + roles: roles, ); user = await _userRepository.create(item: user); // Save the new user print('Created new user: ${user.id}'); @@ -258,7 +264,7 @@ class AuthService { try { user = User( id: _uuid.v4(), // Generate new ID - role: UserRole.guestUser, // Anonymous users are guest users + roles: [UserRoles.guestUser], // Anonymous users are guest users email: null, // Anonymous users don't have an email initially ); user = await _userRepository.create(item: user); @@ -368,25 +374,27 @@ class AuthService { required User anonymousUser, required String emailToLink, }) async { - if (anonymousUser.role != UserRole.guestUser) { + if (!anonymousUser.roles.contains(UserRoles.guestUser)) { throw const BadRequestException( 'Account is already permanent. Cannot link email.', ); } try { - // 1. Check if emailToLink is already used by another *permanent* user. - final query = {'email': emailToLink, 'isAnonymous': false}; - final existingUsers = await _userRepository.readAllByQuery(query); - if (existingUsers.items.isNotEmpty) { - // Ensure it's not the same user if somehow an anonymous user had an email - // (though current logic prevents this for new anonymous users). - // This check is more for emails used by *other* permanent accounts. - if (existingUsers.items.any((u) => u.id != anonymousUser.id)) { - throw ConflictException( - 'Email address "$emailToLink" is already in use by another account.', - ); - } + // 1. Check if emailToLink is already used by another permanent user. + final query = {'email': emailToLink}; + final existingUsersResponse = await _userRepository.readAllByQuery(query); + + // Filter for permanent users (not guests) that are not the current user. + final conflictingPermanentUsers = existingUsersResponse.items.where( + (u) => + !u.roles.contains(UserRoles.guestUser) && u.id != anonymousUser.id, + ); + + if (conflictingPermanentUsers.isNotEmpty) { + throw ConflictException( + 'Email address "$emailToLink" is already in use by another account.', + ); } // 2. Generate and store the link code. @@ -430,7 +438,7 @@ class AuthService { required String codeFromUser, required String oldAnonymousToken, // Needed to invalidate it }) async { - if (anonymousUser.role != UserRole.guestUser) { + if (!anonymousUser.roles.contains(UserRoles.guestUser)) { // Should ideally not happen if flow is correct, but good safeguard. throw const BadRequestException( 'Account is already permanent. Cannot complete email linking.', @@ -455,7 +463,7 @@ class AuthService { final updatedUser = User( id: anonymousUser.id, // Preserve original ID email: linkedEmail, - role: UserRole.standardUser, // Now a permanent standard user + roles: [UserRoles.standardUser], // Now a permanent standard user ); final permanentUser = await _userRepository.update( id: updatedUser.id, diff --git a/lib/src/services/default_user_preference_limit_service.dart b/lib/src/services/default_user_preference_limit_service.dart index d115787..cc10281 100644 --- a/lib/src/services/default_user_preference_limit_service.dart +++ b/lib/src/services/default_user_preference_limit_service.dart @@ -29,39 +29,42 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { final appConfig = await _appConfigRepository.read(id: _appConfigId); final limits = appConfig.userPreferenceLimits; - // 2. Determine the limit based on user role and item type + // Admins have no limits. + if (user.roles.contains(UserRoles.admin)) { + return; + } + + // 2. Determine the limit based on the user's highest role. int limit; - switch (user.role) { - case UserRole.guestUser: - if (itemType == 'headline') { - limit = limits.guestSavedHeadlinesLimit; - } else { - // Applies to countries, sources, categories - limit = limits.guestFollowedItemsLimit; - } - case UserRole.standardUser: - if (itemType == 'headline') { - limit = limits.authenticatedSavedHeadlinesLimit; - } else { - // Applies to countries, sources, categories - limit = limits.authenticatedFollowedItemsLimit; - } - case UserRole.premiumUser: - if (itemType == 'headline') { - limit = limits.premiumSavedHeadlinesLimit; - } else { - limit = limits.premiumFollowedItemsLimit; - } - case UserRole.admin: - // Admins have no limits - return; + String accountType; + + if (user.roles.contains(UserRoles.premiumUser)) { + accountType = 'premium'; + limit = (itemType == 'headline') + ? limits.premiumSavedHeadlinesLimit + : limits.premiumFollowedItemsLimit; + } else if (user.roles.contains(UserRoles.standardUser)) { + accountType = 'standard'; + limit = (itemType == 'headline') + ? limits.authenticatedSavedHeadlinesLimit + : limits.authenticatedFollowedItemsLimit; + } else if (user.roles.contains(UserRoles.guestUser)) { + accountType = 'guest'; + limit = (itemType == 'headline') + ? limits.guestSavedHeadlinesLimit + : limits.guestFollowedItemsLimit; + } else { + // Fallback for users with unknown or no roles. + throw const ForbiddenException( + 'Cannot determine preference limits for this user account.', + ); } // 3. Check if adding the item would exceed the limit if (currentCount >= limit) { throw ForbiddenException( 'You have reached the maximum number of $itemType items allowed ' - 'for your account type (${user.role.name}).', + 'for your account type ($accountType).', ); } } on HtHttpException { @@ -86,48 +89,58 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService { final appConfig = await _appConfigRepository.read(id: _appConfigId); final limits = appConfig.userPreferenceLimits; - // 2. Determine limits based on user role + // Admins have no limits. + if (user.roles.contains(UserRoles.admin)) { + return; + } + + // 2. Determine limits based on the user's highest role. int followedItemsLimit; int savedHeadlinesLimit; + String accountType; - switch (user.role) { - case UserRole.guestUser: - followedItemsLimit = limits.guestFollowedItemsLimit; - savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; - case UserRole.standardUser: - followedItemsLimit = limits.authenticatedFollowedItemsLimit; - savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; - case UserRole.premiumUser: - followedItemsLimit = limits.premiumFollowedItemsLimit; - savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit; - case UserRole.admin: - // Admins have no limits - return; + if (user.roles.contains(UserRoles.premiumUser)) { + accountType = 'premium'; + followedItemsLimit = limits.premiumFollowedItemsLimit; + savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit; + } else if (user.roles.contains(UserRoles.standardUser)) { + accountType = 'standard'; + followedItemsLimit = limits.authenticatedFollowedItemsLimit; + savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit; + } else if (user.roles.contains(UserRoles.guestUser)) { + accountType = 'guest'; + followedItemsLimit = limits.guestFollowedItemsLimit; + savedHeadlinesLimit = limits.guestSavedHeadlinesLimit; + } else { + // Fallback for users with unknown or no roles. + throw const ForbiddenException( + 'Cannot determine preference limits for this user account.', + ); } // 3. Check if proposed preferences exceed limits if (updatedPreferences.followedCountries.length > followedItemsLimit) { throw ForbiddenException( 'You have reached the maximum number of followed countries allowed ' - 'for your account type (${user.role.name}).', + 'for your account type ($accountType).', ); } if (updatedPreferences.followedSources.length > followedItemsLimit) { throw ForbiddenException( 'You have reached the maximum number of followed sources allowed ' - 'for your account type (${user.role.name}).', + 'for your account type ($accountType).', ); } if (updatedPreferences.followedCategories.length > followedItemsLimit) { throw ForbiddenException( 'You have reached the maximum number of followed categories allowed ' - 'for your account type (${user.role.name}).', + 'for your account type ($accountType).', ); } if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) { throw ForbiddenException( 'You have reached the maximum number of saved headlines allowed ' - 'for your account type (${user.role.name}).', + 'for your account type ($accountType).', ); } } on HtHttpException { diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index 491a665..ea4fb24 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -5,16 +5,6 @@ import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:uuid/uuid.dart'; -/// Helper function to convert UserRole enum to its snake_case string. -String _userRoleToString(UserRole role) { - return switch (role) { - UserRole.admin => 'admin', - UserRole.standardUser => 'standard_user', - UserRole.guestUser => 'guest_user', - UserRole.premiumUser => 'premium_user', - }; -} - /// {@template jwt_auth_token_service} /// An implementation of [AuthTokenService] using JSON Web Tokens (JWT). /// @@ -70,9 +60,7 @@ class JwtAuthTokenService implements AuthTokenService { 'jti': _uuid.v4(), // JWT ID (for potential blacklisting) // Custom claims (optional, include what's useful) 'email': user.email, - 'role': _userRoleToString( - user.role, - ), // Include the user's role as a string + 'roles': user.roles, // Include the user's roles as a list of strings }, issuer: _issuer, subject: user.id, diff --git a/routes/api/v1/auth/link-email.dart b/routes/api/v1/auth/link-email.dart index e708a01..b042518 100644 --- a/routes/api/v1/auth/link-email.dart +++ b/routes/api/v1/auth/link-email.dart @@ -22,7 +22,7 @@ Future onRequest(RequestContext context) async { // This should ideally be caught by `authenticationProvider` if route is protected throw const UnauthorizedException('Authentication required to link email.'); } - if (authenticatedUser.role != UserRole.guestUser) { + if (!authenticatedUser.roles.contains(UserRoles.guestUser)) { throw const BadRequestException( 'Account is already permanent. Cannot initiate email linking.', ); diff --git a/routes/api/v1/auth/verify-link-email.dart b/routes/api/v1/auth/verify-link-email.dart index 31e61db..997c74c 100644 --- a/routes/api/v1/auth/verify-link-email.dart +++ b/routes/api/v1/auth/verify-link-email.dart @@ -23,7 +23,7 @@ Future onRequest(RequestContext context) async { 'Authentication required to verify email link.', ); } - if (authenticatedUser.role != UserRole.guestUser) { + if (!authenticatedUser.roles.contains(UserRoles.guestUser)) { throw const BadRequestException( 'Account is already permanent. Cannot complete email linking.', );