@@ -2,8 +2,10 @@ import 'package:core/core.dart';
22import 'package:dart_frog/dart_frog.dart' ;
33import 'package:data_repository/data_repository.dart' ;
44import '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' ;
56import 'package:flutter_news_app_api_server_full_source_code/src/services/country_query_service.dart' ;
67import '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+
5762class 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
0 commit comments