From 28b2e161bdd1e3fda526f64ec36f67f0c536b9f5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 16 May 2025 16:14:47 +0100 Subject: [PATCH 1/8] feat: implement RBAC for access control - Added permissions and roles - Created AuthorizationService - Defined role-permission mapping --- lib/src/permissions.dart | 96 +++++++++++++++++++++ lib/src/services/authorization_service.dart | 35 ++++++++ rbac-plan.dart | 51 +++++++++++ 3 files changed, 182 insertions(+) create mode 100644 lib/src/permissions.dart create mode 100644 lib/src/services/authorization_service.dart create mode 100644 rbac-plan.dart diff --git a/lib/src/permissions.dart b/lib/src/permissions.dart new file mode 100644 index 0000000..1c11637 --- /dev/null +++ b/lib/src/permissions.dart @@ -0,0 +1,96 @@ +/// Defines the roles and permissions used in the RBAC system. +/// +/// Permissions are defined as constants in the format `resource.action`. +/// Roles are defined as constants. +/// The `rolePermissions` map defines which permissions are granted to each role. + +/// {@template role} +/// Defines the available user roles in the system. +/// {@endtemplate} +abstract class Role { + /// Administrator role with full access. + static const String admin = 'admin'; + + /// Standard user role with limited access. + static const String standardUser = 'standard_user'; + + // Add other roles here as needed. +} + +/// {@template permission} +/// Defines the available permissions in the system. +/// +/// Permissions follow the format `resource.action`. +/// {@endtemplate} +abstract class Permission { + // Headline Permissions + static const String headlineRead = 'headline.read'; + static const String headlineCreate = 'headline.create'; + static const String headlineUpdate = 'headline.update'; + static const String headlineDelete = 'headline.delete'; + + // Category Permissions + static const String categoryRead = 'category.read'; + static const String categoryCreate = 'category.create'; + static const String categoryUpdate = 'category.update'; + static const String categoryDelete = 'category.delete'; + + // Source Permissions + static const String sourceRead = 'source.read'; + static const String sourceCreate = 'source.create'; + static const String sourceUpdate = 'source.update'; + static const String sourceDelete = 'source.delete'; + + // Country Permissions + static const String countryRead = 'country.read'; + static const String countryCreate = 'country.create'; + static const String countryUpdate = 'country.update'; + static const String countryDelete = 'country.delete'; + + // User Settings Permissions + static const String userSettingsRead = 'user_settings.read'; + static const String userSettingsUpdate = 'user_settings.update'; + // Note: User settings delete is handled via account deletion, no separate permission needed here. + + // Add other resource permissions here as needed. +} + +/// A map defining which permissions are granted to each role. +/// +/// The key is the role string, and the value is a set of permission strings. +final Map> rolePermissions = { + Role.admin: { + // Admins have all permissions. In a real system, you might have a more + // sophisticated way to represent this, but listing them explicitly is clear. + Permission.headlineRead, + Permission.headlineCreate, + Permission.headlineUpdate, + Permission.headlineDelete, + Permission.categoryRead, + Permission.categoryCreate, + Permission.categoryUpdate, + Permission.categoryDelete, + Permission.sourceRead, + Permission.sourceCreate, + Permission.sourceUpdate, + Permission.sourceDelete, + Permission.countryRead, + Permission.countryCreate, + Permission.countryUpdate, + Permission.countryDelete, + Permission.userSettingsRead, + Permission.userSettingsUpdate, + // Add other admin permissions here. + }, + Role.standardUser: { + // Standard users can read public data and manage their own settings. + Permission.headlineRead, + Permission.categoryRead, + Permission.sourceRead, + Permission.countryRead, + Permission.userSettingsRead, // Can read their own settings + Permission.userSettingsUpdate, // Can update their own settings + // Add other standard user permissions here. + }, + // Add mappings for other roles here. +}; diff --git a/lib/src/services/authorization_service.dart b/lib/src/services/authorization_service.dart new file mode 100644 index 0000000..2d5bf7a --- /dev/null +++ b/lib/src/services/authorization_service.dart @@ -0,0 +1,35 @@ +import 'package:ht_api/src/permissions.dart'; +import 'package:ht_shared/ht_shared.dart'; // Assuming User model is here + +/// {@template authorization_service} +/// Service responsible for checking user permissions based on roles. +/// {@endtemplate} +class AuthorizationService { + /// {@macro authorization_service} + const AuthorizationService(); + + /// Checks if the given [user] has the specified [permission]. + /// + /// Assumes the [User] model has a `role` property (String). + /// Returns `true` if the user has the permission, `false` otherwise. + bool hasPermission(User user, String permission) { + // Admins always have permission. + // Assuming user.role exists and 'admin' is the admin role string. + if (user.role == Role.admin) { + return true; + } + + // Get the permissions for the user's role. + final permissionsForRole = rolePermissions[user.role]; + + // If the role is not found or has no permissions, deny access. + if (permissionsForRole == null) { + return false; + } + + // Check if the requested permission is in the set of permissions for the role. + return permissionsForRole.contains(permission); + } + + // Optional: Add methods for checking ownership or other complex authorization rules here later. +} diff --git a/rbac-plan.dart b/rbac-plan.dart new file mode 100644 index 0000000..ebef1d3 --- /dev/null +++ b/rbac-plan.dart @@ -0,0 +1,51 @@ +/// High-Level Plan for Implementing Role-Based Access Control (RBAC) +/// in the Headlines Toolkit API (ht-api). +/// +/// This plan outlines the key tasks required to transition from the basic +/// ModelOwnership approach to a more flexible RBAC system, tailored to the +/// project's existing architecture and shared packages. +/// +/// This is a high-level overview and does not include implementation details. + +// Assuming the User model in ht_shared has been updated to include a 'role' property. + +// 1. Define Roles and Permissions (Initially within ht_api) +// - Determine the specific roles needed (e.g., admin, standard_user, editor). +// - Define granular permissions for each resource and action (e.g., 'headline.read', +// 'category.create', 'user_settings.update', 'favorite_list.add'). +// - Store these permission strings as static constants within the ht_api package +// (e.g., in a new file like lib/src/permissions.dart). +// - Map which permissions are assigned to which roles (e.g., using a Map or class +// within ht_api). + +// 2. Create Authorization Service (Within ht_api) +// - Implement a dedicated service (e.g., AuthorizationService in lib/src/services/) +// that encapsulates the logic for checking permissions. +// - This service will take an authenticated User object and a requested permission +// string, and determine if the user's role(s) grant them that permission based +// on the hardcoded role-permission mapping. + +// 3. Integrate Authorization Checks into Middleware/Routes +// - Modify the /api/v1/data/_middleware.dart to check permissions based on +// the requested model and HTTP method using the new AuthorizationService. +// (e.g., check for 'modelName.read' for GET, 'modelName.create' for POST). +// - For user-owned resources (like settings or future favorite lists), update +// middleware (e.g., routes/api/v1/users/[userId]/settings/_middleware.dart) +// or handlers to combine the permission check (e.g., 'user_settings.update') +// with the ownership check (authenticated user ID matches resource owner ID, +// unless user is admin). + +// 4. Refine Ownership Checks (Within ht_api middleware/handlers or Repositories) +// - For user-owned resources, ensure repository methods accept the authenticated +// userId and enforce that operations are limited to resources owned by that user +// (unless the user is an admin). This pattern is already partially used and +// can be consistently applied. + +// 5. Refactor Existing Access Control +// - Replace existing basic isAdmin checks and simple ownership comparisons +// in route handlers with calls to the new AuthorizationService and/or rely +// on the updated middleware/repository checks. + +// 6. Add Tests +// - Write unit and integration tests for the new AuthorizationService, middleware, +// and affected route handlers to ensure permissions and ownership are enforced correctly. From d4badaf45c12a28c5bc4cb87cb2ff456db476fd5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 16 May 2025 16:40:13 +0100 Subject: [PATCH 2/8] style: misc --- analysis_options.yaml | 1 + lib/src/permissions.dart | 6 ++++- rbac-plan.dart | 51 ---------------------------------------- 3 files changed, 6 insertions(+), 52 deletions(-) delete mode 100644 rbac-plan.dart diff --git a/analysis_options.yaml b/analysis_options.yaml index 79a89d5..6302629 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -8,6 +8,7 @@ analyzer: lines_longer_than_80_chars: ignore avoid_dynamic_calls: ignore avoid_catching_errors: ignore + document_ignores: ignore exclude: - build/** linter: diff --git a/lib/src/permissions.dart b/lib/src/permissions.dart index 1c11637..0688324 100644 --- a/lib/src/permissions.dart +++ b/lib/src/permissions.dart @@ -1,9 +1,13 @@ +// ignore_for_file: public_member_api_docs + /// Defines the roles and permissions used in the RBAC system. /// /// Permissions are defined as constants in the format `resource.action`. /// Roles are defined as constants. /// The `rolePermissions` map defines which permissions are granted to each role. +library; + /// {@template role} /// Defines the available user roles in the system. /// {@endtemplate} @@ -60,7 +64,7 @@ abstract class Permission { /// The key is the role string, and the value is a set of permission strings. final Map> rolePermissions = { Role.admin: { - // Admins have all permissions. In a real system, you might have a more + // Admins have all permissions. You might have a more // sophisticated way to represent this, but listing them explicitly is clear. Permission.headlineRead, Permission.headlineCreate, diff --git a/rbac-plan.dart b/rbac-plan.dart deleted file mode 100644 index ebef1d3..0000000 --- a/rbac-plan.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// High-Level Plan for Implementing Role-Based Access Control (RBAC) -/// in the Headlines Toolkit API (ht-api). -/// -/// This plan outlines the key tasks required to transition from the basic -/// ModelOwnership approach to a more flexible RBAC system, tailored to the -/// project's existing architecture and shared packages. -/// -/// This is a high-level overview and does not include implementation details. - -// Assuming the User model in ht_shared has been updated to include a 'role' property. - -// 1. Define Roles and Permissions (Initially within ht_api) -// - Determine the specific roles needed (e.g., admin, standard_user, editor). -// - Define granular permissions for each resource and action (e.g., 'headline.read', -// 'category.create', 'user_settings.update', 'favorite_list.add'). -// - Store these permission strings as static constants within the ht_api package -// (e.g., in a new file like lib/src/permissions.dart). -// - Map which permissions are assigned to which roles (e.g., using a Map or class -// within ht_api). - -// 2. Create Authorization Service (Within ht_api) -// - Implement a dedicated service (e.g., AuthorizationService in lib/src/services/) -// that encapsulates the logic for checking permissions. -// - This service will take an authenticated User object and a requested permission -// string, and determine if the user's role(s) grant them that permission based -// on the hardcoded role-permission mapping. - -// 3. Integrate Authorization Checks into Middleware/Routes -// - Modify the /api/v1/data/_middleware.dart to check permissions based on -// the requested model and HTTP method using the new AuthorizationService. -// (e.g., check for 'modelName.read' for GET, 'modelName.create' for POST). -// - For user-owned resources (like settings or future favorite lists), update -// middleware (e.g., routes/api/v1/users/[userId]/settings/_middleware.dart) -// or handlers to combine the permission check (e.g., 'user_settings.update') -// with the ownership check (authenticated user ID matches resource owner ID, -// unless user is admin). - -// 4. Refine Ownership Checks (Within ht_api middleware/handlers or Repositories) -// - For user-owned resources, ensure repository methods accept the authenticated -// userId and enforce that operations are limited to resources owned by that user -// (unless the user is an admin). This pattern is already partially used and -// can be consistently applied. - -// 5. Refactor Existing Access Control -// - Replace existing basic isAdmin checks and simple ownership comparisons -// in route handlers with calls to the new AuthorizationService and/or rely -// on the updated middleware/repository checks. - -// 6. Add Tests -// - Write unit and integration tests for the new AuthorizationService, middleware, -// and affected route handlers to ensure permissions and ownership are enforced correctly. From 0a83508000874cb830e2c72b897a2bcf7f7711c8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Fri, 16 May 2025 19:09:16 +0100 Subject: [PATCH 3/8] refactor: use shared models for permissions - Use UserRole enum - Use Permission class --- lib/src/permissions.dart | 111 ++++++-------------- lib/src/services/authorization_service.dart | 4 +- 2 files changed, 33 insertions(+), 82 deletions(-) diff --git a/lib/src/permissions.dart b/lib/src/permissions.dart index 0688324..f2156ab 100644 --- a/lib/src/permissions.dart +++ b/lib/src/permissions.dart @@ -1,3 +1,5 @@ +import 'package:ht_shared/ht_shared.dart'; + // ignore_for_file: public_member_api_docs /// Defines the roles and permissions used in the RBAC system. @@ -6,94 +8,43 @@ /// Roles are defined as constants. /// The `rolePermissions` map defines which permissions are granted to each role. -library; - -/// {@template role} -/// Defines the available user roles in the system. -/// {@endtemplate} -abstract class Role { - /// Administrator role with full access. - static const String admin = 'admin'; - - /// Standard user role with limited access. - static const String standardUser = 'standard_user'; - - // Add other roles here as needed. -} - -/// {@template permission} -/// Defines the available permissions in the system. -/// -/// Permissions follow the format `resource.action`. -/// {@endtemplate} -abstract class Permission { - // Headline Permissions - static const String headlineRead = 'headline.read'; - static const String headlineCreate = 'headline.create'; - static const String headlineUpdate = 'headline.update'; - static const String headlineDelete = 'headline.delete'; - - // Category Permissions - static const String categoryRead = 'category.read'; - static const String categoryCreate = 'category.create'; - static const String categoryUpdate = 'category.update'; - static const String categoryDelete = 'category.delete'; - - // Source Permissions - static const String sourceRead = 'source.read'; - static const String sourceCreate = 'source.create'; - static const String sourceUpdate = 'source.update'; - static const String sourceDelete = 'source.delete'; - - // Country Permissions - static const String countryRead = 'country.read'; - static const String countryCreate = 'country.create'; - static const String countryUpdate = 'country.update'; - static const String countryDelete = 'country.delete'; - - // User Settings Permissions - static const String userSettingsRead = 'user_settings.read'; - static const String userSettingsUpdate = 'user_settings.update'; - // Note: User settings delete is handled via account deletion, no separate permission needed here. - - // Add other resource permissions here as needed. -} - /// A map defining which permissions are granted to each role. /// /// The key is the role string, and the value is a set of permission strings. -final Map> rolePermissions = { - Role.admin: { +final Map> rolePermissions = { + UserRole.admin: { // Admins have all permissions. You might have a more // sophisticated way to represent this, but listing them explicitly is clear. - Permission.headlineRead, - Permission.headlineCreate, - Permission.headlineUpdate, - Permission.headlineDelete, - Permission.categoryRead, - Permission.categoryCreate, - Permission.categoryUpdate, - Permission.categoryDelete, - Permission.sourceRead, - Permission.sourceCreate, - Permission.sourceUpdate, - Permission.sourceDelete, - Permission.countryRead, - Permission.countryCreate, - Permission.countryUpdate, - Permission.countryDelete, - Permission.userSettingsRead, - Permission.userSettingsUpdate, + const Permission(name: 'headlineRead'), + const Permission(name: 'headlineCreate'), + const Permission(name: 'headlineUpdate'), + const Permission(name: 'headlineDelete'), + const Permission(name: 'categoryRead'), + const Permission(name: 'categoryCreate'), + const Permission(name: 'categoryUpdate'), + const Permission(name: 'categoryDelete'), + const Permission(name: 'sourceRead'), + const Permission(name: 'sourceCreate'), + const Permission(name: 'sourceUpdate'), + const Permission(name: 'sourceDelete'), + const Permission(name: 'countryRead'), + const Permission(name: 'countryCreate'), + const Permission(name: 'countryUpdate'), + const Permission(name: 'countryDelete'), + const Permission(name: 'userSettingsRead'), + const Permission(name: 'userSettingsUpdate'), // Add other admin permissions here. }, - Role.standardUser: { + UserRole.standardUser: { // Standard users can read public data and manage their own settings. - Permission.headlineRead, - Permission.categoryRead, - Permission.sourceRead, - Permission.countryRead, - Permission.userSettingsRead, // Can read their own settings - Permission.userSettingsUpdate, // Can update their own settings + const Permission(name: 'headlineRead'), + const Permission(name: 'categoryRead'), + const Permission(name: 'sourceRead'), + const Permission(name: 'countryRead'), + const Permission(name: 'userSettingsRead'), // Can read their own settings + const Permission( + name: 'userSettingsUpdate', + ), // Can update their own settings // Add other standard user permissions here. }, // Add mappings for other roles here. diff --git a/lib/src/services/authorization_service.dart b/lib/src/services/authorization_service.dart index 2d5bf7a..3790486 100644 --- a/lib/src/services/authorization_service.dart +++ b/lib/src/services/authorization_service.dart @@ -12,10 +12,10 @@ class AuthorizationService { /// /// Assumes the [User] model has a `role` property (String). /// Returns `true` if the user has the permission, `false` otherwise. - bool hasPermission(User user, String permission) { + bool hasPermission(User user, Permission permission) { // Admins always have permission. // Assuming user.role exists and 'admin' is the admin role string. - if (user.role == Role.admin) { + if (user.role == UserRole.admin) { return true; } From 56a4affce3adcbd2ebf3c4f548e8848b495a2ab1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 17 May 2025 06:16:47 +0100 Subject: [PATCH 4/8] chore: remove permissions and auth service --- lib/src/permissions.dart | 51 --------------------- lib/src/services/authorization_service.dart | 35 -------------- 2 files changed, 86 deletions(-) delete mode 100644 lib/src/permissions.dart delete mode 100644 lib/src/services/authorization_service.dart diff --git a/lib/src/permissions.dart b/lib/src/permissions.dart deleted file mode 100644 index f2156ab..0000000 --- a/lib/src/permissions.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:ht_shared/ht_shared.dart'; - -// ignore_for_file: public_member_api_docs - -/// Defines the roles and permissions used in the RBAC system. -/// -/// Permissions are defined as constants in the format `resource.action`. -/// Roles are defined as constants. -/// The `rolePermissions` map defines which permissions are granted to each role. - -/// A map defining which permissions are granted to each role. -/// -/// The key is the role string, and the value is a set of permission strings. -final Map> rolePermissions = { - UserRole.admin: { - // Admins have all permissions. You might have a more - // sophisticated way to represent this, but listing them explicitly is clear. - const Permission(name: 'headlineRead'), - const Permission(name: 'headlineCreate'), - const Permission(name: 'headlineUpdate'), - const Permission(name: 'headlineDelete'), - const Permission(name: 'categoryRead'), - const Permission(name: 'categoryCreate'), - const Permission(name: 'categoryUpdate'), - const Permission(name: 'categoryDelete'), - const Permission(name: 'sourceRead'), - const Permission(name: 'sourceCreate'), - const Permission(name: 'sourceUpdate'), - const Permission(name: 'sourceDelete'), - const Permission(name: 'countryRead'), - const Permission(name: 'countryCreate'), - const Permission(name: 'countryUpdate'), - const Permission(name: 'countryDelete'), - const Permission(name: 'userSettingsRead'), - const Permission(name: 'userSettingsUpdate'), - // Add other admin permissions here. - }, - UserRole.standardUser: { - // Standard users can read public data and manage their own settings. - const Permission(name: 'headlineRead'), - const Permission(name: 'categoryRead'), - const Permission(name: 'sourceRead'), - const Permission(name: 'countryRead'), - const Permission(name: 'userSettingsRead'), // Can read their own settings - const Permission( - name: 'userSettingsUpdate', - ), // Can update their own settings - // Add other standard user permissions here. - }, - // Add mappings for other roles here. -}; diff --git a/lib/src/services/authorization_service.dart b/lib/src/services/authorization_service.dart deleted file mode 100644 index 3790486..0000000 --- a/lib/src/services/authorization_service.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:ht_api/src/permissions.dart'; -import 'package:ht_shared/ht_shared.dart'; // Assuming User model is here - -/// {@template authorization_service} -/// Service responsible for checking user permissions based on roles. -/// {@endtemplate} -class AuthorizationService { - /// {@macro authorization_service} - const AuthorizationService(); - - /// Checks if the given [user] has the specified [permission]. - /// - /// Assumes the [User] model has a `role` property (String). - /// Returns `true` if the user has the permission, `false` otherwise. - bool hasPermission(User user, Permission permission) { - // Admins always have permission. - // Assuming user.role exists and 'admin' is the admin role string. - if (user.role == UserRole.admin) { - return true; - } - - // Get the permissions for the user's role. - final permissionsForRole = rolePermissions[user.role]; - - // If the role is not found or has no permissions, deny access. - if (permissionsForRole == null) { - return false; - } - - // Check if the requested permission is in the set of permissions for the role. - return permissionsForRole.contains(permission); - } - - // Optional: Add methods for checking ownership or other complex authorization rules here later. -} From e8ea54cf5703a8082f3b7994ce391ce0e612ac54 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sat, 17 May 2025 06:27:18 +0100 Subject: [PATCH 5/8] fix(auth): use UserRole enum instead of bool - Replaced isAnonymous and isAdmin - Used UserRole enum for clarity - Updated auth checks accordingly --- lib/src/services/auth_service.dart | 13 +++++-------- lib/src/services/jwt_auth_token_service.dart | 2 +- routes/api/v1/auth/link-email.dart | 2 +- routes/api/v1/auth/verify-link-email.dart | 2 +- routes/api/v1/data/[id].dart | 10 +++++----- routes/api/v1/data/index.dart | 4 ++-- test/src/services/jwt_auth_token_service_test.dart | 5 ++--- 7 files changed, 17 insertions(+), 21 deletions(-) diff --git a/lib/src/services/auth_service.dart b/lib/src/services/auth_service.dart index 0503a2d..55b880c 100644 --- a/lib/src/services/auth_service.dart +++ b/lib/src/services/auth_service.dart @@ -115,8 +115,7 @@ class AuthService { user = User( id: _uuid.v4(), // Generate new ID email: email, - isAnonymous: false, // Email verified user is not anonymous - isAdmin: false, + role: UserRole.standardUser, // Email verified user is standard user ); user = await _userRepository.create(item: user); // Save the new user print('Created new user: ${user.id}'); @@ -155,9 +154,8 @@ class AuthService { try { user = User( id: _uuid.v4(), // Generate new ID - isAnonymous: true, + role: UserRole.guestUser, // Anonymous users are guest users email: null, // Anonymous users don't have an email initially - isAdmin: false, ); user = await _userRepository.create(item: user); print('Created anonymous user: ${user.id}'); @@ -248,7 +246,7 @@ class AuthService { required User anonymousUser, required String emailToLink, }) async { - if (!anonymousUser.isAnonymous) { + if (anonymousUser.role != UserRole.guestUser) { throw const BadRequestException( 'Account is already permanent. Cannot link email.', ); @@ -310,7 +308,7 @@ class AuthService { required String codeFromUser, required String oldAnonymousToken, // Needed to invalidate it }) async { - if (!anonymousUser.isAnonymous) { + if (anonymousUser.role != UserRole.guestUser) { // Should ideally not happen if flow is correct, but good safeguard. throw const BadRequestException( 'Account is already permanent. Cannot complete email linking.', @@ -335,8 +333,7 @@ class AuthService { final updatedUser = User( id: anonymousUser.id, // Preserve original ID email: linkedEmail, - isAnonymous: false, // Now a permanent user - isAdmin: false, + role: UserRole.standardUser, // Now a permanent standard user ); final permanentUser = await _userRepository.update( id: updatedUser.id, diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index 511f202..739d620 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -64,7 +64,7 @@ class JwtAuthTokenService implements AuthTokenService { // Custom claims (optional, include what's useful) 'email': user.email, - 'isAnonymous': user.isAnonymous, + 'role': user.role, // Include the user's role }, issuer: _issuer, subject: user.id, diff --git a/routes/api/v1/auth/link-email.dart b/routes/api/v1/auth/link-email.dart index 9c31493..e708a01 100644 --- a/routes/api/v1/auth/link-email.dart +++ b/routes/api/v1/auth/link-email.dart @@ -22,7 +22,7 @@ Future onRequest(RequestContext context) async { // This should ideally be caught by `authenticationProvider` if route is protected throw const UnauthorizedException('Authentication required to link email.'); } - if (!authenticatedUser.isAnonymous) { + if (authenticatedUser.role != UserRole.guestUser) { throw const BadRequestException( 'Account is already permanent. Cannot initiate email linking.', ); diff --git a/routes/api/v1/auth/verify-link-email.dart b/routes/api/v1/auth/verify-link-email.dart index bc2ab38..31e61db 100644 --- a/routes/api/v1/auth/verify-link-email.dart +++ b/routes/api/v1/auth/verify-link-email.dart @@ -23,7 +23,7 @@ Future onRequest(RequestContext context) async { 'Authentication required to verify email link.', ); } - if (!authenticatedUser.isAnonymous) { + if (authenticatedUser.role != UserRole.guestUser) { throw const BadRequestException( 'Account is already permanent. Cannot complete email linking.', ); diff --git a/routes/api/v1/data/[id].dart b/routes/api/v1/data/[id].dart index a447061..12a954c 100644 --- a/routes/api/v1/data/[id].dart +++ b/routes/api/v1/data/[id].dart @@ -82,7 +82,7 @@ Future _handleGet( ) async { // Apply access control based on ownership type for GET requests if (modelConfig.ownership == ModelOwnership.adminOwned && - !authenticatedUser.isAdmin) { + authenticatedUser.role != UserRole.admin) { throw const ForbiddenException( 'You do not have permission to read this resource.', ); @@ -201,13 +201,13 @@ Future _handlePut( // Apply access control based on ownership type for PUT requests if ((modelConfig.ownership == ModelOwnership.adminOwned || modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) && - !authenticatedUser.isAdmin) { + authenticatedUser.role != UserRole.admin) { throw const ForbiddenException( 'Only administrators can update this resource.', ); } if (modelConfig.ownership == ModelOwnership.userOwned && - !authenticatedUser.isAdmin) { + authenticatedUser.role != UserRole.admin) { // For userOwned, non-admins must be the owner. // The repository will enforce this check when userIdForRepoCall is passed. } @@ -351,13 +351,13 @@ Future _handleDelete( // Apply access control based on ownership type for DELETE requests if ((modelConfig.ownership == ModelOwnership.adminOwned || modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) && - !authenticatedUser.isAdmin) { + authenticatedUser.role != UserRole.admin) { throw const ForbiddenException( 'Only administrators can delete this resource.', ); } if (modelConfig.ownership == ModelOwnership.userOwned && - !authenticatedUser.isAdmin) { + authenticatedUser.role != UserRole.admin) { // For userOwned, non-admins must be the owner. // The repository will enforce this check when userIdForRepoCall is passed. } diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index cfa7869..50982cc 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -84,7 +84,7 @@ Future _handleGet( // Apply access control based on ownership type for GET requests if (modelConfig.ownership == ModelOwnership.adminOwned && - !authenticatedUser.isAdmin) { + authenticatedUser.role != UserRole.admin) { throw const ForbiddenException( 'You do not have permission to read this resource.', ); @@ -243,7 +243,7 @@ Future _handlePost( // Apply access control based on ownership type for POST requests if ((modelConfig.ownership == ModelOwnership.adminOwned || modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) && - !authenticatedUser.isAdmin) { + authenticatedUser.role != UserRole.admin) { throw const ForbiddenException( 'Only administrators can create this resource.', ); diff --git a/test/src/services/jwt_auth_token_service_test.dart b/test/src/services/jwt_auth_token_service_test.dart index 9eae560..36fc7d1 100644 --- a/test/src/services/jwt_auth_token_service_test.dart +++ b/test/src/services/jwt_auth_token_service_test.dart @@ -21,15 +21,14 @@ void main() { const testUser = User( id: 'user-jwt-123', email: 'jwt@example.com', - isAnonymous: false, - isAdmin: false, + role: UserRole.standardUser, ); const testUuidValue = 'test-uuid-v4'; setUpAll(() { // Register fallback values for argument matchers registerFallbackValue( - const User(id: 'fallback', isAnonymous: true, isAdmin: false), + const User(id: 'fallback', role: UserRole.guestUser), ); // Register fallback for DateTime if needed for blacklist mock registerFallbackValue(DateTime(2024)); From 16007a976c167f5fc8eccfc97ed5b6376fb42e63 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 18 May 2025 16:57:27 +0100 Subject: [PATCH 6/8] fix: updated lib/src/services/jwt_auth_token_service.dart to correctly serialize the UserRole enum in the JWT payload. This change addresses the JWTException seen in the server logs for the /api/v1/auth/anonymous and /api/v1/auth/verify-code routes --- .../middlewares/authorization_middleware.dart | 92 +++ lib/src/rbac/permission_service.dart | 41 ++ lib/src/rbac/permissions.dart | 54 ++ lib/src/rbac/role_permissions.dart | 72 +++ lib/src/registry/model_registry.dart | 175 +++++- lib/src/services/jwt_auth_token_service.dart | 11 +- routes/_middleware.dart | 13 +- routes/api/v1/data/[id].dart | 549 ++++++++++-------- routes/api/v1/data/_middleware.dart | 55 +- routes/api/v1/data/index.dart | 388 ++++++------- 10 files changed, 958 insertions(+), 492 deletions(-) create mode 100644 lib/src/middlewares/authorization_middleware.dart create mode 100644 lib/src/rbac/permission_service.dart create mode 100644 lib/src/rbac/permissions.dart create mode 100644 lib/src/rbac/role_permissions.dart diff --git a/lib/src/middlewares/authorization_middleware.dart b/lib/src/middlewares/authorization_middleware.dart new file mode 100644 index 0000000..b436179 --- /dev/null +++ b/lib/src/middlewares/authorization_middleware.dart @@ -0,0 +1,92 @@ +import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; +import 'package:ht_api/src/registry/model_registry.dart'; +import 'package:ht_shared/ht_shared.dart'; // For User, ForbiddenException + +/// {@template authorization_middleware} +/// Middleware to enforce role-based permissions and model-specific access rules. +/// +/// This middleware reads the authenticated [User], the requested `modelName`, +/// the `HttpMethod`, and the `ModelConfig` from the request context. It then +/// determines the required permission based on the `ModelConfig` metadata for +/// the specific HTTP method and checks if the authenticated user has that +/// permission using the [PermissionService]. +/// +/// If the user does not have the required permission, it throws a +/// [ForbiddenException], which should be caught by the [errorHandler] middleware. +/// +/// This middleware runs *after* authentication and model validation. +/// It does NOT perform instance-level ownership checks; those are handled +/// by the route handlers (`index.dart`, `[id].dart`) if required by the +/// `ModelActionPermission.requiresOwnershipCheck` flag. +/// {@endtemplate} +Middleware authorizationMiddleware() { + return (handler) { + return (context) async { + // Read dependencies from the context. + // User is guaranteed non-null by requireAuthentication() middleware. + final user = context.read(); + final permissionService = context.read(); + final modelName = context.read(); // Provided by data/_middleware + final modelConfig = context.read>(); // Provided by data/_middleware + final method = context.request.method; + + // Determine the required permission configuration based on the HTTP method + ModelActionPermission requiredPermissionConfig; + switch (method) { + case HttpMethod.get: + requiredPermissionConfig = modelConfig.getPermission; + case HttpMethod.post: + requiredPermissionConfig = modelConfig.postPermission; + case HttpMethod.put: + requiredPermissionConfig = modelConfig.putPermission; + case HttpMethod.delete: + requiredPermissionConfig = modelConfig.deletePermission; + default: + // Should ideally be caught earlier by Dart Frog's routing, + // but as a safeguard, deny unsupported methods. + throw const ForbiddenException('Method not supported for this resource.'); + } + + // Perform the permission check based on the configuration type + switch (requiredPermissionConfig.type) { + case RequiredPermissionType.none: + // No specific permission required (beyond authentication if applicable) + // This case is primarily for documentation/completeness if a route + // group didn't require authentication, but the /data route does. + // For the /data route, 'none' effectively means 'authenticated users allowed'. + break; + case RequiredPermissionType.adminOnly: + // Requires the user to be an admin + if (!permissionService.isAdmin(user)) { + throw const ForbiddenException( + 'Only administrators can perform this action.', + ); + } + case RequiredPermissionType.specificPermission: + // Requires a specific permission string + final permission = requiredPermissionConfig.permission; + if (permission == null) { + // This indicates a configuration error in ModelRegistry + print( + '[AuthorizationMiddleware] Configuration Error: specificPermission ' + 'type requires a permission string for model "$modelName", method "$method".', + ); + throw const OperationFailedException( + 'Internal Server Error: Authorization configuration error.', + ); + } + if (!permissionService.hasPermission(user, permission)) { + throw const ForbiddenException( + 'You do not have permission to perform this action.', + ); + } + } + + // If all checks pass, proceed to the next handler in the chain. + // Instance-level ownership checks (if requiredPermissionConfig.requiresOwnershipCheck is true) + // are handled by the route handlers themselves. + return handler(context); + }; + }; +} diff --git a/lib/src/rbac/permission_service.dart b/lib/src/rbac/permission_service.dart new file mode 100644 index 0000000..b95cb74 --- /dev/null +++ b/lib/src/rbac/permission_service.dart @@ -0,0 +1,41 @@ +import 'package:ht_api/src/rbac/role_permissions.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// {@template permission_service} +/// Service responsible for checking if a user has a specific permission. +/// +/// This service uses the predefined [rolePermissions] map to determine +/// a user's access rights based on their [UserRole]. It also includes +/// an explicit check for the [UserRole.admin], granting them all permissions. +/// {@endtemplate} +class PermissionService { + /// {@macro permission_service} + const PermissionService(); + + /// Checks if the given [user] has the specified [permission]. + /// + /// Returns `true` if the user's role grants the permission, or if the user + /// is an administrator. Returns `false` otherwise. + /// + /// - [user]: The authenticated user. + /// - [permission]: The permission string to check (e.g., `headline.read`). + bool hasPermission(User user, String permission) { + // Administrators have all permissions + if (user.role == UserRole.admin) { + return true; + } + + // Check if the user's role is in the map and has the permission + return rolePermissions[user.role]?.contains(permission) ?? false; + } + + /// Checks if the given [user] has the [UserRole.admin] role. + /// + /// This is a convenience method for checks that are strictly limited + /// to administrators, bypassing the permission map. + /// + /// - [user]: The authenticated user. + bool isAdmin(User user) { + return user.role == UserRole.admin; + } +} diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart new file mode 100644 index 0000000..ff23765 --- /dev/null +++ b/lib/src/rbac/permissions.dart @@ -0,0 +1,54 @@ +// ignore_for_file: public_member_api_docs + +/// Defines the available permissions in the system. +/// +/// Permissions follow the format `resource.action`. +abstract class Permissions { + // Headline Permissions + static const String headlineCreate = 'headline.create'; + static const String headlineRead = 'headline.read'; + static const String headlineUpdate = 'headline.update'; + static const String headlineDelete = 'headline.delete'; + + // Category Permissions + static const String categoryCreate = 'category.create'; + static const String categoryRead = 'category.read'; + static const String categoryUpdate = 'category.update'; + static const String categoryDelete = 'category.delete'; + + // Source Permissions + static const String sourceCreate = 'source.create'; + static const String sourceRead = 'source.read'; + static const String sourceUpdate = 'source.update'; + static const String sourceDelete = 'source.delete'; + + // Country Permissions + static const String countryCreate = 'country.create'; + static const String countryRead = 'country.read'; + static const String countryUpdate = 'country.update'; + static const String countryDelete = 'country.delete'; + + // User Permissions + // Allows reading any user profile (e.g., for admin or public profiles) + static const String userRead = 'user.read'; + // Allows reading the authenticated user's own profile + static const String userReadOwned = 'user.read_owned'; + // Allows updating the authenticated user's own profile + static const String userUpdateOwned = 'user.update_owned'; + // Allows deleting the authenticated user's own account + static const String userDeleteOwned = 'user.delete_owned'; + + // App Settings Permissions (User-owned) + static const String appSettingsReadOwned = 'app_settings.read_owned'; + static const String appSettingsUpdateOwned = 'app_settings.update_owned'; + + // User Preferences Permissions (User-owned) + static const String userPreferencesReadOwned = 'user_preferences.read_owned'; + static const String userPreferencesUpdateOwned = 'user_preferences.update_owned'; + + // Remote Config Permissions (Admin-owned/managed) + static const String remoteConfigReadAdmin = 'remote_config.read_admin'; + static const String remoteConfigUpdateAdmin = 'remote_config.update_admin'; + + // Add other permissions as needed for future models/features +} diff --git a/lib/src/rbac/role_permissions.dart b/lib/src/rbac/role_permissions.dart new file mode 100644 index 0000000..d3eb8e5 --- /dev/null +++ b/lib/src/rbac/role_permissions.dart @@ -0,0 +1,72 @@ +import 'package:ht_api/src/rbac/permission_service.dart' show PermissionService; +import 'package:ht_api/src/rbac/permissions.dart'; +import 'package:ht_shared/ht_shared.dart'; // Assuming UserRole is defined here + +/// Defines the mapping between user roles and the permissions they possess. +/// +/// This map is the core of the Role-Based Access Control (RBAC) system. +/// Each key is a [UserRole], and the associated value is a [Set] of +/// [Permissions] strings that users with that role are granted. +/// +/// Note: Administrators typically have implicit access to all resources +/// regardless of this map, but including their permissions here can aid +/// documentation and clarity. The [PermissionService] should handle the +/// explicit admin bypass if desired. +final Map> rolePermissions = { + UserRole.admin: { + // Admins typically have all permissions. Listing them explicitly + // or handling the admin bypass in PermissionService are options. + // For clarity, listing some key admin permissions here: + Permissions.headlineCreate, + Permissions.headlineRead, + Permissions.headlineUpdate, + Permissions.headlineDelete, + Permissions.categoryCreate, + Permissions.categoryRead, + Permissions.categoryUpdate, + Permissions.categoryDelete, + Permissions.sourceCreate, + Permissions.sourceRead, + Permissions.sourceUpdate, + Permissions.sourceDelete, + Permissions.countryCreate, + Permissions.countryRead, + Permissions.countryUpdate, + Permissions.countryDelete, + Permissions.userRead, // Admins can read any user profile + Permissions.userReadOwned, + Permissions.userUpdateOwned, + Permissions.userDeleteOwned, + Permissions.appSettingsReadOwned, + Permissions.appSettingsUpdateOwned, + Permissions.userPreferencesReadOwned, + Permissions.userPreferencesUpdateOwned, + Permissions.remoteConfigReadAdmin, + Permissions.remoteConfigUpdateAdmin, + // Add all other permissions here for completeness if not using admin bypass + }, + UserRole.standardUser: { + // Standard users can read public/shared data + Permissions.headlineRead, + Permissions.categoryRead, + Permissions.sourceRead, + Permissions.countryRead, + // Standard users can manage their own user-owned data + Permissions.userReadOwned, + Permissions.userUpdateOwned, + Permissions.userDeleteOwned, + Permissions.appSettingsReadOwned, + Permissions.appSettingsUpdateOwned, + Permissions.userPreferencesReadOwned, + Permissions.userPreferencesUpdateOwned, + // Add other permissions for standard users as needed + }, + UserRole.guestUser: { + // Guest users have very limited permissions, primarily reading public data + Permissions.headlineRead, + Permissions.categoryRead, + Permissions.sourceRead, + Permissions.countryRead, + // Add other permissions for guest users as needed + }, +}; diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index b1a3ac0..6e20b8b 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -2,31 +2,60 @@ // ignore_for_file: strict_raw_type, lines_longer_than_80_chars import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_app_settings_client/ht_app_settings_client.dart'; import 'package:ht_data_client/ht_data_client.dart'; import 'package:ht_shared/ht_shared.dart'; -/// Defines the ownership type of a data model and associated access rules. -enum ModelOwnership { - /// Indicates the resource is fully managed by admins (only admins can - /// Create, Read, Update, Delete). - adminOwned, +import 'package:ht_api/src/rbac/permissions.dart'; // Import permissions - /// Indicates the resource is managed by admins (only admins can Create, - /// Update, Delete), but read operations (GET) are allowed for all - /// authenticated users. - adminOwnedReadAllowed, +/// Defines the type of permission check required for a specific action. +enum RequiredPermissionType { + /// No specific permission check is required (e.g., public access). + /// Note: This assumes the parent route group middleware allows unauthenticated + /// access if needed. The /data route requires authentication by default. + none, - /// Indicates the resource is owned by a specific user (only the owning user - /// or an admin can Create, Read, Update, Delete). - userOwned, + /// Requires the user to have the [UserRole.admin] role. + adminOnly, + + /// Requires the user to have a specific permission string. + specificPermission, +} + +/// Configuration for the authorization requirements of a single HTTP method +/// on a data model. +class ModelActionPermission { + /// {@macro model_action_permission} + const ModelActionPermission({ + required this.type, + this.permission, + this.requiresOwnershipCheck = false, + }) : assert( + type != RequiredPermissionType.specificPermission || + permission != null, + 'Permission string must be provided for specificPermission type', + ); + + /// The type of permission check required. + final RequiredPermissionType type; + + /// The specific permission string required if [type] is + /// [RequiredPermissionType.specificPermission]. + final String? permission; + + /// Whether an additional check is required to verify the authenticated user + /// is the owner of the specific data item being accessed (for item-specific + /// methods like GET, PUT, DELETE on `/[id]`). + final bool requiresOwnershipCheck; } /// {@template model_config} /// Configuration holder for a specific data model type [T]. /// /// This class encapsulates the type-specific operations (like deserialization -/// from JSON and ID extraction) needed by the generic `/api/v1/data` endpoint -/// handlers. It allows those handlers to work with different data models +/// from JSON, ID extraction, and owner ID extraction) and authorization +/// requirements needed by the generic `/api/v1/data` endpoint handlers and +/// middleware. It allows those handlers to work with different data models /// without needing explicit type checks for these common operations. /// /// An instance of this config is looked up via the [modelRegistry] based on the @@ -37,7 +66,11 @@ class ModelConfig { const ModelConfig({ required this.fromJson, required this.getId, - required this.ownership, // New field + required this.getPermission, + required this.postPermission, + required this.putPermission, + required this.deletePermission, + this.getOwnerId, // Optional: Function to get owner ID for user-owned models }); /// Function to deserialize JSON into an object of type [T]. @@ -46,12 +79,23 @@ class ModelConfig { /// Function to extract the unique string ID from an item of type [T]. final String Function(T item) getId; - /// The ownership type of this model. - final ModelOwnership ownership; -} + /// Optional function to extract the unique string ID of the owner from an + /// item of type [T]. Required for models where `requiresOwnershipCheck` + /// is true for any action. + final String? Function(T item)? getOwnerId; -// Repository providers are no longer defined here. -// They will be created and provided directly in the main dependency setup. + /// Authorization configuration for GET requests. + final ModelActionPermission getPermission; + + /// Authorization configuration for POST requests. + final ModelActionPermission postPermission; + + /// Authorization configuration for PUT requests. + final ModelActionPermission putPermission; + + /// Authorization configuration for DELETE requests. + final ModelActionPermission deletePermission; +} /// {@template model_registry} /// Central registry mapping model name strings (used in the `?model=` query parameter) @@ -61,7 +105,8 @@ class ModelConfig { /// The middleware (`routes/api/v1/data/_middleware.dart`) uses this map to: /// 1. Validate the `model` query parameter provided by the client. /// 2. Retrieve the correct [ModelConfig] containing type-specific functions -/// (like `fromJson`) needed by the generic route handlers (`index.dart`, `[id].dart`). +/// (like `fromJson`, `getOwnerId`) and authorization metadata needed by the +/// generic route handlers (`index.dart`, `[id].dart`) and authorization middleware. /// /// While individual repositories (`HtDataRepository`, etc.) are provided /// directly in the main `routes/_middleware.dart`, this registry provides the @@ -72,23 +117,103 @@ final modelRegistry = >{ 'headline': ModelConfig( fromJson: Headline.fromJson, getId: (h) => h.id, - ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership + // Headlines: Admin-owned, read allowed by standard/guest users + getPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.headlineRead, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), ), 'category': ModelConfig( fromJson: Category.fromJson, getId: (c) => c.id, - ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership + // Categories: Admin-owned, read allowed by standard/guest users + getPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.categoryRead, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), ), 'source': ModelConfig( fromJson: Source.fromJson, getId: (s) => s.id, - ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership + // Sources: Admin-owned, read allowed by standard/guest users + getPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.sourceRead, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), ), 'country': ModelConfig( fromJson: Country.fromJson, getId: (c) => c.id, // Assuming Country has an 'id' field - ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership + // Countries: Admin-owned, read allowed by standard/guest users + getPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.countryRead, + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.adminOnly, + ), + ), + // Add configurations for other models like User, AppSettings, etc. + // Example for a User model (user can read/update their own, admin can read any) + 'user': ModelConfig( + fromJson: User.fromJson, + getId: (u) => u.id, + getOwnerId: (u) => u.id, // User is the owner of their profile + getPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userReadOwned, // User can read their own + requiresOwnershipCheck: true, // Must be the owner + ), + postPermission: const ModelActionPermission( + type: RequiredPermissionType.none, // User creation handled by auth routes + ), + putPermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userUpdateOwned, // User can update their own + requiresOwnershipCheck: true, // Must be the owner + ), + deletePermission: const ModelActionPermission( + type: RequiredPermissionType.specificPermission, + permission: Permissions.userDeleteOwned, // User can delete their own + requiresOwnershipCheck: true, // Must be the owner + ), ), + // Example for AppSettings (user-owned) + + // Add other models following this pattern... }; /// Type alias for the ModelRegistry map for easier provider usage. diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index 739d620..70d3817 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -8,6 +8,15 @@ import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; import 'package:uuid/uuid.dart'; +/// Helper function to convert UserRole enum to its snake_case string. +String _userRoleToString(UserRole role) { + return switch (role) { + UserRole.admin => 'admin', + UserRole.standardUser => 'standard_user', + UserRole.guestUser => 'guest_user', + }; +} + /// {@template jwt_auth_token_service} /// An implementation of [AuthTokenService] using JSON Web Tokens (JWT). /// @@ -64,7 +73,7 @@ class JwtAuthTokenService implements AuthTokenService { // Custom claims (optional, include what's useful) 'email': user.email, - 'role': user.role, // Include the user's role + 'role': _userRoleToString(user.role), // Include the user's role as a string }, issuer: _issuer, subject: user.id, diff --git a/routes/_middleware.dart b/routes/_middleware.dart index 03584a3..2847d31 100644 --- a/routes/_middleware.dart +++ b/routes/_middleware.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/error_handler.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; // Import PermissionService import 'package:ht_api/src/registry/model_registry.dart'; import 'package:ht_api/src/services/auth_service.dart'; import 'package:ht_api/src/services/auth_token_service.dart'; @@ -203,6 +204,10 @@ Handler middleware(Handler handler) { ); print('[MiddlewareSetup] AuthService instantiated.'); // Added log + // --- RBAC Dependencies --- + const permissionService = + PermissionService(); // Instantiate PermissionService + // ========================================================================== // MIDDLEWARE CHAIN // ========================================================================== @@ -290,7 +295,13 @@ Handler middleware(Handler handler) { ), ) // Reads other services/repos - // --- 5. Request Logger (Logging) --- + // --- 5. RBAC Service Provider --- + // PURPOSE: Provides the PermissionService for authorization checks. + // ORDER: Must be provided before any middleware or handlers that use it + // (e.g., authorizationMiddleware). + .use(provider((_) => permissionService)) + + // --- 6. Request Logger (Logging) --- // PURPOSE: Logs details about the incoming request and outgoing response. // ORDER: Often placed late in the request phase / early in the response // phase. Placing it here logs the request *before* the handler diff --git a/routes/api/v1/data/[id].dart b/routes/api/v1/data/[id].dart index 12a954c..793fbd4 100644 --- a/routes/api/v1/data/[id].dart +++ b/routes/api/v1/data/[id].dart @@ -1,11 +1,12 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; // Import PermissionService import 'package:ht_api/src/registry/model_registry.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; -import '../../../_middleware.dart'; +import '../../../_middleware.dart'; // Assuming RequestId is here /// Handles requests for the /api/v1/data/[id] endpoint. /// Dispatches requests to specific handlers based on the HTTP method. @@ -14,58 +15,46 @@ Future onRequest(RequestContext context, String id) async { final modelName = context.read(); final modelConfig = context.read>(); final requestId = context.read().id; - // Since requireAuthentication is used, User is guaranteed to be non-null. + // User is guaranteed non-null by requireAuthentication() middleware final authenticatedUser = context.read(); + final permissionService = context.read(); // Read PermissionService - try { - switch (context.request.method) { - case HttpMethod.get: - return await _handleGet( - context, - id, - modelName, - modelConfig, - authenticatedUser, - requestId, - ); - case HttpMethod.put: - return await _handlePut( - context, - id, - modelName, - modelConfig, - authenticatedUser, - requestId, - ); - case HttpMethod.delete: - return await _handleDelete( - context, - id, - modelName, - modelConfig, - authenticatedUser, - requestId, - ); - default: - // Methods not allowed on the item endpoint - return Response(statusCode: HttpStatus.methodNotAllowed); - } - } on HtHttpException catch (_) { - // Let the errorHandler middleware handle HtHttpExceptions (incl. NotFound) - rethrow; - } on FormatException catch (_) { - // Let the errorHandler middleware handle FormatExceptions (e.g., from PUT body) - rethrow; - } catch (e, stackTrace) { - // Handle any other unexpected errors locally (e.g., provider resolution) - // Include requestId in the server log - print( - '[ReqID: $requestId] Unexpected error in /data/[id].dart handler: $e\n$stackTrace', - ); - return Response( - statusCode: HttpStatus.internalServerError, - body: 'Internal Server Error.', - ); + // The main try/catch block here is removed to let the errorHandler middleware + // handle all exceptions thrown by the handlers below. + switch (context.request.method) { + case HttpMethod.get: + return _handleGet( + context, + id, + modelName, + modelConfig, + authenticatedUser, + permissionService, // Pass PermissionService + requestId, + ); + case HttpMethod.put: + return _handlePut( + context, + id, + modelName, + modelConfig, + authenticatedUser, + permissionService, // Pass PermissionService + requestId, + ); + case HttpMethod.delete: + return _handleDelete( + context, + id, + modelName, + modelConfig, + authenticatedUser, + permissionService, // Pass PermissionService + requestId, + ); + default: + // Methods not allowed on the item endpoint + return Response(statusCode: HttpStatus.methodNotAllowed); } } @@ -78,63 +67,82 @@ Future _handleGet( String modelName, ModelConfig modelConfig, User authenticatedUser, + PermissionService permissionService, // Receive PermissionService String requestId, ) async { - // Apply access control based on ownership type for GET requests - if (modelConfig.ownership == ModelOwnership.adminOwned && - authenticatedUser.role != UserRole.admin) { - throw const ForbiddenException( - 'You do not have permission to read this resource.', - ); - } + // Authorization check is handled by authorizationMiddleware before this. + // This handler only needs to perform the ownership check if required. dynamic item; + // Determine userId for repository call based on ModelConfig (for data scoping) String? userIdForRepoCall; - // For userOwned models, pass the authenticated user's ID to the repository - // for filtering. For adminOwned/adminOwnedReadAllowed, pass null. - if (modelConfig.ownership == ModelOwnership.userOwned) { - userIdForRepoCall = authenticatedUser.id; + // If the model is user-owned, pass the authenticated user's ID to the repository + // for filtering. Otherwise, pass null. + // Note: This is for data *scoping* by the repository, not the permission check. + // We infer user-owned based on the presence of getOwnerId function. + if (modelConfig.getOwnerId != null) { + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } - // Repository exceptions (like NotFoundException) will propagate up. - try { - switch (modelName) { - case 'headline': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'category': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'source': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - case 'country': - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); - default: - // This case should ideally be caught by middleware, but added for safety - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Unsupported model type "$modelName" reached handler.', - ); + + // Repository exceptions (like NotFoundException) will propagate up to the + // main onRequest try/catch (which is now removed, so they go to errorHandler). + switch (modelName) { + case 'headline': + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + case 'category': + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + case 'source': + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + case 'country': + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + case 'user': // Handle User model specifically if needed, or rely on generic + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); + // Add cases for other models as they are added to ModelRegistry + default: + // This case should ideally be caught by middleware, but added for safety + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', + ); + } + + // --- Handler-Level Ownership Check (for GET item) --- + // This check is needed if the ModelConfig for GET requires ownership + // AND the user is NOT an admin (admins can bypass ownership checks). + if (modelConfig.getPermission.requiresOwnershipCheck && + !permissionService.isAdmin(authenticatedUser)) { + // Ensure getOwnerId is provided for models requiring ownership check + if (modelConfig.getOwnerId == null) { + print( + '[ReqID: $requestId] Configuration Error: Model "$modelName" requires ' + 'ownership check for GET but getOwnerId is not provided.', + ); + // Throw an exception to be caught by the errorHandler + throw const OperationFailedException( + 'Internal Server Error: Model configuration error.', + ); + } + + final itemOwnerId = modelConfig.getOwnerId!(item); + if (itemOwnerId != authenticatedUser.id) { + // If the authenticated user is not the owner, deny access. + // Throw ForbiddenException to be caught by the errorHandler + throw const ForbiddenException( + 'You do not have permission to access this specific item.', + ); } - } catch (e) { - // Catch potential provider errors during context.read within this handler - // Include requestId in the server log - print( - '[ReqID: $requestId] Error reading repository provider for model "$modelName" in _handleGet [id]: $e', - ); - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Could not resolve repository for model "$modelName".', - ); } + // Create metadata including the request ID and current timestamp final metadata = ResponseMetadata( requestId: requestId, @@ -165,14 +173,16 @@ Future _handlePut( String modelName, ModelConfig modelConfig, User authenticatedUser, + PermissionService permissionService, // Receive PermissionService String requestId, ) async { + // Authorization check is handled by authorizationMiddleware before this. + // This handler only needs to perform the ownership check if required. + final requestBody = await context.request.json() as Map?; if (requestBody == null) { - return Response( - statusCode: HttpStatus.badRequest, - body: 'Missing or invalid request body.', - ); + // Throw BadRequestException to be caught by the errorHandler + throw const BadRequestException('Missing or invalid request body.'); } // Deserialize using ModelConfig's fromJson, catching TypeErrors locally @@ -185,138 +195,140 @@ Future _handlePut( print( '[ReqID: $requestId] Deserialization TypeError in PUT /data/[id]: $e', ); - return Response.json( - statusCode: HttpStatus.badRequest, // 400 - body: { - 'error': { - 'code': 'INVALID_REQUEST_BODY', - 'message': - 'Invalid request body: Missing or invalid required field(s).', - // 'details': e.toString(), // Optional: Include details in dev - }, - }, + // Throw BadRequestException to be caught by the errorHandler + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', ); } - // Apply access control based on ownership type for PUT requests - if ((modelConfig.ownership == ModelOwnership.adminOwned || - modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) && - authenticatedUser.role != UserRole.admin) { - throw const ForbiddenException( - 'Only administrators can update this resource.', - ); - } - if (modelConfig.ownership == ModelOwnership.userOwned && - authenticatedUser.role != UserRole.admin) { - // For userOwned, non-admins must be the owner. - // The repository will enforce this check when userIdForRepoCall is passed. + // Ensure the ID in the path matches the ID in the request body (if present) + // This is a data integrity check, not an authorization check. + try { + final bodyItemId = modelConfig.getId(itemToUpdate); + if (bodyItemId != id) { + // Throw BadRequestException to be caught by the errorHandler + throw BadRequestException( + 'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").', + ); + } + } catch (e) { + // Ignore if getId throws, means ID might not be in the body, + // which is acceptable depending on the model/client. + // Log for debugging if needed. + print('[ReqID: $requestId] Warning: Could not get ID from PUT body: $e'); } - dynamic updatedItem; + // Determine userId for repository call based on ModelConfig (for data scoping/ownership enforcement) String? userIdForRepoCall; - // For userOwned models, pass the authenticated user's ID to the repository - // for ownership enforcement. For adminOwned/adminOwnedReadAllowed, pass null - // (repository handles admin updates). - if (modelConfig.ownership == ModelOwnership.userOwned) { - userIdForRepoCall = authenticatedUser.id; + // If the model is user-owned, pass the authenticated user's ID to the repository + // for ownership enforcement. Otherwise, pass null. + if (modelConfig.getOwnerId != null) { + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } + + dynamic updatedItem; + // Repository exceptions (like NotFoundException, BadRequestException) - // will propagate up. - try { - switch (modelName) { - case 'headline': - { - final repo = context.read>(); - final typedItem = itemToUpdate as Headline; - if (typedItem.id != id) { - return Response( - statusCode: HttpStatus.badRequest, - body: - 'Bad Request: ID in request body ("${typedItem.id}") does not match ID in path ("$id").', - ); - } - updatedItem = await repo.update( - id: id, - item: typedItem, - userId: userIdForRepoCall, - ); - } - case 'category': - { - final repo = context.read>(); - final typedItem = itemToUpdate as Category; - if (typedItem.id != id) { - return Response( - statusCode: HttpStatus.badRequest, - body: - 'Bad Request: ID in request body ("${typedItem.id}") does not match ID in path ("$id").', - ); - } - updatedItem = await repo.update( - id: id, - item: typedItem, - userId: userIdForRepoCall, - ); - } - case 'source': - { - final repo = context.read>(); - final typedItem = itemToUpdate as Source; - if (typedItem.id != id) { - return Response( - statusCode: HttpStatus.badRequest, - body: - 'Bad Request: ID in request body ("${typedItem.id}") does not match ID in path ("$id").', - ); - } - updatedItem = await repo.update( - id: id, - item: typedItem, - userId: userIdForRepoCall, - ); - } - case 'country': - { - final repo = context.read>(); - final typedItem = itemToUpdate as Country; - if (typedItem.id != id) { - return Response( - statusCode: HttpStatus.badRequest, - body: - 'Bad Request: ID in request body ("${typedItem.id}") does not match ID in path ("$id").', - ); - } - updatedItem = await repo.update( - id: id, - item: typedItem, - userId: userIdForRepoCall, - ); - } - default: - // This case should ideally be caught by middleware, but added for safety - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Unsupported model type "$modelName" reached handler.', + // will propagate up to the errorHandler. + switch (modelName) { + case 'headline': + { + final repo = context.read>(); + updatedItem = await repo.update( + id: id, + item: itemToUpdate as Headline, + userId: userIdForRepoCall, ); + } + case 'category': + { + final repo = context.read>(); + updatedItem = await repo.update( + id: id, + item: itemToUpdate as Category, + userId: userIdForRepoCall, + ); + } + case 'source': + { + final repo = context.read>(); + updatedItem = await repo.update( + id: id, + item: itemToUpdate as Source, + userId: userIdForRepoCall, + ); + } + case 'country': + { + final repo = context.read>(); + updatedItem = await repo.update( + id: id, + item: itemToUpdate as Country, + userId: userIdForRepoCall, + ); + } + case 'user': + { + final repo = context.read>(); + updatedItem = await repo.update( + id: id, + item: itemToUpdate as User, + userId: userIdForRepoCall, + ); + } + // Add cases for other models as they are added to ModelRegistry + default: + // This case should ideally be caught by middleware, but added for safety + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', + ); + } + + // --- Handler-Level Ownership Check (for PUT) --- + // This check is needed if the ModelConfig for PUT requires ownership + // AND the user is NOT an admin (admins can bypass ownership checks). + // Note: The repository *might* have already enforced ownership if userId was passed. + // This handler-level check provides a second layer of defense and is necessary + // if the repository doesn't fully enforce ownership based on userId alone + // (e.g., if the repo update method allows admins to update any item even if userId is passed). + if (modelConfig.putPermission.requiresOwnershipCheck && + !permissionService.isAdmin(authenticatedUser)) { + // Ensure getOwnerId is provided for models requiring ownership check + if (modelConfig.getOwnerId == null) { + print( + '[ReqID: $requestId] Configuration Error: Model "$modelName" requires ' + 'ownership check for PUT but getOwnerId is not provided.', + ); + // Throw an exception to be caught by the errorHandler + throw const OperationFailedException( + 'Internal Server Error: Model configuration error.', + ); + } + // Re-fetch the item to ensure we have the owner ID from the source of truth + // after the update, or ideally, the update method returns the item with owner ID. + // Assuming the updatedItem returned by the repo has the owner ID: + final itemOwnerId = modelConfig.getOwnerId!(updatedItem); + if (itemOwnerId != authenticatedUser.id) { + // This scenario should ideally not happen if the repository correctly + // enforced ownership during the update call when userId was passed. + // But as a defense-in-depth, we check here. + print( + '[ReqID: $requestId] Ownership check failed AFTER PUT for item $id. ' + 'Item owner: $itemOwnerId, User: ${authenticatedUser.id}', + ); + // Throw ForbiddenException to be caught by the errorHandler + throw const ForbiddenException( + 'You do not have permission to update this specific item.', + ); } - } catch (e) { - // Catch potential provider errors during context.read within this handler - // Include requestId in the server log - print( - '[ReqID: $requestId] Error reading repository provider for model "$modelName" in _handlePut [id]: $e', - ); - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Could not resolve repository for model "$modelName".', - ); } + // Create metadata including the request ID and current timestamp final metadata = ResponseMetadata( requestId: requestId, @@ -346,32 +358,84 @@ Future _handleDelete( String modelName, ModelConfig modelConfig, User authenticatedUser, + PermissionService permissionService, // Receive PermissionService String requestId, ) async { - // Apply access control based on ownership type for DELETE requests - if ((modelConfig.ownership == ModelOwnership.adminOwned || - modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) && - authenticatedUser.role != UserRole.admin) { - throw const ForbiddenException( - 'Only administrators can delete this resource.', - ); - } - if (modelConfig.ownership == ModelOwnership.userOwned && - authenticatedUser.role != UserRole.admin) { - // For userOwned, non-admins must be the owner. - // The repository will enforce this check when userIdForRepoCall is passed. - } + // Authorization check is handled by authorizationMiddleware before this. + // This handler only needs to perform the ownership check if required. + // Determine userId for repository call based on ModelConfig (for data scoping/ownership enforcement) String? userIdForRepoCall; - // For userOwned models, pass the authenticated user's ID to the repository - // for ownership enforcement. For adminOwned/adminOwnedReadAllowed, pass null - // (repository handles admin deletions). - if (modelConfig.ownership == ModelOwnership.userOwned) { - userIdForRepoCall = authenticatedUser.id; + // If the model is user-owned, pass the authenticated user's ID to the repository + // for ownership enforcement. Otherwise, pass null. + if (modelConfig.getOwnerId != null) { + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } + // --- Handler-Level Ownership Check (for DELETE) --- + // For DELETE, we need to fetch the item *before* attempting deletion + // to perform the ownership check if required. + dynamic itemToDelete; + if (modelConfig.deletePermission.requiresOwnershipCheck && + !permissionService.isAdmin(authenticatedUser)) { + // Ensure getOwnerId is provided for models requiring ownership check + if (modelConfig.getOwnerId == null) { + print( + '[ReqID: $requestId] Configuration Error: Model "$modelName" requires ' + 'ownership check for DELETE but getOwnerId is not provided.', + ); + // Throw an exception to be caught by the errorHandler + throw const OperationFailedException( + 'Internal Server Error: Model configuration error.', + ); + } + // Fetch the item to check ownership. Use userIdForRepoCall for scoping. + // Repository exceptions (like NotFoundException) will propagate up to the errorHandler. + switch (modelName) { + case 'headline': + final repo = context.read>(); + itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); + case 'category': + final repo = context.read>(); + itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); + case 'source': + final repo = context.read>(); + itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); + case 'country': + final repo = context.read>(); + itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); + case 'user': + final repo = context.read>(); + itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); + // Add cases for other models + default: + print( + '[ReqID: $requestId] Error: Unsupported model type "$modelName" reached _handleDelete ownership check.', + ); + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', + ); + } + + // Perform the ownership check if the item was found + if (itemToDelete != null) { + final itemOwnerId = modelConfig.getOwnerId!(itemToDelete); + if (itemOwnerId != authenticatedUser.id) { + // If the authenticated user is not the owner, deny access. + // Throw ForbiddenException to be caught by the errorHandler + throw const ForbiddenException( + 'You do not have permission to delete this specific item.', + ); + } + } + // If itemToDelete is null here, it means the item wasn't found during the read. + // The subsequent delete call will likely throw NotFoundException, which is correct. + } + + // Allow repository exceptions (e.g., NotFoundException) to propagate // upwards to be handled by the standard error handling mechanism. switch (modelName) { @@ -391,19 +455,24 @@ Future _handleDelete( await context .read>() .delete(id: id, userId: userIdForRepoCall); + case 'user': + await context + .read>() + .delete(id: id, userId: userIdForRepoCall); + // Add cases for other models as they are added to ModelRegistry default: // This case should ideally be caught by the data/_middleware.dart, // but added for safety. Consider logging this unexpected state. print( '[ReqID: $requestId] Error: Unsupported model type "$modelName" reached _handleDelete.', ); - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Unsupported model type "$modelName" reached handler.', + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', ); } + // Return 204 No Content for successful deletion (no body, no metadata) return Response(statusCode: HttpStatus.noContent); } diff --git a/routes/api/v1/data/_middleware.dart b/routes/api/v1/data/_middleware.dart index 93317a1..f28d481 100644 --- a/routes/api/v1/data/_middleware.dart +++ b/routes/api/v1/data/_middleware.dart @@ -1,6 +1,8 @@ import 'package:dart_frog/dart_frog.dart'; import 'package:ht_api/src/middlewares/authentication_middleware.dart'; +import 'package:ht_api/src/middlewares/authorization_middleware.dart'; // Import authorization middleware import 'package:ht_api/src/registry/model_registry.dart'; +import 'package:ht_shared/ht_shared.dart'; // For BadRequestException /// Middleware specific to the generic `/api/v1/data` route path. /// @@ -11,24 +13,27 @@ import 'package:ht_api/src/registry/model_registry.dart'; /// - Validates the `model` query parameter. /// - Looks up the `ModelConfig` from the `ModelRegistryMap`. /// - Provides the `ModelConfig` and `modelName` into the request context -/// for downstream route handlers. +/// for downstream middleware and route handlers. +/// 3. **Authorization Check (`authorizationMiddleware`):** Enforces role-based +/// and model-specific permissions based on the `ModelConfig` metadata. +/// If the user lacks permission, it throws a [ForbiddenException]. /// -/// This setup ensures that data routes are protected and have the necessary -/// model-specific configuration available. +/// This setup ensures that data routes are protected, have the necessary +/// model-specific configuration available, and access is authorized before +/// reaching the final route handler. // Helper middleware for model validation and context provision. Middleware _modelValidationAndProviderMiddleware() { return (handler) { // This 'handler' is the next handler in the chain, - // which, in this setup, is the actual route handler from - // index.dart or [id].dart. + // which, in this setup, is the authorizationMiddleware. return (context) async { // --- 1. Read and Validate `model` Parameter --- final modelName = context.request.uri.queryParameters['model']; if (modelName == null || modelName.isEmpty) { - return Response( - statusCode: 400, - body: 'Bad Request: Missing or empty "model" query parameter.', + // Throw BadRequestException to be caught by the errorHandler + throw const BadRequestException( + 'Missing or empty "model" query parameter.', ); } @@ -39,10 +44,10 @@ Middleware _modelValidationAndProviderMiddleware() { // Further validation: Ensure model exists in the registry if (modelConfig == null) { - return Response( - statusCode: 400, - body: 'Bad Request: Invalid model type "$modelName". ' - 'Supported models are: ${registry.keys.join(', ')}.', + // Throw BadRequestException to be caught by the errorHandler + throw BadRequestException( + 'Invalid model type "$modelName". ' + 'Supported models are: ${registry.keys.join(', ')}.', ); } @@ -51,7 +56,7 @@ Middleware _modelValidationAndProviderMiddleware() { .provide>(() => modelConfig) .provide(() => modelName); - // Call the next handler in the chain with the updated context + // Call the next handler in the chain (authorizationMiddleware) return handler(updatedContext); }; }; @@ -78,14 +83,28 @@ Handler middleware(Handler handler) { // - This runs if `requireAuthentication()` passes. // - It validates the `?model=` query parameter and provides the // `ModelConfig` and `modelName` into the context. - // - If model validation fails, it returns a 400 Bad Request response directly. - // - If successful, it calls the next handler in the chain. + // - If model validation fails, it throws a BadRequestException, caught + // by the global errorHandler. + // - If successful, it calls the next handler in the chain (authorizationMiddleware). // - // 3. Actual Route Handler (from `index.dart` or `[id].dart`): - // - This runs last, only if both preceding middlewares pass. It will have + // 3. `authorizationMiddleware()`: + // - This runs if `_modelValidationAndProviderMiddleware()` passes. + // - It reads the `User`, `modelName`, and `ModelConfig` from the context. + // - It checks if the user has permission to perform the requested HTTP + // method on the specified model based on the `ModelConfig` metadata. + // - If authorization fails, it throws a ForbiddenException, caught by + // the global errorHandler. + // - If successful, it calls the next handler in the chain (the actual + // route handler). + // + // 4. Actual Route Handler (from `index.dart` or `[id].dart`): + // - This runs last, only if all preceding middlewares pass. It will have // access to a non-null `User`, `ModelConfig`, and `modelName` from the context. + // - It performs the data operation and any necessary handler-level + // ownership checks (if flagged by `ModelActionPermission.requiresOwnershipCheck`). // return handler - .use(_modelValidationAndProviderMiddleware()) // Applied second (inner) + .use(authorizationMiddleware()) // Applied third (inner) + .use(_modelValidationAndProviderMiddleware()) // Applied second .use(requireAuthentication()); // Applied first (outermost) } diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index 50982cc..1377590 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -1,11 +1,12 @@ import 'dart:io'; import 'package:dart_frog/dart_frog.dart'; +import 'package:ht_api/src/rbac/permission_service.dart'; // Import PermissionService import 'package:ht_api/src/registry/model_registry.dart'; import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart'; -import '../../../_middleware.dart'; +import '../../../_middleware.dart'; // Assuming RequestId is here /// Handles requests for the /api/v1/data collection endpoint. /// Dispatches requests to specific handlers based on the HTTP method. @@ -14,48 +15,35 @@ Future onRequest(RequestContext context) async { final modelName = context.read(); final modelConfig = context.read>(); final requestId = context.read().id; - // Since requireAuthentication is used, User is guaranteed to be non-null. + // User is guaranteed non-null by requireAuthentication() middleware final authenticatedUser = context.read(); + final permissionService = context.read(); // Read PermissionService - try { - switch (context.request.method) { - case HttpMethod.get: - return await _handleGet( - context, - modelName, - modelConfig, - authenticatedUser, - requestId, - ); - case HttpMethod.post: - return await _handlePost( - context, - modelName, - modelConfig, - authenticatedUser, - requestId, - ); - // Add cases for other methods if needed in the future - default: - // Methods not allowed on the collection endpoint - return Response(statusCode: HttpStatus.methodNotAllowed); - } - } on HtHttpException catch (_) { - // Let the errorHandler middleware handle HtHttpExceptions - rethrow; - } on FormatException catch (_) { - // Let the errorHandler middleware handle FormatExceptions - rethrow; - } catch (e, stackTrace) { - // Handle any other unexpected errors locally (e.g., provider resolution) - // Include requestId in the server log for easier debugging - print( - '[ReqID: $requestId] Unexpected error in /data/index.dart handler: $e\n$stackTrace', - ); - return Response( - statusCode: HttpStatus.internalServerError, - body: 'Internal Server Error.', - ); + // The main try/catch block here is removed to let the errorHandler middleware + // handle all exceptions thrown by the handlers below. + switch (context.request.method) { + case HttpMethod.get: + return _handleGet( + context, + modelName, + modelConfig, + authenticatedUser, + permissionService, // Pass PermissionService + requestId, + ); + case HttpMethod.post: + return _handlePost( + context, + modelName, + modelConfig, + authenticatedUser, + permissionService, // Pass PermissionService + requestId, + ); + // Add cases for other methods if needed in the future + default: + // Methods not allowed on the collection endpoint + return Response(statusCode: HttpStatus.methodNotAllowed); } } @@ -67,8 +55,12 @@ Future _handleGet( String modelName, ModelConfig modelConfig, User authenticatedUser, + PermissionService permissionService, // Receive PermissionService String requestId, ) async { + // Authorization check is handled by authorizationMiddleware before this. + // This handler only needs to perform the ownership check if required. + // Read query parameters final queryParams = context.request.uri.queryParameters; final startAfterId = queryParams['startAfterId']; @@ -82,100 +74,101 @@ Future _handleGet( // Process based on model type PaginatedResponse paginatedResponse; - // Apply access control based on ownership type for GET requests - if (modelConfig.ownership == ModelOwnership.adminOwned && - authenticatedUser.role != UserRole.admin) { - throw const ForbiddenException( - 'You do not have permission to read this resource.', - ); - } - + // Determine userId for repository call based on ModelConfig (for data scoping) String? userIdForRepoCall; - // For userOwned models, pass the authenticated user's ID to the repository - // for filtering. For adminOwned/adminOwnedReadAllowed, pass null. - if (modelConfig.ownership == ModelOwnership.userOwned) { - userIdForRepoCall = authenticatedUser.id; + // If the model is user-owned, pass the authenticated user's ID to the repository + // for filtering. Otherwise, pass null. + // Note: This is for data *scoping* by the repository, not the permission check. + // We infer user-owned based on the presence of getOwnerId function. + if (modelConfig.getOwnerId != null) { + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } - try { - switch (modelName) { - case 'headline': - final repo = context.read>(); - paginatedResponse = specificQuery.isNotEmpty - ? await repo.readAllByQuery( - specificQuery, - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ) - : await repo.readAll( - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ); - case 'category': - final repo = context.read>(); - paginatedResponse = specificQuery.isNotEmpty - ? await repo.readAllByQuery( - specificQuery, - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ) - : await repo.readAll( - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ); - case 'source': - final repo = context.read>(); - paginatedResponse = specificQuery.isNotEmpty - ? await repo.readAllByQuery( - specificQuery, - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ) - : await repo.readAll( - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ); - case 'country': - final repo = context.read>(); - paginatedResponse = specificQuery.isNotEmpty - ? await repo.readAllByQuery( - specificQuery, - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ) - : await repo.readAll( - userId: userIdForRepoCall, - startAfterId: startAfterId, - limit: limit, - ); - default: - // This case should be caught by middleware, but added for safety - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Unsupported model type "$modelName" reached handler.', - ); - } - } catch (e) { - // Catch potential provider errors during context.read within this handler - // Include requestId in the server log - print( - '[ReqID: $requestId] Error reading repository provider for model "$modelName" in _handleGet: $e', - ); - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Could not resolve repository for model "$modelName".', - ); + // Repository exceptions (like NotFoundException, BadRequestException) + // will propagate up to the errorHandler. + switch (modelName) { + case 'headline': + final repo = context.read>(); + paginatedResponse = specificQuery.isNotEmpty + ? await repo.readAllByQuery( + specificQuery, + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ) + : await repo.readAll( + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ); + case 'category': + final repo = context.read>(); + paginatedResponse = specificQuery.isNotEmpty + ? await repo.readAllByQuery( + specificQuery, + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ) + : await repo.readAll( + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ); + case 'source': + final repo = context.read>(); + paginatedResponse = specificQuery.isNotEmpty + ? await repo.readAllByQuery( + specificQuery, + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ) + : await repo.readAll( + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ); + case 'country': + final repo = context.read>(); + paginatedResponse = specificQuery.isNotEmpty + ? await repo.readAllByQuery( + specificQuery, + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ) + : await repo.readAll( + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ); + case 'user': // Handle User model specifically if needed, or rely on generic + final repo = context.read>(); + // Note: readAll/readAllByQuery on User repo might need special handling + // depending on whether non-admins can list *all* users or just their own. + // Assuming for now readAll/readAllByQuery with userId scopes to owned. + paginatedResponse = specificQuery.isNotEmpty + ? await repo.readAllByQuery( + specificQuery, + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ) + : await repo.readAll( + userId: userIdForRepoCall, + startAfterId: startAfterId, + limit: limit, + ); + // Add cases for other models as they are added to ModelRegistry + default: + // This case should be caught by middleware, but added for safety + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', + ); } // Create metadata including the request ID and current timestamp @@ -209,14 +202,15 @@ Future _handlePost( String modelName, ModelConfig modelConfig, User authenticatedUser, + PermissionService permissionService, // Receive PermissionService String requestId, ) async { + // Authorization check is handled by authorizationMiddleware before this. + final requestBody = await context.request.json() as Map?; if (requestBody == null) { - return Response( - statusCode: HttpStatus.badRequest, - body: 'Missing or invalid request body.', - ); + // Throw BadRequestException to be caught by the errorHandler + throw const BadRequestException('Missing or invalid request body.'); } // Deserialize using ModelConfig's fromJson, catching TypeErrors @@ -227,88 +221,68 @@ Future _handlePost( // Catch errors during deserialization (e.g., missing required fields) // Include requestId in the server log print('[ReqID: $requestId] Deserialization TypeError in POST /data: $e'); - return Response.json( - statusCode: HttpStatus.badRequest, // 400 - body: { - 'error': { - 'code': 'INVALID_REQUEST_BODY', - 'message': - 'Invalid request body: Missing or invalid required field(s).', - // 'details': e.toString(), // Optional: Include details in dev - }, - }, + // Throw BadRequestException to be caught by the errorHandler + throw const BadRequestException( + 'Invalid request body: Missing or invalid required field(s).', ); } - // Apply access control based on ownership type for POST requests - if ((modelConfig.ownership == ModelOwnership.adminOwned || - modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) && - authenticatedUser.role != UserRole.admin) { - throw const ForbiddenException( - 'Only administrators can create this resource.', - ); - } - - // Process based on model type - dynamic createdItem; - + // Determine userId for repository call based on ModelConfig (for data scoping/ownership enforcement) String? userIdForRepoCall; - // For userOwned models, pass the authenticated user's ID to the repository - // for associating ownership during creation. For adminOwned/adminOwnedReadAllowed, - // pass null (repository handles admin creation). - if (modelConfig.ownership == ModelOwnership.userOwned) { + // If the model is user-owned, pass the authenticated user's ID to the repository + // for associating ownership during creation. Otherwise, pass null. + // We infer user-owned based on the presence of getOwnerId function. + if (modelConfig.getOwnerId != null) { userIdForRepoCall = authenticatedUser.id; } else { userIdForRepoCall = null; } + + // Process based on model type + dynamic createdItem; + // Repository exceptions (like BadRequestException from create) will propagate - // up to the main onRequest try/catch and be re-thrown to the middleware. - try { - switch (modelName) { - case 'headline': - final repo = context.read>(); - createdItem = await repo.create( - item: newItem as Headline, - userId: userIdForRepoCall, - ); - case 'category': - final repo = context.read>(); - createdItem = await repo.create( - item: newItem as Category, - userId: userIdForRepoCall, - ); - case 'source': - final repo = context.read>(); - createdItem = await repo.create( - item: newItem as Source, - userId: userIdForRepoCall, - ); - case 'country': - final repo = context.read>(); - createdItem = await repo.create( - item: newItem as Country, - userId: userIdForRepoCall, - ); - default: - // This case should ideally be caught by middleware, but added for safety - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Unsupported model type "$modelName" reached handler.', - ); - } - } catch (e) { - // Catch potential provider errors during context.read within this handler - // Include requestId in the server log - print( - '[ReqID: $requestId] Error reading repository provider for model "$modelName" in _handlePost: $e', - ); - return Response( - statusCode: HttpStatus.internalServerError, - body: - 'Internal Server Error: Could not resolve repository for model "$modelName".', - ); + // up to the errorHandler. + switch (modelName) { + case 'headline': + final repo = context.read>(); + createdItem = await repo.create( + item: newItem as Headline, + userId: userIdForRepoCall, + ); + case 'category': + final repo = context.read>(); + createdItem = await repo.create( + item: newItem as Category, + userId: userIdForRepoCall, + ); + case 'source': + final repo = context.read>(); + createdItem = await repo.create( + item: newItem as Source, + userId: userIdForRepoCall, + ); + case 'country': + final repo = context.read>(); + createdItem = await repo.create( + item: newItem as Country, + userId: userIdForRepoCall, + ); + case 'user': // Handle User model specifically if needed, or rely on generic + final repo = context.read>(); + // User creation is typically handled by auth routes, not generic data POST. + // Throw Forbidden or BadRequest if attempted here. + throw const ForbiddenException( + 'User creation is not allowed via the generic data endpoint.', + ); + // Add cases for other models as they are added to ModelRegistry + default: + // This case should ideally be caught by middleware, but added for safety + // Throw an exception to be caught by the errorHandler + throw OperationFailedException( + 'Unsupported model type "$modelName" reached handler.', + ); } // Create metadata including the request ID and current timestamp From f98db03a2317aa2de65ff094628b641a9f2403a4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 18 May 2025 16:58:49 +0100 Subject: [PATCH 7/8] style: misc --- .../middlewares/authorization_middleware.dart | 13 +-- lib/src/rbac/permissions.dart | 3 +- lib/src/registry/model_registry.dart | 4 +- lib/src/services/jwt_auth_token_service.dart | 4 +- routes/api/v1/data/[id].dart | 88 +++++++++---------- routes/api/v1/data/index.dart | 27 +++--- 6 files changed, 68 insertions(+), 71 deletions(-) diff --git a/lib/src/middlewares/authorization_middleware.dart b/lib/src/middlewares/authorization_middleware.dart index b436179..71dcc69 100644 --- a/lib/src/middlewares/authorization_middleware.dart +++ b/lib/src/middlewares/authorization_middleware.dart @@ -13,7 +13,7 @@ import 'package:ht_shared/ht_shared.dart'; // For User, ForbiddenException /// permission using the [PermissionService]. /// /// If the user does not have the required permission, it throws a -/// [ForbiddenException], which should be caught by the [errorHandler] middleware. +/// [ForbiddenException], which should be caught by the 'errorHandler' middleware. /// /// This middleware runs *after* authentication and model validation. /// It does NOT perform instance-level ownership checks; those are handled @@ -28,7 +28,8 @@ Middleware authorizationMiddleware() { final user = context.read(); final permissionService = context.read(); final modelName = context.read(); // Provided by data/_middleware - final modelConfig = context.read>(); // Provided by data/_middleware + final modelConfig = + context.read>(); // Provided by data/_middleware final method = context.request.method; // Determine the required permission configuration based on the HTTP method @@ -45,7 +46,9 @@ Middleware authorizationMiddleware() { default: // Should ideally be caught earlier by Dart Frog's routing, // but as a safeguard, deny unsupported methods. - throw const ForbiddenException('Method not supported for this resource.'); + throw const ForbiddenException( + 'Method not supported for this resource.', + ); } // Perform the permission check based on the configuration type @@ -67,8 +70,8 @@ Middleware authorizationMiddleware() { // Requires a specific permission string final permission = requiredPermissionConfig.permission; if (permission == null) { - // This indicates a configuration error in ModelRegistry - print( + // This indicates a configuration error in ModelRegistry + print( '[AuthorizationMiddleware] Configuration Error: specificPermission ' 'type requires a permission string for model "$modelName", method "$method".', ); diff --git a/lib/src/rbac/permissions.dart b/lib/src/rbac/permissions.dart index ff23765..4bd86f6 100644 --- a/lib/src/rbac/permissions.dart +++ b/lib/src/rbac/permissions.dart @@ -44,7 +44,8 @@ abstract class Permissions { // User Preferences Permissions (User-owned) static const String userPreferencesReadOwned = 'user_preferences.read_owned'; - static const String userPreferencesUpdateOwned = 'user_preferences.update_owned'; + static const String userPreferencesUpdateOwned = + 'user_preferences.update_owned'; // Remote Config Permissions (Admin-owned/managed) static const String remoteConfigReadAdmin = 'remote_config.read_admin'; diff --git a/lib/src/registry/model_registry.dart b/lib/src/registry/model_registry.dart index 6e20b8b..ac95003 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -2,12 +2,10 @@ // ignore_for_file: strict_raw_type, lines_longer_than_80_chars import 'package:dart_frog/dart_frog.dart'; -import 'package:ht_app_settings_client/ht_app_settings_client.dart'; +import 'package:ht_api/src/rbac/permissions.dart'; // Import permissions import 'package:ht_data_client/ht_data_client.dart'; import 'package:ht_shared/ht_shared.dart'; -import 'package:ht_api/src/rbac/permissions.dart'; // Import permissions - /// Defines the type of permission check required for a specific action. enum RequiredPermissionType { /// No specific permission check is required (e.g., public access). diff --git a/lib/src/services/jwt_auth_token_service.dart b/lib/src/services/jwt_auth_token_service.dart index 70d3817..e4db429 100644 --- a/lib/src/services/jwt_auth_token_service.dart +++ b/lib/src/services/jwt_auth_token_service.dart @@ -73,7 +73,9 @@ class JwtAuthTokenService implements AuthTokenService { // Custom claims (optional, include what's useful) 'email': user.email, - 'role': _userRoleToString(user.role), // Include the user's role as a string + 'role': _userRoleToString( + user.role, + ), // Include the user's role as a string }, issuer: _issuer, subject: user.id, diff --git a/routes/api/v1/data/[id].dart b/routes/api/v1/data/[id].dart index 793fbd4..d56d0a8 100644 --- a/routes/api/v1/data/[id].dart +++ b/routes/api/v1/data/[id].dart @@ -17,7 +17,8 @@ Future onRequest(RequestContext context, String id) async { final requestId = context.read().id; // User is guaranteed non-null by requireAuthentication() middleware final authenticatedUser = context.read(); - final permissionService = context.read(); // Read PermissionService + final permissionService = + context.read(); // Read PermissionService // The main try/catch block here is removed to let the errorHandler middleware // handle all exceptions thrown by the handlers below. @@ -82,12 +83,11 @@ Future _handleGet( // Note: This is for data *scoping* by the repository, not the permission check. // We infer user-owned based on the presence of getOwnerId function. if (modelConfig.getOwnerId != null) { - userIdForRepoCall = authenticatedUser.id; + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } - // Repository exceptions (like NotFoundException) will propagate up to the // main onRequest try/catch (which is now removed, so they go to errorHandler). switch (modelName) { @@ -104,8 +104,8 @@ Future _handleGet( final repo = context.read>(); item = await repo.read(id: id, userId: userIdForRepoCall); case 'user': // Handle User model specifically if needed, or rely on generic - final repo = context.read>(); - item = await repo.read(id: id, userId: userIdForRepoCall); + final repo = context.read>(); + item = await repo.read(id: id, userId: userIdForRepoCall); // Add cases for other models as they are added to ModelRegistry default: // This case should ideally be caught by middleware, but added for safety @@ -122,7 +122,7 @@ Future _handleGet( !permissionService.isAdmin(authenticatedUser)) { // Ensure getOwnerId is provided for models requiring ownership check if (modelConfig.getOwnerId == null) { - print( + print( '[ReqID: $requestId] Configuration Error: Model "$modelName" requires ' 'ownership check for GET but getOwnerId is not provided.', ); @@ -142,7 +142,6 @@ Future _handleGet( } } - // Create metadata including the request ID and current timestamp final metadata = ResponseMetadata( requestId: requestId, @@ -204,32 +203,30 @@ Future _handlePut( // Ensure the ID in the path matches the ID in the request body (if present) // This is a data integrity check, not an authorization check. try { - final bodyItemId = modelConfig.getId(itemToUpdate); - if (bodyItemId != id) { - // Throw BadRequestException to be caught by the errorHandler - throw BadRequestException( - 'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").', - ); - } + final bodyItemId = modelConfig.getId(itemToUpdate); + if (bodyItemId != id) { + // Throw BadRequestException to be caught by the errorHandler + throw BadRequestException( + 'Bad Request: ID in request body ("$bodyItemId") does not match ID in path ("$id").', + ); + } } catch (e) { - // Ignore if getId throws, means ID might not be in the body, - // which is acceptable depending on the model/client. - // Log for debugging if needed. - print('[ReqID: $requestId] Warning: Could not get ID from PUT body: $e'); + // Ignore if getId throws, means ID might not be in the body, + // which is acceptable depending on the model/client. + // Log for debugging if needed. + print('[ReqID: $requestId] Warning: Could not get ID from PUT body: $e'); } - // Determine userId for repository call based on ModelConfig (for data scoping/ownership enforcement) String? userIdForRepoCall; // If the model is user-owned, pass the authenticated user's ID to the repository // for ownership enforcement. Otherwise, pass null. - if (modelConfig.getOwnerId != null) { - userIdForRepoCall = authenticatedUser.id; + if (modelConfig.getOwnerId != null) { + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } - dynamic updatedItem; // Repository exceptions (like NotFoundException, BadRequestException) @@ -247,7 +244,7 @@ Future _handlePut( case 'category': { final repo = context.read>(); - updatedItem = await repo.update( + updatedItem = await repo.update( id: id, item: itemToUpdate as Category, userId: userIdForRepoCall, @@ -256,7 +253,7 @@ Future _handlePut( case 'source': { final repo = context.read>(); - updatedItem = await repo.update( + updatedItem = await repo.update( id: id, item: itemToUpdate as Source, userId: userIdForRepoCall, @@ -265,16 +262,16 @@ Future _handlePut( case 'country': { final repo = context.read>(); - updatedItem = await repo.update( + updatedItem = await repo.update( id: id, item: itemToUpdate as Country, userId: userIdForRepoCall, ); } - case 'user': + case 'user': { final repo = context.read>(); - updatedItem = await repo.update( + updatedItem = await repo.update( id: id, item: itemToUpdate as User, userId: userIdForRepoCall, @@ -289,7 +286,7 @@ Future _handlePut( ); } - // --- Handler-Level Ownership Check (for PUT) --- + // --- Handler-Level Ownership Check (for PUT) --- // This check is needed if the ModelConfig for PUT requires ownership // AND the user is NOT an admin (admins can bypass ownership checks). // Note: The repository *might* have already enforced ownership if userId was passed. @@ -298,9 +295,9 @@ Future _handlePut( // (e.g., if the repo update method allows admins to update any item even if userId is passed). if (modelConfig.putPermission.requiresOwnershipCheck && !permissionService.isAdmin(authenticatedUser)) { - // Ensure getOwnerId is provided for models requiring ownership check + // Ensure getOwnerId is provided for models requiring ownership check if (modelConfig.getOwnerId == null) { - print( + print( '[ReqID: $requestId] Configuration Error: Model "$modelName" requires ' 'ownership check for PUT but getOwnerId is not provided.', ); @@ -313,7 +310,7 @@ Future _handlePut( // after the update, or ideally, the update method returns the item with owner ID. // Assuming the updatedItem returned by the repo has the owner ID: final itemOwnerId = modelConfig.getOwnerId!(updatedItem); - if (itemOwnerId != authenticatedUser.id) { + if (itemOwnerId != authenticatedUser.id) { // This scenario should ideally not happen if the repository correctly // enforced ownership during the update call when userId was passed. // But as a defense-in-depth, we check here. @@ -328,7 +325,6 @@ Future _handlePut( } } - // Create metadata including the request ID and current timestamp final metadata = ResponseMetadata( requestId: requestId, @@ -368,21 +364,21 @@ Future _handleDelete( String? userIdForRepoCall; // If the model is user-owned, pass the authenticated user's ID to the repository // for ownership enforcement. Otherwise, pass null. - if (modelConfig.getOwnerId != null) { - userIdForRepoCall = authenticatedUser.id; + if (modelConfig.getOwnerId != null) { + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } // --- Handler-Level Ownership Check (for DELETE) --- // For DELETE, we need to fetch the item *before* attempting deletion // to perform the ownership check if required. dynamic itemToDelete; - if (modelConfig.deletePermission.requiresOwnershipCheck && + if (modelConfig.deletePermission.requiresOwnershipCheck && !permissionService.isAdmin(authenticatedUser)) { - // Ensure getOwnerId is provided for models requiring ownership check + // Ensure getOwnerId is provided for models requiring ownership check if (modelConfig.getOwnerId == null) { - print( + print( '[ReqID: $requestId] Configuration Error: Model "$modelName" requires ' 'ownership check for DELETE but getOwnerId is not provided.', ); @@ -406,12 +402,12 @@ Future _handleDelete( case 'country': final repo = context.read>(); itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); - case 'user': + case 'user': final repo = context.read>(); itemToDelete = await repo.read(id: id, userId: userIdForRepoCall); // Add cases for other models default: - print( + print( '[ReqID: $requestId] Error: Unsupported model type "$modelName" reached _handleDelete ownership check.', ); // Throw an exception to be caught by the errorHandler @@ -431,11 +427,10 @@ Future _handleDelete( ); } } - // If itemToDelete is null here, it means the item wasn't found during the read. - // The subsequent delete call will likely throw NotFoundException, which is correct. + // If itemToDelete is null here, it means the item wasn't found during the read. + // The subsequent delete call will likely throw NotFoundException, which is correct. } - // Allow repository exceptions (e.g., NotFoundException) to propagate // upwards to be handled by the standard error handling mechanism. switch (modelName) { @@ -455,7 +450,7 @@ Future _handleDelete( await context .read>() .delete(id: id, userId: userIdForRepoCall); - case 'user': + case 'user': await context .read>() .delete(id: id, userId: userIdForRepoCall); @@ -472,7 +467,6 @@ Future _handleDelete( ); } - // Return 204 No Content for successful deletion (no body, no metadata) return Response(statusCode: HttpStatus.noContent); } diff --git a/routes/api/v1/data/index.dart b/routes/api/v1/data/index.dart index 1377590..60fbea1 100644 --- a/routes/api/v1/data/index.dart +++ b/routes/api/v1/data/index.dart @@ -17,7 +17,8 @@ Future onRequest(RequestContext context) async { final requestId = context.read().id; // User is guaranteed non-null by requireAuthentication() middleware final authenticatedUser = context.read(); - final permissionService = context.read(); // Read PermissionService + final permissionService = + context.read(); // Read PermissionService // The main try/catch block here is removed to let the errorHandler middleware // handle all exceptions thrown by the handlers below. @@ -81,9 +82,9 @@ Future _handleGet( // Note: This is for data *scoping* by the repository, not the permission check. // We infer user-owned based on the presence of getOwnerId function. if (modelConfig.getOwnerId != null) { - userIdForRepoCall = authenticatedUser.id; + userIdForRepoCall = authenticatedUser.id; } else { - userIdForRepoCall = null; + userIdForRepoCall = null; } // Repository exceptions (like NotFoundException, BadRequestException) @@ -146,11 +147,11 @@ Future _handleGet( limit: limit, ); case 'user': // Handle User model specifically if needed, or rely on generic - final repo = context.read>(); - // Note: readAll/readAllByQuery on User repo might need special handling - // depending on whether non-admins can list *all* users or just their own. - // Assuming for now readAll/readAllByQuery with userId scopes to owned. - paginatedResponse = specificQuery.isNotEmpty + final repo = context.read>(); + // Note: readAll/readAllByQuery on User repo might need special handling + // depending on whether non-admins can list *all* users or just their own. + // Assuming for now readAll/readAllByQuery with userId scopes to owned. + paginatedResponse = specificQuery.isNotEmpty ? await repo.readAllByQuery( specificQuery, userId: userIdForRepoCall, @@ -238,7 +239,6 @@ Future _handlePost( userIdForRepoCall = null; } - // Process based on model type dynamic createdItem; @@ -270,12 +270,11 @@ Future _handlePost( userId: userIdForRepoCall, ); case 'user': // Handle User model specifically if needed, or rely on generic - final repo = context.read>(); - // User creation is typically handled by auth routes, not generic data POST. - // Throw Forbidden or BadRequest if attempted here. - throw const ForbiddenException( + // User creation is typically handled by auth routes, not generic data POST. + // Throw Forbidden or BadRequest if attempted here. + throw const ForbiddenException( 'User creation is not allowed via the generic data endpoint.', - ); + ); // Add cases for other models as they are added to ModelRegistry default: // This case should ideally be caught by middleware, but added for safety From f680062bd722c305b263be2f86eee9f8b7eb870d Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 18 May 2025 17:29:06 +0100 Subject: [PATCH 8/8] docs: update README.md with project overview - Added key capabilities section - Updated API endpoints description - Added access and licensing details --- README.md | 275 +++++++++++++----------------------------------------- 1 file changed, 67 insertions(+), 208 deletions(-) diff --git a/README.md b/README.md index d734823..beece3d 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,62 @@ # ht_api -![coverage: percentage](https://img.shields.io/badge/coverage-22-green) +![coverage: percentage](https://img.shields.io/badge/coverage-XX-green) [![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) [![License: PolyForm Free Trial](https://img.shields.io/badge/License-PolyForm%20Free%20Trial-blue)](https://polyformproject.org/licenses/free-trial/1.0.0) -## Overview - -`ht_api` is the central backend API service for the Headlines Toolkit (HT) project. Built with Dart using the Dart Frog framework, it provides essential APIs to support HT client applications (like the mobile app and web dashboard). It aims for simplicity, maintainability, and scalability, currently offering APIs for data access and user settings management. - -## API Endpoints: - -### Authentication (`/api/v1/auth`) - -These endpoints handle user authentication flows. - -**Standard Response Structure:** Uses the same `SuccessApiResponse` and error structure as the Data API. Authentication success responses typically use `SuccessApiResponse` (containing User and token) or `SuccessApiResponse`. - -**Authentication Operations:** - -1. **Request Sign-In Code** - * **Method:** `POST` - * **Path:** `/api/v1/auth/request-code` - * **Request Body:** JSON object `{"email": "user@example.com"}`. - * **Success Response:** `202 Accepted` (Indicates request accepted, email sending initiated). - * **Example:** `POST /api/v1/auth/request-code` with body `{"email": "test@example.com"}` - -2. **Verify Sign-In Code** - * **Method:** `POST` - * **Path:** `/api/v1/auth/verify-code` - * **Request Body:** JSON object `{"email": "user@example.com", "code": "123456"}`. - * **Success Response:** `200 OK` with `SuccessApiResponse` containing the `User` object and the authentication `token`. - * **Error Response:** `400 Bad Request` (e.g., invalid code/email format), `400 Bad Request` via `InvalidInputException` (e.g., code incorrect/expired). - * **Example:** `POST /api/v1/auth/verify-code` with body `{"email": "test@example.com", "code": "654321"}` - -3. **Sign In Anonymously** - * **Method:** `POST` - * **Path:** `/api/v1/auth/anonymous` - * **Request Body:** None. - * **Success Response:** `200 OK` with `SuccessApiResponse` containing the anonymous `User` object and the authentication `token`. - * **Example:** `POST /api/v1/auth/anonymous` - -4. **Initiate Account Linking (Anonymous User)** - * **Method:** `POST` - * **Path:** `/api/v1/auth/link-email` - * **Authentication:** Required (Bearer Token of an *anonymous* user). - * **Request Body:** JSON object `{"email": "user@example.com"}`. - * **Success Response:** `202 Accepted` (Indicates request accepted, email sending initiated). - * **Error Response:** `401 Unauthorized` (if not authenticated), `400 Bad Request` (if not anonymous or invalid email), `409 Conflict` (if email is already in use or linking is pending). - * **Example:** `POST /api/v1/auth/link-email` with body `{"email": "permanent@example.com"}` and `Authorization: Bearer ` header. - -5. **Complete Account Linking (Anonymous User)** - * **Method:** `POST` - * **Path:** `/api/v1/auth/verify-link-email` - * **Authentication:** Required (Bearer Token of the *anonymous* user who initiated the link). - * **Request Body:** JSON object `{"code": "123456"}`. - * **Success Response:** `200 OK` with `SuccessApiResponse` containing the updated (now permanent) `User` object and a **new** authentication `token`. - * **Error Response:** `401 Unauthorized` (if not authenticated), `400 Bad Request` (if not anonymous or invalid code), `400 Bad Request` via `InvalidInputException` (if code is incorrect/expired). - * **Example:** `POST /api/v1/auth/verify-link-email` with body `{"code": "654321"}` and `Authorization: Bearer ` header. - -6. **Get Current User Details** - * **Method:** `GET` - * **Path:** `/api/v1/auth/me` - * **Authentication:** Required (Bearer Token). - * **Success Response:** `200 OK` with `SuccessApiResponse` containing the details of the authenticated user. - * **Error Response:** `401 Unauthorized`. - * **Example:** `GET /api/v1/auth/me` with `Authorization: Bearer ` header. - -7. **Sign Out** - * **Method:** `POST` - * **Path:** `/api/v1/auth/sign-out` - * **Authentication:** Required (Bearer Token). - * **Request Body:** None. - * **Success Response:** `204 No Content` (Indicates successful server-side action, if any). Client is responsible for clearing local token. - * **Error Response:** `401 Unauthorized`. - * **Example:** `POST /api/v1/auth/sign-out` with `Authorization: Bearer ` header. - -8. **Delete Account** - * **Method:** `DELETE` - * **Path:** `/api/v1/auth/delete-account` - * **Authentication:** Required (Bearer Token). - * **Request Body:** None. - * **Success Response:** `204 No Content` (Indicates successful deletion). - * **Error Response:** `401 Unauthorized` (if not authenticated), `404 Not Found` (if the user was already deleted), or other standard errors via the error handler middleware. - * **Example:** `DELETE /api/v1/auth/delete-account` with `Authorization: Bearer ` header. - -### Data (`/api/v1/data`) - -**Authentication required for all operations.** - -This endpoint serves as the single entry point for accessing different data models. The specific model is determined by the `model` query parameter. - -**Supported `model` values (currently global):** `headline`, `category`, `source`, `country` - -**Standard Response Structure:** (Applies to both Data and Settings APIs) - -* **Success:** - ```json - { - "data": , - "metadata": { - "request_id": "unique-uuid-v4-per-request", - "timestamp": "iso-8601-utc-timestamp" - } - } - ``` -* **Error:** - ```json - { - "error": { - "code": "ERROR_CODE_STRING", - "message": "Descriptive error message" - } - } - ``` +🚀 Accelerate the development of your news application backend with **ht_api**, the +dedicated API service for the Headlines Toolkit. Built on the high-performance +Dart Frog framework, `ht_api` provides the essential server-side infrastructure +specifically designed to power robust and feature-rich news applications. + +`ht_api` is a core component of the **Headlines Toolkit**, a comprehensive, +source-available ecosystem designed for building feature-rich news +applications, which also includes a Flutter mobile app and a web-based content +management dashboard. + +## ✨ Key Capabilities + +* 🔒 **Effortless User Authentication:** Provide secure and seamless user access + with flexible flows including passwordless sign-in, anonymous access, and + the ability to easily link anonymous accounts to permanent ones. Focus on + user experience while `ht_api` handles the security complexities. + +* ⚙️ **Synchronized App Settings:** Ensure a consistent and personalized user + experience across devices by effortlessly syncing application preferences + like theme, language, font styles, and more. + +* 👤 **Personalized User Preferences:** Enable richer user interactions by + managing and syncing user-specific data such as saved headlines, search + history, or other personalized content tailored to individual users. + +* 💾 **Robust Data Management:** Securely manage core news application data, + including headlines, categories, and sources, through a well-structured + and protected API. + +* 🔧 **Solid Technical Foundation:** Built with Dart and the high-performance + Dart Frog framework, offering a maintainable codebase, standardized API + responses, and built-in access control for developers. + +## 🔌 API Endpoints -**Data Operations:** - -1. **Get All Items (Collection)** - * **Method:** `GET` - * **Path:** `/api/v1/data?model=` - * **Optional Query Parameters:** `limit=`, `startAfterId=`, other filtering params. - * **Success Response:** `200 OK` with `SuccessApiResponse>`. - * **Example:** `GET /api/v1/data?model=headline&limit=10` - -2. **Create Item** - * **Method:** `POST` - * **Path:** `/api/v1/data?model=` - * **Request Body:** JSON object representing the item to create (using `camelCase` keys). - * **Success Response:** `201 Created` with `SuccessApiResponse` containing the created item. - * **Example:** `POST /api/v1/data?model=category` with body `{"name": "Sports", "description": "News about sports"}` (Requires Bearer token) - -3. **Get Item by ID** - * **Method:** `GET` - * **Path:** `/api/v1/data/?model=` - * **Authentication:** Required (Bearer Token). - * **Success Response:** `200 OK` with `SuccessApiResponse`. - * **Error Response:** `401 Unauthorized`, `404 Not Found`. - * **Example:** `GET /api/v1/data/some-headline-id?model=headline` (Requires Bearer token) - -4. **Update Item by ID** - * **Method:** `PUT` - * **Path:** `/api/v1/data/?model=` - * **Authentication:** Required (Bearer Token). - * **Request Body:** JSON object representing the complete updated item (must include `id`, using `camelCase` keys). - * **Success Response:** `200 OK` with `SuccessApiResponse`. - * **Error Response:** `401 Unauthorized`, `404 Not Found`, `400 Bad Request`. - * **Example:** `PUT /api/v1/data/some-category-id?model=category` with updated category JSON (Requires Bearer token). - -5. **Delete Item by ID** - * **Method:** `DELETE` - * **Path:** `/api/v1/data/?model=` - * **Authentication:** Required (Bearer Token). - * **Success Response:** `204 No Content`. - * **Error Response:** `401 Unauthorized`, `404 Not Found`. - * **Example:** `DELETE /api/v1/data/some-source-id?model=source` (Requires Bearer token) - -### User Settings (`/api/v1/users/{userId}/settings`) - -These endpoints manage application settings for an authenticated user. The `{userId}` in the path must match the ID of the authenticated user. - -**Authentication Required for all operations.** - -**Standard Response Structure:** Uses the same `SuccessApiResponse` and error structure as the Data API. - -**Settings Operations:** - -1. **Get Display Settings** - * **Method:** `GET` - * **Path:** `/api/v1/users/{userId}/settings/display` - * **Success Response:** `200 OK` with `SuccessApiResponse`. - * **Error Response:** `401 Unauthorized`, `403 Forbidden`. - * **Example:** `GET /api/v1/users/user-abc-123/settings/display` (Requires Bearer token for user-abc-123) - -2. **Update Display Settings** - * **Method:** `PUT` - * **Path:** `/api/v1/users/{userId}/settings/display` - * **Request Body:** JSON object representing the complete `DisplaySettings` (using `camelCase` keys). - * **Success Response:** `200 OK` with `SuccessApiResponse` containing the updated settings. - * **Error Response:** `401 Unauthorized`, `403 Forbidden`, `400 Bad Request`. - * **Example:** `PUT /api/v1/users/user-abc-123/settings/display` with body `{"baseTheme": "dark", ...}` (Requires Bearer token for user-abc-123). - -3. **Get Language Setting** - * **Method:** `GET` - * **Path:** `/api/v1/users/{userId}/settings/language` - * **Success Response:** `200 OK` with `SuccessApiResponse>` (e.g., `{"data": {"language": "en"}, ...}`). - * **Error Response:** `401 Unauthorized`, `403 Forbidden`. - * **Example:** `GET /api/v1/users/user-abc-123/settings/language` (Requires Bearer token for user-abc-123) - -4. **Update Language Setting** - * **Method:** `PUT` - * **Path:** `/api/v1/users/{userId}/settings/language` - * **Request Body:** JSON object `{"language": ""}` (e.g., `{"language": "es"}`). - * **Success Response:** `200 OK` with `SuccessApiResponse>` containing the updated language setting. - * **Error Response:** `401 Unauthorized`, `403 Forbidden`, `400 Bad Request`. - * **Example:** `PUT /api/v1/users/user-abc-123/settings/language` with body `{"language": "fr"}` (Requires Bearer token for user-abc-123). - -5. **Clear All Settings** - * **Method:** `DELETE` - * **Path:** `/api/v1/users/{userId}/settings` - * **Success Response:** `204 No Content`. - * **Error Response:** `401 Unauthorized`, `403 Forbidden`. - * **Example:** `DELETE /api/v1/users/user-abc-123/settings` (Requires Bearer token for user-abc-123) - -## Setup & Running +`ht_api` provides a clear and organized API surface under the `/api/v1/` path. +Key endpoint groups cover authentication, data access, and user settings. + +For complete API specifications, detailed endpoint documentation, +request/response schemas, and error codes, please refer to the dedicated +documentation website [todo:Link to the docs website]. + +## 🔑 Access and Licensing + +`ht_api` is source-available as part of the Headlines Toolkit ecosystem. + +The source code for `ht_api` is available for review as part of the Headlines +Toolkit ecosystem. To acquire a commercial license for building unlimited news +applications with the Headlines Toolkit repositories, please visit the +[Headlines Toolkit GitHub organization page](https://github.com/headlines-toolkit) +for more details. + +## 💻 Setup & Running 1. **Prerequisites:** * Dart SDK (`>=3.0.0`) @@ -220,16 +74,21 @@ These endpoints manage application settings for an authenticated user. The `{use ```bash dart_frog dev ``` - The API will typically be available at `http://localhost:8080`. Fixture data from `lib/src/fixtures/` will be loaded into the in-memory repositories on startup. + The API will typically be available at `http://localhost:8080`. Fixture data + from `lib/src/fixtures/` will be loaded into the in-memory repositories on + startup. -## Testing +## ✅ Testing -* Run tests and check coverage (aim for >= 90%): - ```bash - # Ensure very_good_cli is activated: dart pub global activate very_good_cli - very_good test --min-coverage 90 - ``` - -## License +Ensure the API is robust and meets quality standards by running the test suite: + +```bash +# Ensure very_good_cli is activated: dart pub global activate very_good_cli +very_good test --min-coverage 90 +``` + +Aim for a minimum of 90% line coverage. + +## 📄 License This package is licensed under the [PolyForm Free Trial](LICENSE). Please review the terms before use.