Skip to content
143 changes: 143 additions & 0 deletions lib/src/middlewares/data_fetch_middleware.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import 'package:core/core.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:data_repository/data_repository.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/services/dashboard_summary_service.dart';
import 'package:logging/logging.dart';

final _log = Logger('DataFetchMiddleware');

/// Middleware to fetch a data item by its ID and provide it to the context.
///
/// This middleware is responsible for:
/// 1. Reading the `modelName` and item `id` from the context.
/// 2. Calling the appropriate data repository to fetch the item.
/// 3. If the item is found, providing it to the downstream context wrapped in a
/// [FetchedItem] for type safety.
/// 4. If the item is not found, throwing a [NotFoundException] to halt the
/// request pipeline early.
///
/// This centralizes the item fetching logic for all item-specific routes,
/// ensuring that subsequent middleware (like ownership checks) and the final
/// route handler can safely assume the item exists in the context.
Middleware dataFetchMiddleware() {
return (handler) {
return (context) async {
final modelName = context.read<String>();
final id = context.request.uri.pathSegments.last;

_log.info('Fetching item for model "$modelName", id "$id".');

final item = await _fetchItem(context, modelName, id);

if (item == null) {
_log.warning(
'Item not found for model "$modelName", id "$id".',
);
throw NotFoundException(
'The requested item of type "$modelName" with id "$id" was not found.',
);
}

_log.finer('Item found. Providing to context.');
final updatedContext = context.provide<FetchedItem<dynamic>>(
() => FetchedItem(item),
);

return handler(updatedContext);
};
};
}

/// Helper function to fetch an item from the correct repository based on the
/// model name.
///
/// This function contains the switch statement that maps a `modelName` string
/// to a specific `DataRepository` call.
///
/// Throws [OperationFailedException] for unsupported model types.
Future<dynamic> _fetchItem(
RequestContext context,
String modelName,
String id,
) async {
// The `userId` is not needed here because this middleware's purpose is to
// fetch the item regardless of ownership. Ownership is checked in a
// subsequent middleware. We pass `null` for `userId` to ensure we are
// performing a global lookup for the item.
const String? userId = null;

try {
switch (modelName) {
case 'headline':
return await context.read<DataRepository<Headline>>().read(
id: id,
userId: userId,
);
case 'topic':
return await context
.read<DataRepository<Topic>>()
.read(id: id, userId: userId);
case 'source':
return await context.read<DataRepository<Source>>().read(
id: id,
userId: userId,
);
case 'country':
return await context.read<DataRepository<Country>>().read(
id: id,
userId: userId,
);
case 'language':
return await context.read<DataRepository<Language>>().read(
id: id,
userId: userId,
);
case 'user':
return await context
.read<DataRepository<User>>()
.read(id: id, userId: userId);
case 'user_app_settings':
return await context.read<DataRepository<UserAppSettings>>().read(
id: id,
userId: userId,
);
case 'user_content_preferences':
return await context
.read<DataRepository<UserContentPreferences>>()
.read(
id: id,
userId: userId,
);
case 'remote_config':
return await context.read<DataRepository<RemoteConfig>>().read(
id: id,
userId: userId,
);
case 'dashboard_summary':
// This is a special case that doesn't use a standard repository.
return await context.read<DashboardSummaryService>().getSummary();
default:
_log.warning('Unsupported model type "$modelName" for fetch operation.');
throw OperationFailedException(
'Unsupported model type "$modelName" for fetch operation.',
);
}
} on NotFoundException {
// The repository will throw this if the item doesn't exist.
// We return null to let the main middleware handler throw a more
// detailed exception.
return null;
} catch (e, s) {
_log.severe(
'Unhandled exception in _fetchItem for model "$modelName", id "$id".',
e,
s,
);
// Re-throw as a standard exception type that the main error handler
// can process into a 500 error, while preserving the original cause.
throw OperationFailedException(
'An internal error occurred while fetching the item: $e',
);
}
}
65 changes: 25 additions & 40 deletions lib/src/middlewares/ownership_check_middleware.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import 'package:core/core.dart';
import 'package:dart_frog/dart_frog.dart';
import 'package:data_repository/data_repository.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/rbac/permission_service.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/registry/model_registry.dart';

Expand All @@ -19,28 +18,27 @@ class FetchedItem<T> {
/// Middleware to check if the authenticated user is the owner of the requested
/// item.
///
/// This middleware is designed to run on item-specific routes (e.g., `/[id]`).
/// It performs the following steps:
/// This middleware runs *after* the `dataFetchMiddleware`, which means it can
/// safely assume that the requested item has already been fetched and is
/// available in the context.
///
/// 1. Determines if an ownership check is required for the current action
/// (GET, PUT, DELETE) based on the `ModelConfig`.
/// 2. If a check is required and the user is not an admin, it fetches the
/// item from the database.
/// 3. It then compares the item's owner ID with the authenticated user's ID.
/// 4. If the check fails, it throws a [ForbiddenException].
/// 5. If the check passes, it provides the fetched item into the request
/// context via `context.provide<FetchedItem<dynamic>>`. This prevents the
/// downstream route handler from needing to fetch the item again.
/// It performs the following steps:
/// 1. Determines if an ownership check is required for the current action
/// based on the `ModelConfig`.
/// 2. If a check is required and the user is not an admin, it reads the
/// pre-fetched item from the context.
/// 3. It then compares the item's owner ID with the authenticated user's ID.
/// 4. If the IDs do not match, it throws a [ForbiddenException].
/// 5. If the check is not required or passes, it calls the next handler.
Middleware ownershipCheckMiddleware() {
return (handler) {
return (context) async {
final modelName = context.read<String>();
final modelConfig = context.read<ModelConfig<dynamic>>();
final user = context.read<User>();
final permissionService = context.read<PermissionService>();
final method = context.request.method;
final id = context.request.uri.pathSegments.last;

// Determine the required permission configuration for the current method.
ModelActionPermission permission;
switch (method) {
case HttpMethod.get:
Expand All @@ -50,54 +48,41 @@ Middleware ownershipCheckMiddleware() {
case HttpMethod.delete:
permission = modelConfig.deletePermission;
default:
// For other methods, no ownership check is performed here.
// For any other methods, no ownership check is performed.
return handler(context);
}

// If no ownership check is required or if the user is an admin,
// proceed to the next handler without fetching the item.
// If no ownership check is required for this action, or if the user is
// an admin (who bypasses ownership checks), proceed immediately.
if (!permission.requiresOwnershipCheck ||
permissionService.isAdmin(user)) {
return handler(context);
}

// At this point, an ownership check is required for a non-admin user.

// Ensure the model is configured to support ownership checks.
if (modelConfig.getOwnerId == null) {
throw const OperationFailedException(
'Internal Server Error: Model configuration error for ownership check.',
);
}

final userIdForRepoCall = user.id;
dynamic item;

switch (modelName) {
case 'user':
final repo = context.read<DataRepository<User>>();
item = await repo.read(id: id, userId: userIdForRepoCall);
case 'user_app_settings':
final repo = context.read<DataRepository<UserAppSettings>>();
item = await repo.read(id: id, userId: userIdForRepoCall);
case 'user_content_preferences':
final repo = context.read<DataRepository<UserContentPreferences>>();
item = await repo.read(id: id, userId: userIdForRepoCall);
default:
throw OperationFailedException(
'Ownership check not implemented for model "$modelName".',
);
}
// Read the item that was pre-fetched by the dataFetchMiddleware.
// This is guaranteed to exist because dataFetchMiddleware would have
// thrown a NotFoundException if the item did not exist.
final item = context.read<FetchedItem<dynamic>>().data;

// Compare the item's owner ID with the authenticated user's ID.
final itemOwnerId = modelConfig.getOwnerId!(item);
if (itemOwnerId != user.id) {
throw const ForbiddenException(
'You do not have permission to access this item.',
);
}

final updatedContext = context.provide<FetchedItem<dynamic>>(
() => FetchedItem(item),
);

return handler(updatedContext);
// If the ownership check passes, proceed to the final route handler.
return handler(context);
};
};
}
9 changes: 9 additions & 0 deletions routes/_middleware.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,20 @@ Handler middleware(Handler handler) {
if (!_loggerConfigured) {
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
// A more detailed logger that includes the error and stack trace.
// ignore: avoid_print
print(
'${record.level.name}: ${record.time}: ${record.loggerName}: '
'${record.message}',
);
if (record.error != null) {
// ignore: avoid_print
print(' ERROR: ${record.error}');
}
if (record.stackTrace != null) {
// ignore: avoid_print
print(' STACK TRACE: ${record.stackTrace}');
}
});
_loggerConfigured = true;
}
Expand Down
29 changes: 19 additions & 10 deletions routes/api/v1/data/[id]/_middleware.dart
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
import 'package:dart_frog/dart_frog.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/data_fetch_middleware.dart';
import 'package:flutter_news_app_api_server_full_source_code/src/middlewares/ownership_check_middleware.dart';

/// Middleware specific to the item-level `/api/v1/data/[id]` route path.
///
/// This middleware applies the [ownershipCheckMiddleware] to perform an
/// ownership check on the requested item *after* the parent middleware
/// (`/api/v1/data/_middleware.dart`) has already performed authentication and
/// authorization checks.
/// This middleware chain is responsible for fetching the requested data item
/// and then performing an ownership check on it.
///
/// This ensures that only authorized users can proceed, and then this
/// middleware adds the final layer of security by verifying item ownership
/// for non-admin users when required by the model's configuration.
/// The execution order is as follows:
/// 1. `dataFetchMiddleware`: This runs first. It fetches the item by its ID
/// from the database and provides it to the context. If the item is not
/// found, it throws a `NotFoundException`, aborting the request.
/// 2. `ownershipCheckMiddleware`: This runs second. It reads the fetched item
/// from the context and verifies that the authenticated user is the owner,
/// if the model's configuration requires such a check.
///
/// This ensures that the final route handler only executes for valid,
/// authorized requests and can safely assume the requested item exists.
Handler middleware(Handler handler) {
// The `ownershipCheckMiddleware` will run after the middleware from
// `/api/v1/data/_middleware.dart` (authn, authz, model validation).
return handler.use(ownershipCheckMiddleware());
// The middleware is applied in reverse order of execution.
// `ownershipCheckMiddleware` is the inner middleware, running after
// `dataFetchMiddleware`.
return handler
.use(ownershipCheckMiddleware()) // Runs second
.use(dataFetchMiddleware()); // Runs first
}
Loading
Loading