Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Refactor/implement the required user management capabilities for administrators #88

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all 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
View file Open in desktop
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
View file Open in desktop
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
54 changes: 51 additions & 3 deletions lib/src/registry/data_operation_registry.dart
View file Open in desktop
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,56 @@ 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')) {
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<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
View file Open in desktop
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
View file Open in desktop
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
View file Open in desktop
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

AltStyle γ«γ‚ˆγ£γ¦ε€‰ζ›γ•γ‚ŒγŸγƒšγƒΌγ‚Έ (->γ‚ͺγƒͺγ‚ΈγƒŠγƒ«) /