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. 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/middlewares/authorization_middleware.dart b/lib/src/middlewares/authorization_middleware.dart new file mode 100644 index 0000000..71dcc69 --- /dev/null +++ b/lib/src/middlewares/authorization_middleware.dart @@ -0,0 +1,95 @@ +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..4bd86f6 --- /dev/null +++ b/lib/src/rbac/permissions.dart @@ -0,0 +1,55 @@ +// 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..ac95003 100644 --- a/lib/src/registry/model_registry.dart +++ b/lib/src/registry/model_registry.dart @@ -2,31 +2,58 @@ // ignore_for_file: strict_raw_type, lines_longer_than_80_chars import 'package:dart_frog/dart_frog.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'; -/// 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, +/// 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 managed by admins (only admins can Create, - /// Update, Delete), but read operations (GET) are allowed for all - /// authenticated users. - adminOwnedReadAllowed, + /// Requires the user to have the [UserRole.admin] role. + adminOnly, - /// 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 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 +64,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 +77,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 +103,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 +115,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/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..e4db429 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,9 @@ class JwtAuthTokenService implements AuthTokenService { // Custom claims (optional, include what's useful) 'email': user.email, - 'isAnonymous': user.isAnonymous, + '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/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..d56d0a8 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,47 @@ 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,61 +68,78 @@ 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.isAdmin) { - 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) { + // 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; } - // 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 @@ -165,14 +172,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,136 +194,135 @@ 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.isAdmin) { - throw const ForbiddenException( - 'Only administrators can update this resource.', - ); - } - if (modelConfig.ownership == ModelOwnership.userOwned && - !authenticatedUser.isAdmin) { - // 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) { + // 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; } + 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 @@ -346,32 +354,83 @@ 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.isAdmin) { - throw const ForbiddenException( - 'Only administrators can delete this resource.', - ); - } - if (modelConfig.ownership == ModelOwnership.userOwned && - !authenticatedUser.isAdmin) { - // 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) { + // 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; } + // --- 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,16 +450,20 @@ 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.', ); } 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 cfa7869..60fbea1 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,36 @@ 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 +56,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 +75,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.isAdmin) { - 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) { + // 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; } - 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 +203,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 +222,66 @@ 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.isAdmin) { - 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 + // 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 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));