Skip to content

Commit 49d1e91

Browse files
committed
feat(auth): create ownership check middleware
- Introduces a new `ownershipCheckMiddleware` to centralize the logic for verifying if a user owns a specific data item. - The middleware is configuration-driven, using `ModelConfig` to determine if a check is necessary for the requested action. - If a check is required, it fetches the item and provides it to the downstream handler, preventing redundant database reads. - Throws a `ForbiddenException` if the ownership check fails. - Adds a `FetchedItem` wrapper class for type-safe context provision.
1 parent 5d559e2 commit 49d1e91

File tree

2 files changed

+107
-0
lines changed

2 files changed

+107
-0
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import 'package:dart_frog/dart_frog.dart';
2+
import 'package:ht_api/src/rbac/permission_service.dart';
3+
import 'package:ht_api/src/registry/model_registry.dart';
4+
import 'package:ht_data_repository/ht_data_repository.dart';
5+
import 'package:ht_shared/ht_shared.dart';
6+
7+
/// A wrapper class to provide a fetched item into the request context.
8+
///
9+
/// This ensures type safety and avoids providing a raw `dynamic` object,
10+
/// which could lead to ambiguity if other dynamic objects are in the context.
11+
class FetchedItem<T> {
12+
/// Creates a wrapper for the fetched item.
13+
const FetchedItem(this.data);
14+
15+
/// The fetched item data.
16+
final T data;
17+
}
18+
19+
/// Middleware to check if the authenticated user is the owner of the requested
20+
/// item.
21+
///
22+
/// This middleware is designed to run on item-specific routes (e.g., `/[id]`).
23+
/// It performs the following steps:
24+
///
25+
/// 1. Determines if an ownership check is required for the current action
26+
/// (GET, PUT, DELETE) based on the `ModelConfig`.
27+
/// 2. If a check is required and the user is not an admin, it fetches the
28+
/// item from the database.
29+
/// 3. It then compares the item's owner ID with the authenticated user's ID.
30+
/// 4. If the check fails, it throws a [ForbiddenException].
31+
/// 5. If the check passes, it provides the fetched item into the request
32+
/// context via `context.provide<FetchedItem<dynamic>>`. This prevents the
33+
/// downstream route handler from needing to fetch the item again.
34+
Middleware ownershipCheckMiddleware() {
35+
return (handler) {
36+
return (context) async {
37+
final modelName = context.read<String>();
38+
final modelConfig = context.read<ModelConfig<dynamic>>();
39+
final user = context.read<User>();
40+
final permissionService = context.read<PermissionService>();
41+
final method = context.request.method;
42+
final id = context.request.uri.pathSegments.last;
43+
44+
ModelActionPermission permission;
45+
switch (method) {
46+
case HttpMethod.get:
47+
permission = modelConfig.getItemPermission;
48+
case HttpMethod.put:
49+
permission = modelConfig.putPermission;
50+
case HttpMethod.delete:
51+
permission = modelConfig.deletePermission;
52+
default:
53+
// For other methods, no ownership check is performed here.
54+
return handler(context);
55+
}
56+
57+
// If no ownership check is required or if the user is an admin,
58+
// proceed to the next handler without fetching the item.
59+
if (!permission.requiresOwnershipCheck ||
60+
permissionService.isAdmin(user)) {
61+
return handler(context);
62+
}
63+
64+
if (modelConfig.getOwnerId == null) {
65+
throw const OperationFailedException(
66+
'Internal Server Error: Model configuration error for ownership check.',
67+
);
68+
}
69+
70+
final userIdForRepoCall = user.id;
71+
dynamic item;
72+
73+
switch (modelName) {
74+
case 'user':
75+
final repo = context.read<HtDataRepository<User>>();
76+
item = await repo.read(id: id, userId: userIdForRepoCall);
77+
case 'user_app_settings':
78+
final repo = context.read<HtDataRepository<UserAppSettings>>();
79+
item = await repo.read(id: id, userId: userIdForRepoCall);
80+
case 'user_content_preferences':
81+
final repo = context.read<HtDataRepository<UserContentPreferences>>();
82+
item = await repo.read(id: id, userId: userIdForRepoCall);
83+
default:
84+
throw OperationFailedException(
85+
'Ownership check not implemented for model "$modelName".',
86+
);
87+
}
88+
89+
final itemOwnerId = modelConfig.getOwnerId!(item);
90+
if (itemOwnerId != user.id) {
91+
throw const ForbiddenException(
92+
'You do not have permission to access this item.',
93+
);
94+
}
95+
96+
final updatedContext = context.provide<FetchedItem<dynamic>>(
97+
() => FetchedItem(item),
98+
);
99+
100+
return handler(updatedContext);
101+
};
102+
};
103+
}

pubspec.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ dependencies:
1313
ht_data_client:
1414
git:
1515
url: https://github.com/headlines-toolkit/ht-data-client.git
16+
ht_data_mongodb:
17+
git:
18+
url: https://github.com/headlines-toolkit/ht-data-mongodb.git
1619
ht_data_postgres:
1720
git:
1821
url: https://github.com/headlines-toolkit/ht-data-postgres.git
@@ -37,6 +40,7 @@ dependencies:
3740

3841
logging: ^1.3.0
3942
meta: ^1.16.0
43+
mongo_dart: ^0.10.5
4044
postgres: ^3.5.6
4145
shelf_cors_headers: ^0.1.5
4246
uuid: ^4.5.1

0 commit comments

Comments
 (0)