Skip to content

Refactor migrate user role to multi role system #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 5, 2025
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 10 additions & 8 deletions lib/src/rbac/permission_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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);
}
}
23 changes: 15 additions & 8 deletions lib/src/rbac/role_permissions.dart
Original file line number Diff line number Diff line change
@@ -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';

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

final Set<String> _publisherPermissions = {
..._standardUserPermissions,
Permissions.headlineCreate,
Permissions.headlineUpdate,
Permissions.headlineDelete,
};

final Set<String> _adminPermissions = {
..._standardUserPermissions,
Permissions.headlineCreate,
Expand All @@ -48,16 +54,17 @@ final Set<String> _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<UserRole, Set<String>> rolePermissions = {
UserRole.guestUser: _guestUserPermissions,
UserRole.standardUser: _standardUserPermissions,
UserRole.premiumUser: _premiumUserPermissions,
UserRole.admin: _adminPermissions,
final Map<String, Set<String>> rolePermissions = {
UserRoles.guestUser: _guestUserPermissions,
UserRoles.standardUser: _standardUserPermissions,
UserRoles.premiumUser: _premiumUserPermissions,
UserRoles.publisher: _publisherPermissions,
UserRoles.admin: _adminPermissions,
};
46 changes: 27 additions & 19 deletions lib/src/services/auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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];
Comment on lines +203 to +205

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider defining constants for the clientType values instead of using a magic string1. This improves maintainability and reduces the risk of typos.

const String dashboardClient = 'dashboard';
const String mobileAppClient = 'mobile_app';

final roles = (clientType == dashboardClient)
    ? [UserRoles.standardUser, UserRoles.publisher]
    : [UserRoles.standardUser];

Style Guide References

Footnotes

  1. Avoid using magic values, define constants instead. (link)

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}');
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.',
Expand All @@ -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,
Expand Down
101 changes: 57 additions & 44 deletions lib/src/services/default_user_preference_limit_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
);
}
Comment on lines +32 to 61

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for determining user limits based on roles is duplicated in checkAddItem and checkUpdatePreferences. Refactor this into a private helper method to reduce redundancy and improve maintainability.1

  int _getLimitForUser(User user, String itemType, UserPreferenceLimits limits) {
    if (user.roles.contains(UserRoles.premiumUser)) {
      return (itemType == 'headline')
          ? limits.premiumSavedHeadlinesLimit
          : limits.premiumFollowedItemsLimit;
    } else if (user.roles.contains(UserRoles.standardUser)) {
      return (itemType == 'headline')
          ? limits.authenticatedSavedHeadlinesLimit
          : limits.authenticatedFollowedItemsLimit;
    } else if (user.roles.contains(UserRoles.guestUser)) {
      return (itemType == 'headline')
          ? limits.guestSavedHeadlinesLimit
          : limits.guestFollowedItemsLimit;
    } else {
      throw const ForbiddenException(
          'Cannot determine preference limits for this user account.');
    }
  }

Style Guide References

Footnotes

  1. Avoid code duplication by extracting common logic into reusable methods. (link)


// 3. Check if adding the item would exceed the limit
if (currentCount >= limit) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider using the accountType variable in the exception message for better clarity.

          'for your account type ($accountType).',
        );

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 {
Expand All @@ -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).',
Comment on lines 123 to +125

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider using the accountType variable in the exception message for better clarity.

          'for your account type ($accountType).',
        );

);
}
if (updatedPreferences.followedSources.length > followedItemsLimit) {
throw ForbiddenException(
'You have reached the maximum number of followed sources allowed '
Comment on lines 128 to 130

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider using the accountType variable in the exception message for better clarity.

          'for your account type ($accountType).',
        );

'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 '
Comment on lines 134 to 136

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider using the accountType variable in the exception message for better clarity.

          'for your account type ($accountType).',
        );

'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).',
Comment on lines 141 to +143

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider using the accountType variable in the exception message for better clarity.

          'for your account type ($accountType).',
        );

);
}
} on HtHttpException {
Expand Down
14 changes: 1 addition & 13 deletions lib/src/services/jwt_auth_token_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion routes/api/v1/auth/link-email.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Future<Response> 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.',
);
Expand Down
Loading
Loading