Skip to content

Commit 5c2a85f

Browse files
committed
Implement per-user bucket isolation
Buckets are now transparently namespaced by user ID, allowing multiple users to create buckets with the same name without conflicts. All bucket and object operations in the CLI, core, and API handlers are updated to enforce user-scoped access, creation, deletion, and listing. Documentation and tests are updated to reflect and verify multi-tenant isolation, ensuring users cannot access or see other users' buckets or objects.
1 parent c0f204e commit 5c2a85f

File tree

17 files changed

+634
-93
lines changed

17 files changed

+634
-93
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ base64 = { workspace = true }
4444
md-5 = { workspace = true }
4545
blake3 = { workspace = true }
4646
hex = { workspace = true }
47+
jsonwebtoken = { workspace = true }
48+
serde = { workspace = true }
4749

4850
[[example]]
4951
name = "basic_usage"

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ assert_eq!(shared_secret, recovered);
325325
- **Storage nodes are untrusted**: All sensitive data is encrypted client-side
326326
- **Gateway is trusted for routing**: But never sees encryption keys
327327
- **Keys never leave the client**: HPKE ensures end-to-end encryption
328+
- **Per-user bucket isolation**: Each user's buckets are automatically namespaced - multiple users can have buckets with the same name without conflicts
328329

329330
### Key Management
330331

crates/fula-cli/src/handlers/batch.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ pub async fn delete_objects(
2222
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Write access required"));
2323
}
2424

25-
let mut bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
25+
// User-scoped bucket access
26+
let mut bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
2627

2728
// Parse the delete request XML
2829
let body_str = String::from_utf8_lossy(&body);

crates/fula-cli/src/handlers/bucket.rs

Lines changed: 17 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ pub async fn create_bucket(
2626
let owner = Owner::new(&session.hashed_user_id)
2727
.with_display_name(session.display_name.clone().unwrap_or_default());
2828

29-
state.bucket_manager.create_bucket(bucket.clone(), owner).await?;
29+
// User-scoped bucket creation (each user has isolated namespace)
30+
state.bucket_manager.create_bucket_for_user(
31+
&session.hashed_user_id,
32+
bucket.clone(),
33+
owner,
34+
).await?;
3035

3136
Ok((
3237
StatusCode::OK,
@@ -45,26 +50,20 @@ pub async fn delete_bucket(
4550
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Write access required"));
4651
}
4752

48-
// Check ownership (Security audit fix A3: compare hashed IDs)
49-
let metadata = state.bucket_manager.get_bucket_metadata(&bucket)
50-
.ok_or_else(|| ApiError::s3(S3ErrorCode::NoSuchBucket, "Bucket not found"))?;
51-
52-
if !session.can_access_bucket(&metadata.owner_id) {
53-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Not bucket owner"));
54-
}
55-
56-
state.bucket_manager.delete_bucket(&bucket).await?;
53+
// User-scoped bucket deletion (user can only delete their own buckets)
54+
state.bucket_manager.delete_bucket_for_user(&session.hashed_user_id, &bucket).await?;
5755

5856
Ok(StatusCode::NO_CONTENT.into_response())
5957
}
6058

6159
/// HEAD /{bucket} - Check if bucket exists
6260
pub async fn head_bucket(
6361
State(state): State<Arc<AppState>>,
64-
Extension(_session): Extension<UserSession>,
62+
Extension(session): Extension<UserSession>,
6563
Path(bucket): Path<String>,
6664
) -> Result<Response, ApiError> {
67-
if !state.bucket_manager.bucket_exists(&bucket) {
65+
// User-scoped bucket check (user can only see their own buckets)
66+
if !state.bucket_manager.bucket_exists_for_user(&session.hashed_user_id, &bucket) {
6867
return Err(ApiError::s3(S3ErrorCode::NoSuchBucket, "Bucket not found"));
6968
}
7069

@@ -99,13 +98,9 @@ pub async fn list_objects(
9998
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Read access required"));
10099
}
101100

102-
let bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
103-
104-
// Verify bucket ownership (security audit fix #1)
105-
if !session.can_access_bucket(&bucket.metadata().owner_id) {
106-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "You do not have access to this bucket"));
107-
}
108-
101+
// User-scoped bucket access (user can only access their own buckets)
102+
let bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
103+
109104
let result = bucket.list_objects(
110105
params.prefix.as_deref(),
111106
params.delimiter.as_deref(),
@@ -140,9 +135,11 @@ pub async fn list_objects(
140135
/// GET /{bucket}?location - Get bucket location
141136
pub async fn get_bucket_location(
142137
State(state): State<Arc<AppState>>,
138+
Extension(session): Extension<UserSession>,
143139
Path(bucket): Path<String>,
144140
) -> Result<Response, ApiError> {
145-
if !state.bucket_manager.bucket_exists(&bucket) {
141+
// User-scoped bucket check
142+
if !state.bucket_manager.bucket_exists_for_user(&session.hashed_user_id, &bucket) {
146143
return Err(ApiError::s3(S3ErrorCode::NoSuchBucket, "Bucket not found"));
147144
}
148145

crates/fula-cli/src/handlers/multipart.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ pub async fn create_multipart_upload(
3737
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Write access required"));
3838
}
3939

40-
// Verify bucket exists
41-
if !state.bucket_manager.bucket_exists(&bucket) {
40+
// Verify bucket exists for this user
41+
if !state.bucket_manager.bucket_exists_for_user(&session.hashed_user_id, &bucket) {
4242
return Err(ApiError::s3(S3ErrorCode::NoSuchBucket, "Bucket not found"));
4343
}
4444

@@ -196,8 +196,8 @@ pub async fn complete_multipart_upload(
196196
metadata = metadata.with_user_metadata(k, v);
197197
}
198198

199-
// Store in bucket
200-
let mut bucket_handle = state.bucket_manager.open_bucket(&bucket).await?;
199+
// Store in bucket (user-scoped)
200+
let mut bucket_handle = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket).await?;
201201
bucket_handle.put_object(key.clone(), metadata).await?;
202202
let bucket_root_cid = bucket_handle.flush().await?;
203203

crates/fula-cli/src/handlers/object.rs

Lines changed: 19 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,14 @@ pub async fn put_object(
7272
}
7373
}
7474

75-
// Store in bucket
76-
tracing::debug!(bucket = %bucket_name, "Opening bucket");
77-
let mut bucket = state.bucket_manager.open_bucket(&bucket_name).await
75+
// Store in bucket (user-scoped)
76+
tracing::debug!(bucket = %bucket_name, "Opening user-scoped bucket");
77+
let mut bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await
7878
.map_err(|e| {
7979
tracing::error!(error = %e, bucket = %bucket_name, "Failed to open bucket");
8080
e
8181
})?;
82-
83-
// Verify bucket ownership (security audit fix #1)
84-
if !session.can_access_bucket(&bucket.metadata().owner_id) {
85-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "You do not have access to this bucket"));
86-
}
87-
82+
8883
tracing::debug!(key = %key, "Storing object metadata");
8984
bucket.put_object(key.clone(), metadata).await
9085
.map_err(|e| {
@@ -142,13 +137,9 @@ pub async fn get_object(
142137
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Read access required"));
143138
}
144139

145-
let bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
146-
147-
// Verify bucket ownership (security audit fix #1)
148-
if !session.can_access_bucket(&bucket.metadata().owner_id) {
149-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "You do not have access to this bucket"));
150-
}
151-
140+
// User-scoped bucket access
141+
let bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
142+
152143
let metadata = bucket.get_object(&key).await?
153144
.ok_or_else(|| ApiError::s3_with_resource(
154145
S3ErrorCode::NoSuchKey,
@@ -301,13 +292,9 @@ pub async fn head_object(
301292
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Read access required"));
302293
}
303294

304-
let bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
305-
306-
// Verify bucket ownership (security audit fix #1)
307-
if !session.can_access_bucket(&bucket.metadata().owner_id) {
308-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "You do not have access to this bucket"));
309-
}
310-
295+
// User-scoped bucket access
296+
let bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
297+
311298
let metadata = bucket.get_object(&key).await?
312299
.ok_or_else(|| ApiError::s3_with_resource(
313300
S3ErrorCode::NoSuchKey,
@@ -343,13 +330,9 @@ pub async fn delete_object(
343330
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Write access required"));
344331
}
345332

346-
let mut bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
347-
348-
// Verify bucket ownership (security audit fix #1)
349-
if !session.can_access_bucket(&bucket.metadata().owner_id) {
350-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "You do not have access to this bucket"));
351-
}
352-
333+
// User-scoped bucket access
334+
let mut bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
335+
353336
bucket.delete_object(&key).await?;
354337
bucket.flush().await?;
355338

@@ -390,33 +373,23 @@ pub async fn copy_object(
390373
.split_once('/')
391374
.ok_or_else(|| ApiError::s3(S3ErrorCode::InvalidArgument, "Invalid copy source format"))?;
392375

393-
// Get source object
394-
let source_bucket_handle = state.bucket_manager.open_bucket(source_bucket).await?;
395-
396-
// Verify source bucket ownership (security audit fix #1)
397-
if !session.can_access_bucket(&source_bucket_handle.metadata().owner_id) {
398-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "You do not have access to the source bucket"));
399-
}
400-
376+
// Get source object (user-scoped)
377+
let source_bucket_handle = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, source_bucket).await?;
378+
401379
let source_metadata = source_bucket_handle.get_object(source_key).await?
402380
.ok_or_else(|| ApiError::s3_with_resource(
403381
S3ErrorCode::NoSuchKey,
404382
"Source object not found",
405383
copy_source,
406384
))?;
407385

408-
// Copy to destination
409-
// Security audit fix A3: Use hashed user ID
386+
// Copy to destination (user-scoped)
410387
let mut dest_metadata = source_metadata.clone();
411388
dest_metadata.last_modified = chrono::Utc::now();
412389
dest_metadata.owner_id = Some(session.hashed_user_id.clone());
413390

414-
let mut dest_bucket_handle = state.bucket_manager.open_bucket(&dest_bucket).await?;
415-
416-
// Verify destination bucket ownership (security audit fix #1)
417-
if !session.can_access_bucket(&dest_bucket_handle.metadata().owner_id) {
418-
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "You do not have access to the destination bucket"));
419-
}
391+
let mut dest_bucket_handle = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &dest_bucket).await?;
392+
420393
dest_bucket_handle.put_object(dest_key, dest_metadata.clone()).await?;
421394
dest_bucket_handle.flush().await?;
422395

crates/fula-cli/src/handlers/service.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,15 @@ pub async fn list_buckets(
1515
State(state): State<Arc<AppState>>,
1616
Extension(session): Extension<UserSession>,
1717
) -> Result<Response, ApiError> {
18-
let buckets = state.bucket_manager.list_buckets();
19-
20-
// Filter to buckets owned by this user (or show all for admin)
21-
// Security audit fix A3: Compare using hashed user IDs
18+
// User-scoped bucket listing (returns only this user's buckets)
19+
let buckets = state.bucket_manager.list_buckets_for_user(&session.hashed_user_id);
20+
2221
let user_buckets: Vec<_> = buckets
2322
.into_iter()
24-
.filter(|b| session.can_access_bucket(&b.owner_id))
2523
.map(|b| (b.name, b.created_at))
2624
.collect();
2725

28-
// Return hashed user ID in XML for privacy (Security audit fix A3)
26+
// Return hashed user ID in XML for privacy
2927
let xml_response = xml::list_all_my_buckets_result(
3028
&session.hashed_user_id,
3129
session.display_name.as_deref().unwrap_or("User"),

crates/fula-cli/src/handlers/tagging.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ pub async fn get_object_tagging(
2020
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Read access required"));
2121
}
2222

23-
let bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
23+
// User-scoped bucket access
24+
let bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
2425

2526
let metadata = bucket.get_object(&key).await?
2627
.ok_or_else(|| ApiError::s3_with_resource(
@@ -68,7 +69,8 @@ pub async fn put_object_tagging(
6869
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Write access required"));
6970
}
7071

71-
let mut bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
72+
// User-scoped bucket access
73+
let mut bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
7274

7375
let mut metadata = bucket.get_object(&key).await?
7476
.ok_or_else(|| ApiError::s3_with_resource(
@@ -100,7 +102,8 @@ pub async fn delete_object_tagging(
100102
return Err(ApiError::s3(S3ErrorCode::AccessDenied, "Write access required"));
101103
}
102104

103-
let mut bucket = state.bucket_manager.open_bucket(&bucket_name).await?;
105+
// User-scoped bucket access
106+
let mut bucket = state.bucket_manager.open_bucket_for_user(&session.hashed_user_id, &bucket_name).await?;
104107

105108
let mut metadata = bucket.get_object(&key).await?
106109
.ok_or_else(|| ApiError::s3_with_resource(

crates/fula-cli/src/routes.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async fn bucket_or_list_handler(
8282
if query.uploads.is_some() {
8383
handlers::list_multipart_uploads(state, session, path).await
8484
} else if query.location.is_some() {
85-
handlers::get_bucket_location(state, path).await
85+
handlers::get_bucket_location(state, session, path).await
8686
} else {
8787
let list_params = handlers::ListObjectsParams {
8888
list_type: query.list_type,

0 commit comments

Comments
 (0)