Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/src/rbac/permissions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ abstract class Permissions {
// Allows deleting the authenticated user's own account
static const String userDeleteOwned = 'user.delete_owned';

// Allows creating a new user (admin-only).
static const String userCreate = 'user.create';
// Allows updating any user's profile (admin-only).
static const String userUpdate = 'user.update';
// Allows deleting any user's account (admin-only).
static const String userDelete = 'user.delete';

// User App Settings Permissions (User-owned)
static const String userAppSettingsReadOwned = 'user_app_settings.read_owned';
static const String userAppSettingsUpdateOwned =
Expand Down
6 changes: 5 additions & 1 deletion lib/src/rbac/role_permissions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ final Set<String> _dashboardAdminPermissions = {
Permissions.languageCreate,
Permissions.languageUpdate,
Permissions.languageDelete,
Permissions.userRead, // Allows reading any user's profile
Permissions.userRead, // Allows reading any user's profile.
// Allow full user account management for admins.
Permissions.userCreate,
Permissions.userUpdate,
Permissions.userDelete,
Permissions.remoteConfigCreate,
Permissions.remoteConfigUpdate,
Permissions.remoteConfigDelete,
Expand Down
42 changes: 39 additions & 3 deletions lib/src/registry/data_operation_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,11 @@ class DataOperationRegistry {
item: item as Language,
userId: uid,
),
// Handler for creating a new user.
'user': (c, item, uid) => c.read<DataRepository<User>>().create(
item: item as User,
userId: uid,
),
'remote_config': (c, item, uid) => c
.read<DataRepository<RemoteConfig>>()
.create(item: item as RemoteConfig, userId: uid),
Expand Down Expand Up @@ -220,13 +225,44 @@ class DataOperationRegistry {
'language': (c, id, item, uid) => c
.read<DataRepository<Language>>()
.update(id: id, item: item as Language, userId: uid),
// Custom updater for the 'user' model.
// This updater handles two distinct use cases:
// 1. Admins updating user roles (`appRole`, `dashboardRole`).
// 2. Regular users updating their own `feedDecoratorStatus`.
// It accepts a raw Map<String, dynamic> as the `item` to prevent
// mass assignment vulnerabilities, only applying allowed fields.
'user': (c, id, item, uid) {
final repo = c.read<DataRepository<User>>();
final existingUser = c.read<FetchedItem<dynamic>>().data as User;
final updatedUser = existingUser.copyWith(
feedDecoratorStatus: (item as User).feedDecoratorStatus,
final requestBody = item as Map<String, dynamic>;

AppUserRole? newAppRole;
if (requestBody.containsKey('appRole')) {
newAppRole = AppUserRole.values.byName(
requestBody['appRole'] as String,
);
}

DashboardUserRole? newDashboardRole;
if (requestBody.containsKey('dashboardRole')) {
newDashboardRole = DashboardUserRole.values.byName(
requestBody['dashboardRole'] as String,
);
}

Map<FeedDecoratorType, UserFeedDecoratorStatus>? newStatus;
if (requestBody.containsKey('feedDecoratorStatus')) {
newStatus = User.fromJson(
{'feedDecoratorStatus': requestBody['feedDecoratorStatus']},
).feedDecoratorStatus;
}

final userWithUpdates = existingUser.copyWith(
appRole: newAppRole,
dashboardRole: newDashboardRole,
feedDecoratorStatus: newStatus,
);
return repo.update(id: id, item: updatedUser, userId: uid);
return repo.update(id: id, item: userWithUpdates, userId: uid);
},
'user_app_settings': (c, id, item, uid) => c
.read<DataRepository<UserAppSettings>>()
Expand Down
15 changes: 13 additions & 2 deletions lib/src/registry/model_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -281,17 +281,28 @@ final modelRegistry = <String, ModelConfig<dynamic>>{
requiresOwnershipCheck: true, // Must be the owner
requiresAuthentication: true,
),
// Admins can create users via the data endpoint.
// User creation via auth routes (e.g., sign-up) is separate.
postPermission: const ModelActionPermission(
type: RequiredPermissionType
.unsupported, // User creation handled by auth routes
type: RequiredPermissionType.specificPermission,
permission: Permissions.userCreate,
requiresAuthentication: true,
),
// An admin can update any user's roles.
// A regular user can update specific fields on their own profile
// (e.g., feedDecoratorStatus), which is handled by the updater logic
// in DataOperationRegistry. The ownership check ensures they can only
// access their own user object to begin with.
putPermission: const ModelActionPermission(
type: RequiredPermissionType.specificPermission,
permission: Permissions.userUpdateOwned, // User can update their own
requiresOwnershipCheck: true, // Must be the owner
requiresAuthentication: true,
),
// An admin can delete any user.
// A regular user can delete their own account.
// The ownership check middleware is bypassed for admins, so this single
// config works for both roles.
deletePermission: const ModelActionPermission(
type: RequiredPermissionType.specificPermission,
permission: Permissions.userDeleteOwned, // User can delete their own
Expand Down
48 changes: 31 additions & 17 deletions routes/api/v1/data/[id]/index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,27 +68,41 @@ Future<Response> _handlePut(RequestContext context, String id) async {

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

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

try {
final bodyItemId = modelConfig.getId(itemToUpdate);
if (bodyItemId != id) {
throw BadRequestException(
'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").',
if (modelName == 'user') {
// For user updates, we pass the raw map to the updater.
// This allows the updater to selectively apply fields, preventing mass
// assignment vulnerabilities. The ID check is also skipped as the request
// body for a user role update will not contain an ID.
_logger.finer('User model update: using raw request body for updater.');
itemToUpdate = requestBody;
} else {
// For all other models, deserialize the body into a model instance.
try {
itemToUpdate = modelConfig.fromJson(requestBody);
} on TypeError catch (e, s) {
_logger.warning('Deserialization TypeError in PUT /data/[id]', e, s);
throw const BadRequestException(
'Invalid request body: Missing or invalid required field(s).',
);
}
} catch (e) {
// Ignore if getId throws, as the ID might not be in the body,
// which can be acceptable for some models.
_logger.info('Could not get ID from PUT body: $e');

// Validate that the ID in the body matches the ID in the path.
try {
final bodyItemId = modelConfig.getId(itemToUpdate);
if (bodyItemId != id) {
throw BadRequestException(
'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").',
);
}
} catch (e) {
_logger.info('Could not get ID from PUT body: $e');
}
}

if (modelName == 'user_content_preferences') {
Expand Down
8 changes: 8 additions & 0 deletions routes/api/v1/data/index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ Future<Response> _handlePost(RequestContext context) async {
throw const BadRequestException('Missing or invalid request body.');
}

// For user creation, ensure the email field is present.
if (modelName == 'user') {
if (!requestBody.containsKey('email') ||
(requestBody['email'] as String).isEmpty) {
throw const BadRequestException('Missing required field: "email".');
}
}

final now = DateTime.now().toUtc().toIso8601String();
requestBody['id'] = ObjectId().oid;
requestBody['createdAt'] = now;
Expand Down
Loading