diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index 8bd383f..31c6342 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -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 = diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart index a6660ef..f0cc81f 100644 --- a/lib/src/rbac/role_permissions.dart +++ b/lib/src/rbac/role_permissions.dart @@ -68,7 +68,11 @@ final Set _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, diff --git a/lib/src/registry/data_operation_registry.dart b/lib/src/registry/data_operation_registry.dart index c8cb253..b51146d 100644 --- a/lib/src/registry/data_operation_registry.dart +++ b/lib/src/registry/data_operation_registry.dart @@ -188,6 +188,11 @@ class DataOperationRegistry { item: item as Language, userId: uid, ), + // Handler for creating a new user. + 'user': (c, item, uid) => c.read>().create( + item: item as User, + userId: uid, + ), 'remote_config': (c, item, uid) => c .read>() .create(item: item as RemoteConfig, userId: uid), @@ -220,13 +225,56 @@ class DataOperationRegistry { 'language': (c, id, item, uid) => c .read>() .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 as the `item` to prevent + // mass assignment vulnerabilities, only applying allowed fields. 'user': (c, id, item, uid) { final repo = c.read>(); final existingUser = c.read>().data as User; - final updatedUser = existingUser.copyWith( - feedDecoratorStatus: (item as User).feedDecoratorStatus, + final requestBody = item as Map; + + AppUserRole? newAppRole; + if (requestBody.containsKey('appRole')) { + try { + newAppRole = AppUserRole.values.byName( + requestBody['appRole'] as String, + ); + } on ArgumentError { + throw BadRequestException( + 'Invalid value for "appRole": "${requestBody['appRole']}".', + ); + } + } + + DashboardUserRole? newDashboardRole; + if (requestBody.containsKey('dashboardRole')) { + try { + newDashboardRole = DashboardUserRole.values.byName( + requestBody['dashboardRole'] as String, + ); + } on ArgumentError { + throw BadRequestException( + 'Invalid value for "dashboardRole": "${requestBody['dashboardRole']}".', + ); + } + } + + Map? 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>() diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index 1e89284..bbe9ce3 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -281,17 +281,28 @@ final modelRegistry = >{ 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 diff --git a/routes/api/v1/data/[id]/index.dart b/routes/api/v1/data/[id]/index.dart index 4744c1d..8b929b0 100644 --- a/routes/api/v1/data/[id]/index.dart +++ b/routes/api/v1/data/[id]/index.dart @@ -68,27 +68,41 @@ Future _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') { diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index ef00241..ab541e9 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -117,6 +117,14 @@ Future _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;

AltStyle によって変換されたページ (->オリジナル) /