diff --git a/libertas_api/migrations/20251102152326_add_user_roles_and_quotas.sql b/libertas_api/migrations/20251102152326_add_user_roles_and_quotas.sql new file mode 100644 index 0000000..f8c6f1f --- /dev/null +++ b/libertas_api/migrations/20251102152326_add_user_roles_and_quotas.sql @@ -0,0 +1,4 @@ +ALTER TABLE users +ADD COLUMN role TEXT NOT NULL DEFAULT 'user', +ADD COLUMN storage_quota BIGINT NOT NULL DEFAULT 10737418240, -- 10 GiB default +ADD COLUMN storage_used BIGINT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/libertas_api/src/factory.rs b/libertas_api/src/factory.rs index d2c1d9e..a5b61f7 100644 --- a/libertas_api/src/factory.rs +++ b/libertas_api/src/factory.rs @@ -32,9 +32,14 @@ pub async fn build_app_state(config: Config) -> CoreResult { let hasher = Arc::new(Argon2Hasher::default()); let tokenizer = Arc::new(JwtGenerator::new(config.jwt_secret.clone())); - let user_service = Arc::new(UserServiceImpl::new(user_repo, hasher, tokenizer.clone())); + let user_service = Arc::new(UserServiceImpl::new( + user_repo.clone(), + hasher, + tokenizer.clone(), + )); let media_service = Arc::new(MediaServiceImpl::new( media_repo.clone(), + user_repo.clone(), config.clone(), nats_client.clone(), )); diff --git a/libertas_api/src/services/album_service.rs b/libertas_api/src/services/album_service.rs index 7eb0ca1..32932a9 100644 --- a/libertas_api/src/services/album_service.rs +++ b/libertas_api/src/services/album_service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use chrono::Utc; use libertas_core::{ + authz, error::{CoreError, CoreResult}, models::Album, repositories::{AlbumRepository, MediaRepository}, @@ -65,9 +66,7 @@ impl AlbumService for AlbumServiceImpl { .await? .ok_or(CoreError::NotFound("Album".to_string(), album_id))?; - // Security check: Only owner (for now) can see album details - if album.owner_id != user_id { - // Later, this would also check share permissions + if !authz::is_owner(user_id, &album) { return Err(CoreError::Auth("Access denied to album".to_string())); } @@ -75,12 +74,16 @@ impl AlbumService for AlbumServiceImpl { } async fn add_media_to_album(&self, data: AddMediaToAlbumData, user_id: Uuid) -> CoreResult<()> { - // 1. Verify the user owns the album - if !self.is_album_owner(user_id, data.album_id).await? { + let album = self + .album_repo + .find_by_id(data.album_id) + .await? + .ok_or(CoreError::NotFound("Album".to_string(), data.album_id))?; + + if !authz::is_owner(user_id, &album) { return Err(CoreError::Auth("User does not own this album".to_string())); } - // 2. Bonus: Verify the user owns all media items for media_id in &data.media_ids { let media = self .media_repo @@ -88,7 +91,7 @@ impl AlbumService for AlbumServiceImpl { .await? .ok_or(CoreError::NotFound("Media".to_string(), *media_id))?; - if media.owner_id != user_id { + if !authz::is_owner(user_id, &media) { return Err(CoreError::Auth(format!( "Access denied to media item {}", media_id @@ -96,7 +99,6 @@ impl AlbumService for AlbumServiceImpl { } } - // 3. Call the repository to add them self.album_repo .add_media_to_album(data.album_id, &data.media_ids) .await diff --git a/libertas_api/src/services/media_service.rs b/libertas_api/src/services/media_service.rs index e451353..a45e982 100644 --- a/libertas_api/src/services/media_service.rs +++ b/libertas_api/src/services/media_service.rs @@ -4,10 +4,11 @@ use async_trait::async_trait; use chrono::Datelike; use futures::stream::StreamExt; use libertas_core::{ + authz, config::Config, error::{CoreError, CoreResult}, models::Media, - repositories::MediaRepository, + repositories::{MediaRepository, UserRepository}, schema::UploadMediaData, services::MediaService, }; @@ -18,6 +19,7 @@ use uuid::Uuid; pub struct MediaServiceImpl { repo: Arc, + user_repo: Arc, config: Config, nats_client: async_nats::Client, } @@ -25,11 +27,13 @@ pub struct MediaServiceImpl { impl MediaServiceImpl { pub fn new( repo: Arc, + user_repo: Arc, config: Config, nats_client: async_nats::Client, ) -> Self { Self { repo, + user_repo, config, nats_client, } @@ -39,6 +43,12 @@ impl MediaServiceImpl { #[async_trait] impl MediaService for MediaServiceImpl { async fn upload_media(&self, mut data: UploadMediaData<'_>) -> CoreResult { + let user = self + .user_repo + .find_by_id(data.owner_id) + .await? + .ok_or(CoreError::NotFound("User".to_string(), data.owner_id))?; + let mut hasher = Sha256::new(); let mut file_bytes = Vec::new(); @@ -47,6 +57,14 @@ impl MediaService for MediaServiceImpl { hasher.update(&chunk); file_bytes.extend_from_slice(&chunk); } + let file_size = file_bytes.len() as i64; + + if user.storage_used + file_size > user.storage_quota { + return Err(CoreError::Auth(format!( + "Storage quota exceeded. Used: {}, Quota: {}", + user.storage_used, user.storage_quota + ))); + } let hash = format!("{:x}", hasher.finalize()); @@ -97,6 +115,9 @@ impl MediaService for MediaServiceImpl { }; self.repo.create(&media_model).await?; + self.user_repo + .update_storage_used(user.id, file_size) + .await?; let job_payload = json!({ "media_id": media_model.id }); self.nats_client @@ -114,7 +135,13 @@ impl MediaService for MediaServiceImpl { .await? .ok_or(CoreError::NotFound("Media".to_string(), id))?; - if media.owner_id != user_id { + let user = self + .user_repo + .find_by_id(user_id) + .await? + .ok_or(CoreError::NotFound("User".to_string(), user_id))?; + + if !authz::is_owner(user_id, &media) && !authz::is_admin(&user) { return Err(CoreError::Auth("Access denied".to_string())); } @@ -132,7 +159,13 @@ impl MediaService for MediaServiceImpl { .await? .ok_or(CoreError::NotFound("Media".to_string(), id))?; - if media.owner_id != user_id { + let user = self + .user_repo + .find_by_id(user_id) + .await? + .ok_or(CoreError::NotFound("User".to_string(), user_id))?; + + if !authz::is_owner(user_id, &media) && !authz::is_admin(&user) { return Err(CoreError::Auth("Access denied".to_string())); } diff --git a/libertas_api/src/services/user_service.rs b/libertas_api/src/services/user_service.rs index d53db79..f823333 100644 --- a/libertas_api/src/services/user_service.rs +++ b/libertas_api/src/services/user_service.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use async_trait::async_trait; use libertas_core::{ error::{CoreError, CoreResult}, - models::User, + models::{Role, User}, repositories::UserRepository, schema::{CreateUserData, LoginUserData}, services::UserService, @@ -57,6 +57,9 @@ impl UserService for UserServiceImpl { hashed_password, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + role: Role::User, + storage_quota: 10 * 1024 * 1024 * 1024, // 10 GB + storage_used: 0, }; self.repo.create(user.clone()).await?; diff --git a/libertas_core/src/authz.rs b/libertas_core/src/authz.rs new file mode 100644 index 0000000..c2ee716 --- /dev/null +++ b/libertas_core/src/authz.rs @@ -0,0 +1,25 @@ +use uuid::Uuid; + +use crate::models::{Album, Media, Role, User}; + +pub trait Ownable { + fn owner_id(&self) -> Uuid; +} +impl Ownable for Media { + fn owner_id(&self) -> Uuid { + self.owner_id + } +} +impl Ownable for Album { + fn owner_id(&self) -> Uuid { + self.owner_id + } +} + +pub fn is_admin(user: &User) -> bool { + user.role == Role::Admin +} + +pub fn is_owner(user_id: Uuid, entity: &impl Ownable) -> bool { + user_id == entity.owner_id() +} diff --git a/libertas_core/src/lib.rs b/libertas_core/src/lib.rs index 999c907..6b94f89 100644 --- a/libertas_core/src/lib.rs +++ b/libertas_core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod authz; pub mod config; pub mod error; pub mod models; diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index 7de80f7..d56a229 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -1,3 +1,20 @@ +#[derive(Debug, Clone, PartialEq, Eq, sqlx::Type)] +#[sqlx(rename_all = "lowercase")] +#[sqlx(type_name = "TEXT")] +pub enum Role { + User, + Admin, +} + +impl Role { + pub fn as_str(&self) -> &'static str { + match self { + Role::User => "user", + Role::Admin => "admin", + } + } +} + pub struct Media { pub id: uuid::Uuid, pub owner_id: uuid::Uuid, @@ -11,7 +28,7 @@ pub struct Media { pub height: Option, } -#[derive(Clone)] +#[derive(Clone, sqlx::FromRow)] pub struct User { pub id: uuid::Uuid, pub username: String, @@ -19,6 +36,10 @@ pub struct User { pub hashed_password: String, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, + + pub role: Role, + pub storage_quota: i64, // in bytes + pub storage_used: i64, // in bytes } pub struct Album { diff --git a/libertas_core/src/repositories.rs b/libertas_core/src/repositories.rs index 22c9407..3d89c32 100644 --- a/libertas_core/src/repositories.rs +++ b/libertas_core/src/repositories.rs @@ -27,6 +27,7 @@ pub trait UserRepository: Send + Sync { async fn find_by_email(&self, email: &str) -> CoreResult>; async fn find_by_username(&self, username: &str) -> CoreResult>; async fn find_by_id(&self, id: Uuid) -> CoreResult>; + async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()>; } #[async_trait] diff --git a/libertas_infra/src/repositories/user_repository.rs b/libertas_infra/src/repositories/user_repository.rs index b18cc0e..09793d7 100644 --- a/libertas_infra/src/repositories/user_repository.rs +++ b/libertas_infra/src/repositories/user_repository.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use libertas_core::{ error::{CoreError, CoreResult}, - models::User, + models::{Role, User}, repositories::UserRepository, }; use sqlx::{PgPool, SqlitePool, types::Uuid}; @@ -33,15 +33,18 @@ impl UserRepository for PostgresUserRepository { async fn create(&self, user: User) -> CoreResult<()> { sqlx::query!( r#" - INSERT INTO users (id, username, email, hashed_password, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO users (id, username, email, hashed_password, created_at, updated_at, role, storage_quota, storage_used) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) "#, user.id, user.username, user.email, user.hashed_password, user.created_at, - user.updated_at + user.updated_at, + user.role.as_str(), + user.storage_quota, + user.storage_used ) .execute(&self.pool) .await @@ -51,24 +54,74 @@ impl UserRepository for PostgresUserRepository { } async fn find_by_email(&self, email: &str) -> CoreResult> { - sqlx::query_as!(User, "SELECT * FROM users WHERE email = $1", email) - .fetch_optional(&self.pool) - .await - .map_err(|e| CoreError::Database(e.to_string())) + sqlx::query_as!( + User, + r#" + SELECT + id, username, email, hashed_password, created_at, updated_at, + role as "role: Role", + storage_quota, storage_used + FROM users + WHERE email = $1 + "#, + email + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string())) } async fn find_by_username(&self, username: &str) -> CoreResult> { - sqlx::query_as!(User, "SELECT * FROM users WHERE username = $1", username) - .fetch_optional(&self.pool) - .await - .map_err(|e| CoreError::Database(e.to_string())) + sqlx::query_as!( + User, + r#" + SELECT + id, username, email, hashed_password, created_at, updated_at, + role as "role: Role", + storage_quota, storage_used + FROM users + WHERE username = $1 + "#, + username + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string())) } async fn find_by_id(&self, id: Uuid) -> CoreResult> { - sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id) - .fetch_optional(&self.pool) - .await - .map_err(|e| CoreError::Database(e.to_string())) + sqlx::query_as!( + User, + r#" + SELECT + id, username, email, hashed_password, created_at, updated_at, + role as "role: Role", + storage_quota, storage_used + FROM users + WHERE id = $1 + "#, + id + ) + .fetch_optional(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string())) + } + + async fn update_storage_used(&self, user_id: Uuid, bytes: i64) -> CoreResult<()> { + sqlx::query!( + r#" + UPDATE users + SET storage_used = storage_used + $1, updated_at = NOW() + WHERE id = $2 + "#, + bytes, + user_id + ) + .execute(&self.pool) + .await + .map_err(|e| CoreError::Database(e.to_string()))?; + + Ok(()) } } @@ -93,4 +146,9 @@ impl UserRepository for SqliteUserRepository { println!("SQLITE REPO: Finding user by id"); Ok(None) } + + async fn update_storage_used(&self, _user_id: Uuid, _bytes: i64) -> CoreResult<()> { + println!("SQLITE REPO: Updating user storage used"); + Ok(()) + } }