Skip to content

Commit 5eae9a0

Browse files
authored
Merge pull request #89 from flutter-news-app-full-source-code/refactor/user-management-capabilities-within-the-generic-data-API
Refactor/user management capabilities within the generic data api
2 parents 91445de + 8af7a00 commit 5eae9a0

File tree

5 files changed

+204
-71
lines changed

5 files changed

+204
-71
lines changed

lib/src/rbac/permissions.dart

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,10 @@ abstract class Permissions {
4444
// Allows deleting the authenticated user's own account
4545
static const String userDeleteOwned = 'user.delete_owned';
4646

47-
// Allows creating a new user (admin-only).
48-
static const String userCreate = 'user.create';
4947
// Allows updating any user's profile (admin-only).
48+
// This is distinct from `userUpdateOwned`, which allows a user to update
49+
// their own record.
5050
static const String userUpdate = 'user.update';
51-
// Allows deleting any user's account (admin-only).
52-
static const String userDelete = 'user.delete';
5351

5452
// User App Settings Permissions (User-owned)
5553
static const String userAppSettingsReadOwned = 'user_app_settings.read_owned';

lib/src/rbac/role_permissions.dart

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,12 @@ final Set<String> _dashboardAdminPermissions = {
6868
Permissions.languageCreate,
6969
Permissions.languageUpdate,
7070
Permissions.languageDelete,
71-
Permissions.userRead, // Allows reading any user's profile.
72-
// Allow full user account management for admins.
73-
Permissions.userCreate,
71+
// Allows reading any user's profile.
72+
Permissions.userRead,
73+
// Allows updating any user's profile (e.g., changing their roles).
74+
// User creation and deletion are handled by the auth service, not the
75+
// generic data API.
7476
Permissions.userUpdate,
75-
Permissions.userDelete,
7677
Permissions.remoteConfigCreate,
7778
Permissions.remoteConfigUpdate,
7879
Permissions.remoteConfigDelete,

lib/src/registry/data_operation_registry.dart

Lines changed: 77 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import 'package:core/core.dart';
22
import 'package:dart_frog/dart_frog.dart';
33
import 'package:data_repository/data_repository.dart';
44
import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart';
5+
import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart';
56
import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart';
67
import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart';
8+
import 'package:logging/logging.dart';
79

810
// --- Typedefs for Data Operations ---
911

@@ -54,6 +56,9 @@ typedef ItemDeleter =
5456
/// data operations are performed for each model, improving consistency across
5557
/// the API.
5658
/// {@endtemplate}
59+
60+
final _log = Logger('DataOperationRegistry');
61+
5762
class DataOperationRegistry {
5863
/// {@macro data_operation_registry}
5964
DataOperationRegistry() {
@@ -188,11 +193,6 @@ class DataOperationRegistry {
188193
item: item as Language,
189194
userId: uid,
190195
),
191-
// Handler for creating a new user.
192-
'user': (c, item, uid) => c.read<DataRepository<User>>().create(
193-
item: item as User,
194-
userId: uid,
195-
),
196196
'remote_config': (c, item, uid) => c
197197
.read<DataRepository<RemoteConfig>>()
198198
.create(item: item as RemoteConfig, userId: uid),
@@ -225,56 +225,90 @@ class DataOperationRegistry {
225225
'language': (c, id, item, uid) => c
226226
.read<DataRepository<Language>>()
227227
.update(id: id, item: item as Language, userId: uid),
228-
// Custom updater for the 'user' model.
229-
// This updater handles two distinct use cases:
230-
// 1. Admins updating user roles (`appRole`, `dashboardRole`).
231-
// 2. Regular users updating their own `feedDecoratorStatus`.
232-
// It accepts a raw Map<String, dynamic> as the `item` to prevent
233-
// mass assignment vulnerabilities, only applying allowed fields.
234-
'user': (c, id, item, uid) {
235-
final repo = c.read<DataRepository<User>>();
236-
final existingUser = c.read<FetchedItem<dynamic>>().data as User;
228+
// Custom updater for the 'user' model. This logic is critical for
229+
// security and architectural consistency.
230+
//
231+
// It enforces the following rules:
232+
// 1. Admins can ONLY update a user's `appRole` and `dashboardRole`.
233+
// 2. Regular users can ONLY update their own `feedDecoratorStatus`.
234+
//
235+
// This logic correctly handles a full `User` object in the request body,
236+
// aligning with the DataRepository contract. It works by comparing the
237+
// incoming `User` object from the request (`requestedUpdateUser`) with
238+
// the current state of the user in the database (`userToUpdate`), which
239+
// is pre-fetched by middleware. It then verifies that the *only* fields
240+
// that have changed are ones the authenticated user is permitted to
241+
// modify.
242+
'user': (context, id, item, uid) async {
243+
_log.info('Executing custom updater for user ID: $id.');
244+
final permissionService = context.read<PermissionService>();
245+
final authenticatedUser = context.read<User>();
246+
final userToUpdate = context.read<FetchedItem<dynamic>>().data as User;
237247
final requestBody = item as Map<String, dynamic>;
248+
final requestedUpdateUser = User.fromJson(requestBody);
238249

239-
AppUserRole? newAppRole;
240-
if (requestBody.containsKey('appRole')) {
241-
try {
242-
newAppRole = AppUserRole.values.byName(
243-
requestBody['appRole'] as String,
250+
// --- State Comparison Logic ---
251+
if (permissionService.isAdmin(authenticatedUser)) {
252+
_log.finer(
253+
'Admin user ${authenticatedUser.id} is updating user $id.',
254+
);
255+
256+
// Create a version of the original user with only the fields an
257+
// admin is allowed to change applied from the request.
258+
final permissibleUpdate = userToUpdate.copyWith(
259+
appRole: requestedUpdateUser.appRole,
260+
dashboardRole: requestedUpdateUser.dashboardRole,
261+
);
262+
263+
// If the user from the request is not identical to the one with
264+
// only permissible changes, it means an unauthorized field was
265+
// modified.
266+
if (requestedUpdateUser != permissibleUpdate) {
267+
_log.warning(
268+
'Admin ${authenticatedUser.id} attempted to update unauthorized fields for user $id.',
244269
);
245-
} on ArgumentError {
246-
throw BadRequestException(
247-
'Invalid value for "appRole": "${requestBody['appRole']}".',
270+
throw const ForbiddenException(
271+
'Administrators can only update "appRole" and "dashboardRole" via this endpoint.',
248272
);
249273
}
250-
}
274+
_log.finer('Admin update for user $id validation passed.');
275+
} else {
276+
_log.finer(
277+
'Regular user ${authenticatedUser.id} is updating their own profile.',
278+
);
279+
280+
// Create a version of the original user with only the fields a
281+
// regular user is allowed to change applied from the request.
282+
final permissibleUpdate = userToUpdate.copyWith(
283+
feedDecoratorStatus: requestedUpdateUser.feedDecoratorStatus,
284+
);
251285

252-
DashboardUserRole? newDashboardRole;
253-
if (requestBody.containsKey('dashboardRole')) {
254-
try {
255-
newDashboardRole = DashboardUserRole.values.byName(
256-
requestBody['dashboardRole'] as String,
286+
// If the user from the request is not identical to the one with
287+
// only permissible changes, it means an unauthorized field was
288+
// modified.
289+
if (requestedUpdateUser != permissibleUpdate) {
290+
_log.warning(
291+
'User ${authenticatedUser.id} attempted to update unauthorized fields.',
257292
);
258-
} on ArgumentError {
259-
throw BadRequestException(
260-
'Invalid value for "dashboardRole": "${requestBody['dashboardRole']}".',
293+
throw const ForbiddenException(
294+
'You can only update "feedDecoratorStatus" via this endpoint.',
261295
);
262296
}
297+
_log.finer(
298+
'Regular user update for user $id validation passed.',
299+
);
263300
}
264301

265-
Map<FeedDecoratorType, UserFeedDecoratorStatus>? newStatus;
266-
if (requestBody.containsKey('feedDecoratorStatus')) {
267-
newStatus = User.fromJson(
268-
{'feedDecoratorStatus': requestBody['feedDecoratorStatus']},
269-
).feedDecoratorStatus;
270-
}
271-
272-
final userWithUpdates = existingUser.copyWith(
273-
appRole: newAppRole,
274-
dashboardRole: newDashboardRole,
275-
feedDecoratorStatus: newStatus,
302+
_log.info(
303+
'User update validation passed. Calling repository with full object.',
304+
);
305+
// The validation passed, so we can now safely pass the full User
306+
// object from the request to the repository, honoring the contract.
307+
return context.read<DataRepository<User>>().update(
308+
id: id,
309+
item: requestedUpdateUser,
310+
userId: uid,
276311
);
277-
return repo.update(id: id, item: userWithUpdates, userId: uid);
278312
},
279313
'user_app_settings': (c, id, item, uid) => c
280314
.read<DataRepository<UserAppSettings>>()
@@ -302,8 +336,6 @@ class DataOperationRegistry {
302336
c.read<DataRepository<Country>>().delete(id: id, userId: uid),
303337
'language': (c, id, uid) =>
304338
c.read<DataRepository<Language>>().delete(id: id, userId: uid),
305-
'user': (c, id, uid) =>
306-
c.read<DataRepository<User>>().delete(id: id, userId: uid),
307339
'user_app_settings': (c, id, uid) =>
308340
c.read<DataRepository<UserAppSettings>>().delete(id: id, userId: uid),
309341
'user_content_preferences': (c, id, uid) => c

lib/src/registry/model_registry.dart

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -281,33 +281,28 @@ final modelRegistry = <String, ModelConfig<dynamic>>{
281281
requiresOwnershipCheck: true, // Must be the owner
282282
requiresAuthentication: true,
283283
),
284-
// Admins can create users via the data endpoint.
285-
// User creation via auth routes (e.g., sign-up) is separate.
284+
// User creation is handled exclusively by the authentication service
285+
// (e.g., during sign-up) and is not supported via the generic data API.
286286
postPermission: const ModelActionPermission(
287-
type: RequiredPermissionType.specificPermission,
288-
permission: Permissions.userCreate,
289-
requiresAuthentication: true,
287+
type: RequiredPermissionType.unsupported,
290288
),
291-
// An admin can update any user's roles.
292-
// A regular user can update specific fields on their own profile
293-
// (e.g., feedDecoratorStatus), which is handled by the updater logic
294-
// in DataOperationRegistry. The ownership check ensures they can only
295-
// access their own user object to begin with.
289+
// User updates are handled by a custom updater in DataOperationRegistry.
290+
// - Admins can update roles (`appRole`, `dashboardRole`).
291+
// - Users can update their own `feedDecoratorStatus` and `email`.
292+
// The `userUpdateOwned` permission, combined with the ownership check,
293+
// provides the entry point for both admins (who bypass ownership checks)
294+
// and users to target a user object for an update.
296295
putPermission: const ModelActionPermission(
297296
type: RequiredPermissionType.specificPermission,
298297
permission: Permissions.userUpdateOwned, // User can update their own
299298
requiresOwnershipCheck: true, // Must be the owner
300299
requiresAuthentication: true,
301300
),
302-
// An admin can delete any user.
303-
// A regular user can delete their own account.
304-
// The ownership check middleware is bypassed for admins, so this single
305-
// config works for both roles.
301+
// User deletion is handled exclusively by the authentication service
302+
// (e.g., via a dedicated "delete account" endpoint) and is not
303+
// supported via the generic data API.
306304
deletePermission: const ModelActionPermission(
307-
type: RequiredPermissionType.specificPermission,
308-
permission: Permissions.userDeleteOwned, // User can delete their own
309-
requiresOwnershipCheck: true, // Must be the owner
310-
requiresAuthentication: true,
305+
type: RequiredPermissionType.unsupported,
311306
),
312307
),
313308
'user_app_settings': ModelConfig<UserAppSettings>(

lib/src/services/auth_service.dart

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,4 +611,111 @@ class AuthService {
611611

612612
return (user: permanentUser, token: newToken);
613613
}
614+
615+
/// Initiates the process of updating a user's email address.
616+
///
617+
/// This is the first step in a two-step verification process. It checks if
618+
/// the new email is already in use, then generates and sends a verification
619+
/// code to that new email address.
620+
///
621+
/// - [user]: The currently authenticated user initiating the change.
622+
/// - [newEmail]: The desired new email address.
623+
///
624+
/// Throws [ConflictException] if the `newEmail` is already taken by another
625+
/// user.
626+
/// Throws [OperationFailedException] for other unexpected errors.
627+
Future<void> initiateEmailUpdate({
628+
required User user,
629+
required String newEmail,
630+
}) async {
631+
_log.info(
632+
'User ${user.id} is initiating an email update to "$newEmail".',
633+
);
634+
635+
try {
636+
// 1. Check if the new email address is already in use.
637+
final existingUser = await _findUserByEmail(newEmail);
638+
if (existingUser != null) {
639+
_log.warning(
640+
'Email update failed for user ${user.id}: new email "$newEmail" is already in use by user ${existingUser.id}.',
641+
);
642+
throw const ConflictException(
643+
'This email address is already registered.',
644+
);
645+
}
646+
_log.finer('New email "$newEmail" is available.');
647+
648+
// 2. Generate and send a verification code to the new email.
649+
// We reuse the sign-in code mechanism for this verification step.
650+
final code = await _verificationCodeStorageService
651+
.generateAndStoreSignInCode(newEmail);
652+
_log.finer('Generated verification code for "$newEmail".');
653+
654+
await _emailRepository.sendOtpEmail(
655+
senderEmail: EnvironmentConfig.defaultSenderEmail,
656+
recipientEmail: newEmail,
657+
templateId: EnvironmentConfig.otpTemplateId,
658+
subject: 'Verify your new email address',
659+
otpCode: code,
660+
);
661+
_log.info('Sent email update verification code to "$newEmail".');
662+
} on HttpException {
663+
// Propagate known exceptions (like ConflictException).
664+
rethrow;
665+
} catch (e, s) {
666+
_log.severe(
667+
'Unexpected error during initiateEmailUpdate for user ${user.id}.',
668+
e,
669+
s,
670+
);
671+
throw const OperationFailedException(
672+
'Failed to initiate email update process.',
673+
);
674+
}
675+
}
676+
677+
/// Completes the email update process by verifying the code and updating
678+
/// the user's record.
679+
///
680+
/// - [user]: The currently authenticated user.
681+
/// - [newEmail]: The new email address being verified.
682+
/// - [code]: The verification code sent to the new email address.
683+
///
684+
/// Returns the updated [User] object upon success.
685+
///
686+
/// Throws [InvalidInputException] if the verification code is invalid.
687+
/// Throws [OperationFailedException] for other unexpected errors.
688+
Future<User> completeEmailUpdate({
689+
required User user,
690+
required String newEmail,
691+
required String code,
692+
}) async {
693+
_log.info('User ${user.id} is completing email update to "$newEmail".');
694+
695+
// 1. Validate the verification code for the new email.
696+
final isValid = await _verificationCodeStorageService.validateSignInCode(
697+
newEmail,
698+
code,
699+
);
700+
if (!isValid) {
701+
_log.warning('Invalid verification code provided for "$newEmail".');
702+
throw const InvalidInputException(
703+
'Invalid or expired verification code.',
704+
);
705+
}
706+
_log.finer('Verification code for "$newEmail" is valid.');
707+
708+
// 2. Clear the used code from storage.
709+
await _verificationCodeStorageService.clearSignInCode(newEmail);
710+
711+
// 3. Update the user's email in the repository.
712+
final updatedUser = user.copyWith(email: newEmail);
713+
final finalUser = await _userRepository.update(
714+
id: user.id,
715+
item: updatedUser,
716+
);
717+
_log.info('Successfully updated email for user ${user.id} to "$newEmail".');
718+
719+
return finalUser;
720+
}
614721
}

0 commit comments

Comments
 (0)