Skip to content

Commit 9de9ebb

Browse files
committed
feat(auth): implement access control based on ownership
- Added ownership types to models - Implemented access control in routes - Updated model configurations
1 parent ae467f3 commit 9de9ebb

File tree

3 files changed

+81
-14
lines changed

3 files changed

+81
-14
lines changed

lib/src/registry/model_registry.dart

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,19 @@ import 'package:dart_frog/dart_frog.dart';
55
import 'package:ht_data_client/ht_data_client.dart';
66
import 'package:ht_shared/ht_shared.dart';
77

8-
/// Defines the ownership type of a data model.
8+
/// Defines the ownership type of a data model and associated access rules.
99
enum ModelOwnership {
10-
/// Data is global and not specific to any single user.
11-
global,
10+
/// Indicates the resource is fully managed by admins (only admins can
11+
/// Create, Read, Update, Delete).
12+
adminOwned,
1213

13-
/// Data is owned by a specific user.
14+
/// Indicates the resource is managed by admins (only admins can Create,
15+
/// Update, Delete), but read operations (GET) are allowed for all
16+
/// authenticated users.
17+
adminOwnedReadAllowed,
18+
19+
/// Indicates the resource is owned by a specific user (only the owning user
20+
/// or an admin can Create, Read, Update, Delete).
1421
userOwned,
1522
}
1623

@@ -65,22 +72,22 @@ final modelRegistry = <String, ModelConfig<dynamic>>{
6572
'headline': ModelConfig<Headline>(
6673
fromJson: Headline.fromJson,
6774
getId: (h) => h.id,
68-
ownership: ModelOwnership.global, // Added ownership
75+
ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership
6976
),
7077
'category': ModelConfig<Category>(
7178
fromJson: Category.fromJson,
7279
getId: (c) => c.id,
73-
ownership: ModelOwnership.global, // Added ownership
80+
ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership
7481
),
7582
'source': ModelConfig<Source>(
7683
fromJson: Source.fromJson,
7784
getId: (s) => s.id,
78-
ownership: ModelOwnership.global, // Added ownership
85+
ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership
7986
),
8087
'country': ModelConfig<Country>(
8188
fromJson: Country.fromJson,
8289
getId: (c) => c.id, // Assuming Country has an 'id' field
83-
ownership: ModelOwnership.global, // Added ownership
90+
ownership: ModelOwnership.adminOwnedReadAllowed, // Updated ownership
8491
),
8592
};
8693

routes/api/v1/data/[id].dart

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,19 @@ Future<Response> _handleGet(
8080
User authenticatedUser,
8181
String requestId,
8282
) async {
83+
// Apply access control based on ownership type for GET requests
84+
if (modelConfig.ownership == ModelOwnership.adminOwned &&
85+
!authenticatedUser.isAdmin) {
86+
throw const ForbiddenException(
87+
'You do not have permission to read this resource.',
88+
);
89+
}
90+
8391
dynamic item;
8492

8593
String? userIdForRepoCall;
94+
// For userOwned models, pass the authenticated user's ID to the repository
95+
// for filtering. For adminOwned/adminOwnedReadAllowed, pass null.
8696
if (modelConfig.ownership == ModelOwnership.userOwned) {
8797
userIdForRepoCall = authenticatedUser.id;
8898
} else {
@@ -188,14 +198,29 @@ Future<Response> _handlePut(
188198
);
189199
}
190200

201+
// Apply access control based on ownership type for PUT requests
202+
if ((modelConfig.ownership == ModelOwnership.adminOwned ||
203+
modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) &&
204+
!authenticatedUser.isAdmin) {
205+
throw const ForbiddenException(
206+
'Only administrators can update this resource.',
207+
);
208+
}
209+
if (modelConfig.ownership == ModelOwnership.userOwned &&
210+
!authenticatedUser.isAdmin) {
211+
// For userOwned, non-admins must be the owner.
212+
// The repository will enforce this check when userIdForRepoCall is passed.
213+
}
214+
191215
dynamic updatedItem;
192216

193217
String? userIdForRepoCall;
218+
// For userOwned models, pass the authenticated user's ID to the repository
219+
// for ownership enforcement. For adminOwned/adminOwnedReadAllowed, pass null
220+
// (repository handles admin updates).
194221
if (modelConfig.ownership == ModelOwnership.userOwned) {
195222
userIdForRepoCall = authenticatedUser.id;
196223
} else {
197-
// TODO(fulleni): For global models, update might imply admin rights.
198-
// For now, pass null, consider adding an admin user check.
199224
userIdForRepoCall = null;
200225
}
201226

@@ -323,12 +348,27 @@ Future<Response> _handleDelete(
323348
User authenticatedUser,
324349
String requestId,
325350
) async {
351+
// Apply access control based on ownership type for DELETE requests
352+
if ((modelConfig.ownership == ModelOwnership.adminOwned ||
353+
modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) &&
354+
!authenticatedUser.isAdmin) {
355+
throw const ForbiddenException(
356+
'Only administrators can delete this resource.',
357+
);
358+
}
359+
if (modelConfig.ownership == ModelOwnership.userOwned &&
360+
!authenticatedUser.isAdmin) {
361+
// For userOwned, non-admins must be the owner.
362+
// The repository will enforce this check when userIdForRepoCall is passed.
363+
}
364+
326365
String? userIdForRepoCall;
366+
// For userOwned models, pass the authenticated user's ID to the repository
367+
// for ownership enforcement. For adminOwned/adminOwnedReadAllowed, pass null
368+
// (repository handles admin deletions).
327369
if (modelConfig.ownership == ModelOwnership.userOwned) {
328370
userIdForRepoCall = authenticatedUser.id;
329371
} else {
330-
// TODO(fulleni): For global models, update might imply admin rights.
331-
// For now, pass null, consider adding an admin user check.
332372
userIdForRepoCall = null;
333373
}
334374

routes/api/v1/data/index.dart

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,17 @@ Future<Response> _handleGet(
8282
// Process based on model type
8383
PaginatedResponse<dynamic> paginatedResponse;
8484

85+
// Apply access control based on ownership type for GET requests
86+
if (modelConfig.ownership == ModelOwnership.adminOwned &&
87+
!authenticatedUser.isAdmin) {
88+
throw const ForbiddenException(
89+
'You do not have permission to read this resource.',
90+
);
91+
}
92+
8593
String? userIdForRepoCall;
94+
// For userOwned models, pass the authenticated user's ID to the repository
95+
// for filtering. For adminOwned/adminOwnedReadAllowed, pass null.
8696
if (modelConfig.ownership == ModelOwnership.userOwned) {
8797
userIdForRepoCall = authenticatedUser.id;
8898
} else {
@@ -230,15 +240,25 @@ Future<Response> _handlePost(
230240
);
231241
}
232242

243+
// Apply access control based on ownership type for POST requests
244+
if ((modelConfig.ownership == ModelOwnership.adminOwned ||
245+
modelConfig.ownership == ModelOwnership.adminOwnedReadAllowed) &&
246+
!authenticatedUser.isAdmin) {
247+
throw const ForbiddenException(
248+
'Only administrators can create this resource.',
249+
);
250+
}
251+
233252
// Process based on model type
234253
dynamic createdItem;
235254

236255
String? userIdForRepoCall;
256+
// For userOwned models, pass the authenticated user's ID to the repository
257+
// for associating ownership during creation. For adminOwned/adminOwnedReadAllowed,
258+
// pass null (repository handles admin creation).
237259
if (modelConfig.ownership == ModelOwnership.userOwned) {
238260
userIdForRepoCall = authenticatedUser.id;
239261
} else {
240-
// TODO(fulleni): For global models, update might imply admin rights.
241-
// For now, pass null, consider adding an admin user check.
242262
userIdForRepoCall = null;
243263
}
244264

0 commit comments

Comments
 (0)