Skip to content

Commit 8bf722f

Browse files
authored
Merge pull request #8 from headlines-toolkit/refactor_migrate_user_role_to_multi_role_system
Refactor migrate user role to multi role system
2 parents 25c873a + b8967f6 commit 8bf722f

File tree

8 files changed

+117
-94
lines changed

8 files changed

+117
-94
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ management dashboard](https://github.com/headlines-toolkit/ht-dashboard).
2121
the ability to easily link anonymous accounts to permanent ones. Focus on
2222
user experience while `ht_api` handles the security complexities.
2323

24+
* ⚡️ **Flexible Role-Based Access Control (RBAC):** Implement granular
25+
permissions with a flexible, multi-role system. Assign multiple roles to
26+
users (e.g., 'admin', 'publisher', 'premium_user') to precisely control
27+
access to different API features and data management capabilities.
28+
2429
* ⚙️ **Synchronized App Settings:** Ensure a consistent and personalized user
2530
experience across devices by effortlessly syncing application preferences
2631
like theme, language, font styles, and more.

lib/src/rbac/permission_service.dart

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import 'package:ht_shared/ht_shared.dart';
55
/// Service responsible for checking if a user has a specific permission.
66
///
77
/// This service uses the predefined [rolePermissions] map to determine
8-
/// a user's access rights based on their [UserRole]. It also includes
9-
/// an explicit check for the [UserRole.admin], granting them all permissions.
8+
/// a user's access rights based on their roles. It also includes
9+
/// an explicit check for the 'admin' role, granting them all permissions.
1010
/// {@endtemplate}
1111
class PermissionService {
1212
/// {@macro permission_service}
@@ -20,22 +20,24 @@ class PermissionService {
2020
/// - [user]: The authenticated user.
2121
/// - [permission]: The permission string to check (e.g., `headline.read`).
2222
bool hasPermission(User user, String permission) {
23-
// Administrators have all permissions
24-
if (user.role == UserRole.admin) {
23+
// Administrators implicitly have all permissions.
24+
if (user.roles.contains(UserRoles.admin)) {
2525
return true;
2626
}
2727

28-
// Check if the user's role is in the map and has the permission
29-
return rolePermissions[user.role]?.contains(permission) ?? false;
28+
// Check if any of the user's roles grant the required permission.
29+
return user.roles.any(
30+
(role) => rolePermissions[role]?.contains(permission) ?? false,
31+
);
3032
}
3133

32-
/// Checks if the given [user] has the [UserRole.admin] role.
34+
/// Checks if the given [user] has the 'admin' role.
3335
///
3436
/// This is a convenience method for checks that are strictly limited
3537
/// to administrators, bypassing the permission map.
3638
///
3739
/// - [user]: The authenticated user.
3840
bool isAdmin(User user) {
39-
return user.role == UserRole.admin;
41+
return user.roles.contains(UserRoles.admin);
4042
}
4143
}

lib/src/rbac/role_permissions.dart

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:ht_api/src/rbac/permission_service.dart' show PermissionService;
21
import 'package:ht_api/src/rbac/permissions.dart';
32
import 'package:ht_shared/ht_shared.dart';
43

@@ -25,6 +24,13 @@ final Set<String> _standardUserPermissions = {
2524
// but this set can be expanded later for premium-specific features.
2625
final Set<String> _premiumUserPermissions = {..._standardUserPermissions};
2726

27+
final Set<String> _publisherPermissions = {
28+
..._standardUserPermissions,
29+
Permissions.headlineCreate,
30+
Permissions.headlineUpdate,
31+
Permissions.headlineDelete,
32+
};
33+
2834
final Set<String> _adminPermissions = {
2935
..._standardUserPermissions,
3036
Permissions.headlineCreate,
@@ -48,16 +54,17 @@ final Set<String> _adminPermissions = {
4854
/// Defines the mapping between user roles and the permissions they possess.
4955
///
5056
/// This map is the core of the Role-Based Access Control (RBAC) system.
51-
/// Each key is a [UserRole], and the associated value is a [Set] of
57+
/// Each key is a role string, and the associated value is a [Set] of
5258
/// [Permissions] strings that users with that role are granted.
5359
///
5460
/// Note: Administrators typically have implicit access to all resources
5561
/// regardless of this map, but including their permissions here can aid
56-
/// documentation and clarity. The [PermissionService] should handle the
62+
/// documentation and clarity. The `PermissionService` should handle the
5763
/// explicit admin bypass if desired.
58-
final Map<UserRole, Set<String>> rolePermissions = {
59-
UserRole.guestUser: _guestUserPermissions,
60-
UserRole.standardUser: _standardUserPermissions,
61-
UserRole.premiumUser: _premiumUserPermissions,
62-
UserRole.admin: _adminPermissions,
64+
final Map<String, Set<String>> rolePermissions = {
65+
UserRoles.guestUser: _guestUserPermissions,
66+
UserRoles.standardUser: _standardUserPermissions,
67+
UserRoles.premiumUser: _premiumUserPermissions,
68+
UserRoles.publisher: _publisherPermissions,
69+
UserRoles.admin: _adminPermissions,
6370
};

lib/src/services/auth_service.dart

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ class AuthService {
7676
String email,
7777
String code, {
7878
User? currentAuthUser, // Parameter for potential future linking logic
79+
String? clientType, // e.g., 'dashboard', 'mobile_app'
7980
}) async {
8081
// 1. Validate the code for standard sign-in
8182
final isValidCode = await _verificationCodeStorageService
@@ -100,7 +101,7 @@ class AuthService {
100101
User user;
101102
try {
102103
if (currentAuthUser != null &&
103-
currentAuthUser.role == UserRole.guestUser) {
104+
currentAuthUser.roles.contains(UserRoles.guestUser)) {
104105
// This is an anonymous user linking their account.
105106
// Migrate their existing data to the new permanent user.
106107
print(
@@ -139,7 +140,7 @@ class AuthService {
139140
// Update the existing anonymous user to be permanent
140141
user = currentAuthUser.copyWith(
141142
email: email,
142-
role: UserRole.standardUser,
143+
roles: [UserRoles.standardUser],
143144
);
144145
user = await _userRepository.update(id: user.id, item: user);
145146
print(
@@ -197,10 +198,15 @@ class AuthService {
197198
} else {
198199
// User not found, create a new one
199200
print('User not found for $email, creating new user.');
201+
// Assign roles based on client type. New users from the dashboard
202+
// could be granted publisher rights, for example.
203+
final roles = (clientType == 'dashboard')
204+
? [UserRoles.standardUser, UserRoles.publisher]
205+
: [UserRoles.standardUser];
200206
user = User(
201207
id: _uuid.v4(), // Generate new ID
202208
email: email,
203-
role: UserRole.standardUser, // Email verified user is standard user
209+
roles: roles,
204210
);
205211
user = await _userRepository.create(item: user); // Save the new user
206212
print('Created new user: ${user.id}');
@@ -258,7 +264,7 @@ class AuthService {
258264
try {
259265
user = User(
260266
id: _uuid.v4(), // Generate new ID
261-
role: UserRole.guestUser, // Anonymous users are guest users
267+
roles: [UserRoles.guestUser], // Anonymous users are guest users
262268
email: null, // Anonymous users don't have an email initially
263269
);
264270
user = await _userRepository.create(item: user);
@@ -368,25 +374,27 @@ class AuthService {
368374
required User anonymousUser,
369375
required String emailToLink,
370376
}) async {
371-
if (anonymousUser.role != UserRole.guestUser) {
377+
if (!anonymousUser.roles.contains(UserRoles.guestUser)) {
372378
throw const BadRequestException(
373379
'Account is already permanent. Cannot link email.',
374380
);
375381
}
376382

377383
try {
378-
// 1. Check if emailToLink is already used by another *permanent* user.
379-
final query = {'email': emailToLink, 'isAnonymous': false};
380-
final existingUsers = await _userRepository.readAllByQuery(query);
381-
if (existingUsers.items.isNotEmpty) {
382-
// Ensure it's not the same user if somehow an anonymous user had an email
383-
// (though current logic prevents this for new anonymous users).
384-
// This check is more for emails used by *other* permanent accounts.
385-
if (existingUsers.items.any((u) => u.id != anonymousUser.id)) {
386-
throw ConflictException(
387-
'Email address "$emailToLink" is already in use by another account.',
388-
);
389-
}
384+
// 1. Check if emailToLink is already used by another permanent user.
385+
final query = {'email': emailToLink};
386+
final existingUsersResponse = await _userRepository.readAllByQuery(query);
387+
388+
// Filter for permanent users (not guests) that are not the current user.
389+
final conflictingPermanentUsers = existingUsersResponse.items.where(
390+
(u) =>
391+
!u.roles.contains(UserRoles.guestUser) && u.id != anonymousUser.id,
392+
);
393+
394+
if (conflictingPermanentUsers.isNotEmpty) {
395+
throw ConflictException(
396+
'Email address "$emailToLink" is already in use by another account.',
397+
);
390398
}
391399

392400
// 2. Generate and store the link code.
@@ -430,7 +438,7 @@ class AuthService {
430438
required String codeFromUser,
431439
required String oldAnonymousToken, // Needed to invalidate it
432440
}) async {
433-
if (anonymousUser.role != UserRole.guestUser) {
441+
if (!anonymousUser.roles.contains(UserRoles.guestUser)) {
434442
// Should ideally not happen if flow is correct, but good safeguard.
435443
throw const BadRequestException(
436444
'Account is already permanent. Cannot complete email linking.',
@@ -455,7 +463,7 @@ class AuthService {
455463
final updatedUser = User(
456464
id: anonymousUser.id, // Preserve original ID
457465
email: linkedEmail,
458-
role: UserRole.standardUser, // Now a permanent standard user
466+
roles: [UserRoles.standardUser], // Now a permanent standard user
459467
);
460468
final permanentUser = await _userRepository.update(
461469
id: updatedUser.id,

lib/src/services/default_user_preference_limit_service.dart

Lines changed: 57 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -29,39 +29,42 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService {
2929
final appConfig = await _appConfigRepository.read(id: _appConfigId);
3030
final limits = appConfig.userPreferenceLimits;
3131

32-
// 2. Determine the limit based on user role and item type
32+
// Admins have no limits.
33+
if (user.roles.contains(UserRoles.admin)) {
34+
return;
35+
}
36+
37+
// 2. Determine the limit based on the user's highest role.
3338
int limit;
34-
switch (user.role) {
35-
case UserRole.guestUser:
36-
if (itemType == 'headline') {
37-
limit = limits.guestSavedHeadlinesLimit;
38-
} else {
39-
// Applies to countries, sources, categories
40-
limit = limits.guestFollowedItemsLimit;
41-
}
42-
case UserRole.standardUser:
43-
if (itemType == 'headline') {
44-
limit = limits.authenticatedSavedHeadlinesLimit;
45-
} else {
46-
// Applies to countries, sources, categories
47-
limit = limits.authenticatedFollowedItemsLimit;
48-
}
49-
case UserRole.premiumUser:
50-
if (itemType == 'headline') {
51-
limit = limits.premiumSavedHeadlinesLimit;
52-
} else {
53-
limit = limits.premiumFollowedItemsLimit;
54-
}
55-
case UserRole.admin:
56-
// Admins have no limits
57-
return;
39+
String accountType;
40+
41+
if (user.roles.contains(UserRoles.premiumUser)) {
42+
accountType = 'premium';
43+
limit = (itemType == 'headline')
44+
? limits.premiumSavedHeadlinesLimit
45+
: limits.premiumFollowedItemsLimit;
46+
} else if (user.roles.contains(UserRoles.standardUser)) {
47+
accountType = 'standard';
48+
limit = (itemType == 'headline')
49+
? limits.authenticatedSavedHeadlinesLimit
50+
: limits.authenticatedFollowedItemsLimit;
51+
} else if (user.roles.contains(UserRoles.guestUser)) {
52+
accountType = 'guest';
53+
limit = (itemType == 'headline')
54+
? limits.guestSavedHeadlinesLimit
55+
: limits.guestFollowedItemsLimit;
56+
} else {
57+
// Fallback for users with unknown or no roles.
58+
throw const ForbiddenException(
59+
'Cannot determine preference limits for this user account.',
60+
);
5861
}
5962

6063
// 3. Check if adding the item would exceed the limit
6164
if (currentCount >= limit) {
6265
throw ForbiddenException(
6366
'You have reached the maximum number of $itemType items allowed '
64-
'for your account type (${user.role.name}).',
67+
'for your account type ($accountType).',
6568
);
6669
}
6770
} on HtHttpException {
@@ -86,48 +89,58 @@ class DefaultUserPreferenceLimitService implements UserPreferenceLimitService {
8689
final appConfig = await _appConfigRepository.read(id: _appConfigId);
8790
final limits = appConfig.userPreferenceLimits;
8891

89-
// 2. Determine limits based on user role
92+
// Admins have no limits.
93+
if (user.roles.contains(UserRoles.admin)) {
94+
return;
95+
}
96+
97+
// 2. Determine limits based on the user's highest role.
9098
int followedItemsLimit;
9199
int savedHeadlinesLimit;
100+
String accountType;
92101

93-
switch (user.role) {
94-
case UserRole.guestUser:
95-
followedItemsLimit = limits.guestFollowedItemsLimit;
96-
savedHeadlinesLimit = limits.guestSavedHeadlinesLimit;
97-
case UserRole.standardUser:
98-
followedItemsLimit = limits.authenticatedFollowedItemsLimit;
99-
savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit;
100-
case UserRole.premiumUser:
101-
followedItemsLimit = limits.premiumFollowedItemsLimit;
102-
savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit;
103-
case UserRole.admin:
104-
// Admins have no limits
105-
return;
102+
if (user.roles.contains(UserRoles.premiumUser)) {
103+
accountType = 'premium';
104+
followedItemsLimit = limits.premiumFollowedItemsLimit;
105+
savedHeadlinesLimit = limits.premiumSavedHeadlinesLimit;
106+
} else if (user.roles.contains(UserRoles.standardUser)) {
107+
accountType = 'standard';
108+
followedItemsLimit = limits.authenticatedFollowedItemsLimit;
109+
savedHeadlinesLimit = limits.authenticatedSavedHeadlinesLimit;
110+
} else if (user.roles.contains(UserRoles.guestUser)) {
111+
accountType = 'guest';
112+
followedItemsLimit = limits.guestFollowedItemsLimit;
113+
savedHeadlinesLimit = limits.guestSavedHeadlinesLimit;
114+
} else {
115+
// Fallback for users with unknown or no roles.
116+
throw const ForbiddenException(
117+
'Cannot determine preference limits for this user account.',
118+
);
106119
}
107120

108121
// 3. Check if proposed preferences exceed limits
109122
if (updatedPreferences.followedCountries.length > followedItemsLimit) {
110123
throw ForbiddenException(
111124
'You have reached the maximum number of followed countries allowed '
112-
'for your account type (${user.role.name}).',
125+
'for your account type ($accountType).',
113126
);
114127
}
115128
if (updatedPreferences.followedSources.length > followedItemsLimit) {
116129
throw ForbiddenException(
117130
'You have reached the maximum number of followed sources allowed '
118-
'for your account type (${user.role.name}).',
131+
'for your account type ($accountType).',
119132
);
120133
}
121134
if (updatedPreferences.followedCategories.length > followedItemsLimit) {
122135
throw ForbiddenException(
123136
'You have reached the maximum number of followed categories allowed '
124-
'for your account type (${user.role.name}).',
137+
'for your account type ($accountType).',
125138
);
126139
}
127140
if (updatedPreferences.savedHeadlines.length > savedHeadlinesLimit) {
128141
throw ForbiddenException(
129142
'You have reached the maximum number of saved headlines allowed '
130-
'for your account type (${user.role.name}).',
143+
'for your account type ($accountType).',
131144
);
132145
}
133146
} on HtHttpException {

lib/src/services/jwt_auth_token_service.dart

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,6 @@ import 'package:ht_data_repository/ht_data_repository.dart';
55
import 'package:ht_shared/ht_shared.dart';
66
import 'package:uuid/uuid.dart';
77

8-
/// Helper function to convert UserRole enum to its snake_case string.
9-
String _userRoleToString(UserRole role) {
10-
return switch (role) {
11-
UserRole.admin => 'admin',
12-
UserRole.standardUser => 'standard_user',
13-
UserRole.guestUser => 'guest_user',
14-
UserRole.premiumUser => 'premium_user',
15-
};
16-
}
17-
188
/// {@template jwt_auth_token_service}
199
/// An implementation of [AuthTokenService] using JSON Web Tokens (JWT).
2010
///
@@ -70,9 +60,7 @@ class JwtAuthTokenService implements AuthTokenService {
7060
'jti': _uuid.v4(), // JWT ID (for potential blacklisting)
7161
// Custom claims (optional, include what's useful)
7262
'email': user.email,
73-
'role': _userRoleToString(
74-
user.role,
75-
), // Include the user's role as a string
63+
'roles': user.roles, // Include the user's roles as a list of strings
7664
},
7765
issuer: _issuer,
7866
subject: user.id,

routes/api/v1/auth/link-email.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ Future<Response> onRequest(RequestContext context) async {
2222
// This should ideally be caught by `authenticationProvider` if route is protected
2323
throw const UnauthorizedException('Authentication required to link email.');
2424
}
25-
if (authenticatedUser.role != UserRole.guestUser) {
25+
if (!authenticatedUser.roles.contains(UserRoles.guestUser)) {
2626
throw const BadRequestException(
2727
'Account is already permanent. Cannot initiate email linking.',
2828
);

0 commit comments

Comments
 (0)