Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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)

AltStyle によって変換されたページ (->オリジナル) /