Skip to content

Commit c1ec019

Browse files
authored
Merge pull request #88 from flutter-news-app-full-source-code/refactor/implement-the-required-user-management-capabilities-for-administrators
Refactor/implement the required user management capabilities for administrators
2 parents 6134024 + 6f81b7f commit c1ec019

File tree

6 files changed

+115
-23
lines changed

6 files changed

+115
-23
lines changed

lib/src/rbac/permissions.dart

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ 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';
49+
// Allows updating any user's profile (admin-only).
50+
static const String userUpdate = 'user.update';
51+
// Allows deleting any user's account (admin-only).
52+
static const String userDelete = 'user.delete';
53+
4754
// User App Settings Permissions (User-owned)
4855
static const String userAppSettingsReadOwned = 'user_app_settings.read_owned';
4956
static const String userAppSettingsUpdateOwned =

lib/src/rbac/role_permissions.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,11 @@ final Set<String> _dashboardAdminPermissions = {
6868
Permissions.languageCreate,
6969
Permissions.languageUpdate,
7070
Permissions.languageDelete,
71-
Permissions.userRead, // Allows reading any user's profile
71+
Permissions.userRead, // Allows reading any user's profile.
72+
// Allow full user account management for admins.
73+
Permissions.userCreate,
74+
Permissions.userUpdate,
75+
Permissions.userDelete,
7276
Permissions.remoteConfigCreate,
7377
Permissions.remoteConfigUpdate,
7478
Permissions.remoteConfigDelete,

lib/src/registry/data_operation_registry.dart

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ class DataOperationRegistry {
188188
item: item as Language,
189189
userId: uid,
190190
),
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+
),
191196
'remote_config': (c, item, uid) => c
192197
.read<DataRepository<RemoteConfig>>()
193198
.create(item: item as RemoteConfig, userId: uid),
@@ -220,13 +225,56 @@ class DataOperationRegistry {
220225
'language': (c, id, item, uid) => c
221226
.read<DataRepository<Language>>()
222227
.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.
223234
'user': (c, id, item, uid) {
224235
final repo = c.read<DataRepository<User>>();
225236
final existingUser = c.read<FetchedItem<dynamic>>().data as User;
226-
final updatedUser = existingUser.copyWith(
227-
feedDecoratorStatus: (item as User).feedDecoratorStatus,
237+
final requestBody = item as Map<String, dynamic>;
238+
239+
AppUserRole? newAppRole;
240+
if (requestBody.containsKey('appRole')) {
241+
try {
242+
newAppRole = AppUserRole.values.byName(
243+
requestBody['appRole'] as String,
244+
);
245+
} on ArgumentError {
246+
throw BadRequestException(
247+
'Invalid value for "appRole": "${requestBody['appRole']}".',
248+
);
249+
}
250+
}
251+
252+
DashboardUserRole? newDashboardRole;
253+
if (requestBody.containsKey('dashboardRole')) {
254+
try {
255+
newDashboardRole = DashboardUserRole.values.byName(
256+
requestBody['dashboardRole'] as String,
257+
);
258+
} on ArgumentError {
259+
throw BadRequestException(
260+
'Invalid value for "dashboardRole": "${requestBody['dashboardRole']}".',
261+
);
262+
}
263+
}
264+
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,
228276
);
229-
return repo.update(id: id, item: updatedUser, userId: uid);
277+
return repo.update(id: id, item: userWithUpdates, userId: uid);
230278
},
231279
'user_app_settings': (c, id, item, uid) => c
232280
.read<DataRepository<UserAppSettings>>()

lib/src/registry/model_registry.dart

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -281,17 +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.
284286
postPermission: const ModelActionPermission(
285-
type: RequiredPermissionType
286-
.unsupported, // User creation handled by auth routes
287+
type: RequiredPermissionType.specificPermission,
288+
permission: Permissions.userCreate,
287289
requiresAuthentication: true,
288290
),
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.
289296
putPermission: const ModelActionPermission(
290297
type: RequiredPermissionType.specificPermission,
291298
permission: Permissions.userUpdateOwned, // User can update their own
292299
requiresOwnershipCheck: true, // Must be the owner
293300
requiresAuthentication: true,
294301
),
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.
295306
deletePermission: const ModelActionPermission(
296307
type: RequiredPermissionType.specificPermission,
297308
permission: Permissions.userDeleteOwned, // User can delete their own

routes/api/v1/data/[id]/index.dart

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -68,27 +68,41 @@ Future<Response> _handlePut(RequestContext context, String id) async {
6868

6969
requestBody['updatedAt'] = DateTime.now().toUtc().toIso8601String();
7070

71+
// The item to be passed to the updater function.
72+
// For 'user' updates, this will be the raw request body map to allow for
73+
// secure, selective field merging in the DataOperationRegistry.
74+
// For all other models, it's the deserialized object.
7175
dynamic itemToUpdate;
72-
try {
73-
itemToUpdate = modelConfig.fromJson(requestBody);
74-
} on TypeError catch (e, s) {
75-
_logger.warning('Deserialization TypeError in PUT /data/[id]', e, s);
76-
throw const BadRequestException(
77-
'Invalid request body: Missing or invalid required field(s).',
78-
);
79-
}
8076

81-
try {
82-
final bodyItemId = modelConfig.getId(itemToUpdate);
83-
if (bodyItemId != id) {
84-
throw BadRequestException(
85-
'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").',
77+
if (modelName == 'user') {
78+
// For user updates, we pass the raw map to the updater.
79+
// This allows the updater to selectively apply fields, preventing mass
80+
// assignment vulnerabilities. The ID check is also skipped as the request
81+
// body for a user role update will not contain an ID.
82+
_logger.finer('User model update: using raw request body for updater.');
83+
itemToUpdate = requestBody;
84+
} else {
85+
// For all other models, deserialize the body into a model instance.
86+
try {
87+
itemToUpdate = modelConfig.fromJson(requestBody);
88+
} on TypeError catch (e, s) {
89+
_logger.warning('Deserialization TypeError in PUT /data/[id]', e, s);
90+
throw const BadRequestException(
91+
'Invalid request body: Missing or invalid required field(s).',
8692
);
8793
}
88-
} catch (e) {
89-
// Ignore if getId throws, as the ID might not be in the body,
90-
// which can be acceptable for some models.
91-
_logger.info('Could not get ID from PUT body: $e');
94+
95+
// Validate that the ID in the body matches the ID in the path.
96+
try {
97+
final bodyItemId = modelConfig.getId(itemToUpdate);
98+
if (bodyItemId != id) {
99+
throw BadRequestException(
100+
'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").',
101+
);
102+
}
103+
} catch (e) {
104+
_logger.info('Could not get ID from PUT body: $e');
105+
}
92106
}
93107

94108
if (modelName == 'user_content_preferences') {

routes/api/v1/data/index.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,14 @@ Future<Response> _handlePost(RequestContext context) async {
117117
throw const BadRequestException('Missing or invalid request body.');
118118
}
119119

120+
// For user creation, ensure the email field is present.
121+
if (modelName == 'user') {
122+
if (!requestBody.containsKey('email') ||
123+
(requestBody['email'] as String).isEmpty) {
124+
throw const BadRequestException('Missing required field: "email".');
125+
}
126+
}
127+
120128
final now = DateTime.now().toUtc().toIso8601String();
121129
requestBody['id'] = ObjectId().oid;
122130
requestBody['createdAt'] = now;

0 commit comments

Comments
 (0)