diff --git a/backend/migrations/20260628231705_user_permissions.down.sql b/backend/migrations/20260628231705_user_permissions.down.sql new file mode 100644 index 0000000..718e509 --- /dev/null +++ b/backend/migrations/20260628231705_user_permissions.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +ALTER TABLE users DROP COLUMN permissions; diff --git a/backend/migrations/20260628231705_user_permissions.up.sql b/backend/migrations/20260628231705_user_permissions.up.sql new file mode 100644 index 0000000..fdf4fd8 --- /dev/null +++ b/backend/migrations/20260628231705_user_permissions.up.sql @@ -0,0 +1,2 @@ +-- Add up migration script here +ALTER TABLE users ADD COLUMN permissions BIGINT NOT NULL DEFAULT 0; diff --git a/backend/src/models/batch.rs b/backend/src/models/batch.rs deleted file mode 100644 index e69de29..0000000 diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index ba6684b..9472182 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,8 +1,11 @@ use anyhow::Result; -use sqlx::{Acquire, Executor, Postgres}; +use sqlx::{Executor, Postgres}; +use std::fmt::Display; +use tracing::Instrument; use uuid::Uuid; -pub mod batch; +use crate::models::user::Permissions; + pub mod customer; pub mod flavor; pub mod ingredient; @@ -11,34 +14,105 @@ pub mod supplier; pub mod user; pub trait Update { - fn apply(self, other: &mut R); - fn has_changes(&self) -> bool; + fn apply(self, other: &mut R) -> bool; +} + +trait ApplyOption { + fn apply_to(self, target: &mut T) -> bool; +} + +impl ApplyOption for Option { + fn apply_to(self, target: &mut T) -> bool { + if let Some(value) = self { + *target = value; + true + } else { + false + } + } +} + +pub trait CrudBackend: Sized { + type Create; + type Update: Update; + type Identifier: Display = Uuid; + const TABLE_NAME: &'static str; + const PERM_READ: Permissions; + const PERM_CREATE: Permissions; + const PERM_UPDATE: Permissions; + const PERM_DELETE: Permissions; + + fn id(&self) -> Self::Identifier; + + async fn create_inner<'a>( + conn: impl Executor<'a, Database = Postgres>, + create: Self::Create, + ) -> Result; + + async fn read_inner<'a>( + conn: impl Executor<'a, Database = Postgres>, + id: Self::Identifier, + ) -> Result; + + async fn update_inner<'a>( + &mut self, + conn: impl Executor<'a, Database = Postgres>, + ) -> Result<()>; + + async fn delete_inner<'a>(&self, conn: impl Executor<'a, Database = Postgres>) -> Result<()>; +} + +pub trait Crud: CrudBackend { + async fn create<'a>( + conn: impl Executor<'a, Database = Postgres>, + create: Self::Create, + ) -> Result; + + async fn read<'a>( + conn: impl Executor<'a, Database = Postgres>, + id: Self::Identifier, + ) -> Result; + + async fn update<'a>( + &mut self, + conn: impl Executor<'a, Database = Postgres>, + updates: Self::Update, + ) -> Result<()>; + + async fn delete<'a>(&self, conn: impl Executor<'a, Database = Postgres>) -> Result<()>; } -pub trait Crud -where - Self: Sized, - U: Update, -{ - type Identifier = Uuid; - - // #[tracing::instrument(skip(pool))] - async fn create<'a, A>(conn: A, create: C) -> Result - where - A: Acquire<'a, Database = Postgres> + Executor<'a, Database = Postgres>; - - // #[tracing::instrument(skip(pool))] - async fn read<'a, A>(conn: A, id: Self::Identifier) -> Result - where - A: Acquire<'a, Database = Postgres> + Executor<'a, Database = Postgres>; - - // #[tracing::instrument(skip(pool, self), fields(id = %self.id))] - async fn update<'a, A>(&mut self, conn: A, updates: U) -> Result - where - A: Acquire<'a, Database = Postgres> + Executor<'a, Database = Postgres>; - - // #[tracing::instrument(skip(pool, self), fields(id = %self.id))] - async fn delete<'a, A>(&self, conn: A) -> Result - where - A: Acquire<'a, Database = Postgres> + Executor<'a, Database = Postgres>; +impl Crud for T { + async fn create<'a>( + conn: impl Executor<'a, Database = Postgres>, + create: Self::Create, + ) -> Result { + let span = tracing::info_span!("create", model = std::any::type_name::()); + T::create_inner(conn, create).instrument(span).await + } + + async fn read<'a>( + conn: impl Executor<'a, Database = Postgres>, + id: T::Identifier, + ) -> Result { + let span = tracing::info_span!("read", id = %id, model = std::any::type_name::()); + T::read_inner(conn, id).instrument(span).await + } + + async fn update<'a>( + &mut self, + conn: impl Executor<'a, Database = Postgres>, + updates: Self::Update, + ) -> Result<()> { + let id = self.id(); + let span = tracing::info_span!("update", id = %id, model = std::any::type_name::()); + updates.apply(self); + self.update_inner(conn).instrument(span).await + } + + async fn delete<'a>(&self, conn: impl Executor<'a, Database = Postgres>) -> Result<()> { + let id = self.id(); + let span = tracing::info_span!("update", id = %id, model = std::any::type_name::()); + self.delete_inner(conn).instrument(span).await + } } diff --git a/backend/src/models/user.rs b/backend/src/models/user.rs index 703b235..e682702 100644 --- a/backend/src/models/user.rs +++ b/backend/src/models/user.rs @@ -1,4 +1,4 @@ -use super::{Crud, Update}; +use super::{ApplyOption, CrudBackend, Update}; use anyhow::Result; use argon2::{ Argon2, PasswordHash, PasswordHasher as _, PasswordVerifier as _, @@ -12,7 +12,8 @@ pub struct User { pub id: Uuid, pub full_name: String, pub username: String, - pub password: Option, + pub permissions: Permissions, + pub password: String, } #[derive(Debug, Deserialize, Serialize)] @@ -20,6 +21,7 @@ pub struct UserNoPassword { pub id: Uuid, pub full_name: String, pub username: String, + pub permissions: Permissions, } #[derive(Debug, Deserialize, Serialize)] @@ -29,40 +31,23 @@ pub struct UserCreate { pub password: String, } -impl TryFrom for User { - type Error = anyhow::Error; - fn try_from(value: UserCreate) -> std::prelude::v1::Result { - let password = User::hash_password(&value.password)?; - Ok(User { - id: uuid::Uuid::new_v4(), - full_name: value.full_name, - username: value.username, - password: Some(password), - }) - } -} - #[derive(Debug, Deserialize, Serialize)] pub struct UserUpdate { pub full_name: Option, pub username: Option, + pub permissions: Option, } impl Update for UserUpdate { - fn apply(self, other: &mut User) { - if !self.has_changes() { - return; - }; - if let Some(full_name) = self.full_name { - other.full_name = full_name; - } - if let Some(username) = self.username { - other.username = username; - } - } - - fn has_changes(&self) -> bool { - self.full_name.is_some() || self.username.is_some() + fn apply(self, other: &mut User) -> bool { + let Self { + full_name, + username, + permissions, + } = self; + full_name.apply_to(&mut other.full_name) + | username.apply_to(&mut other.username) + | permissions.apply_to(&mut other.permissions) } } @@ -72,23 +57,39 @@ pub enum UserIdentifier { UserId(Uuid), } -impl Crud for User { +impl std::fmt::Display for UserIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UserId(uuid) => f.write_str(&uuid.to_string()), + Self::Username(uname) => f.write_str(uname), + } + } +} + +impl CrudBackend for User { type Identifier = UserIdentifier; - #[tracing::instrument( - skip(pool, create), fields( - full_name = %create.full_name, - username = %create.username, - ) - )] - async fn create<'a, A>(pool: A, create: UserCreate) -> Result - where - A: sqlx::Acquire<'a, Database = sqlx::Postgres> - + sqlx::Executor<'a, Database = sqlx::Postgres>, - { + + type Create = UserCreate; + type Update = UserUpdate; + + const TABLE_NAME: &'static str = "users"; + const PERM_READ: Permissions = Permissions::UserRead; + const PERM_CREATE: Permissions = Permissions::UserCreate; + const PERM_UPDATE: Permissions = Permissions::UserUpdate; + const PERM_DELETE: Permissions = Permissions::UserDelete; + + fn id(&self) -> Self::Identifier { + UserIdentifier::UserId(self.id) + } + + async fn create_inner<'a>( + pool: impl sqlx::Executor<'a, Database = sqlx::Postgres>, + create: UserCreate, + ) -> Result { let hashed_password = Self::hash_password(&create.password)?; let user = sqlx::query_as!( User, - r#"INSERT INTO USERS (full_name, username, password) VALUES ($1, $2, $3) RETURNING id, full_name, username, password"#, + r#"INSERT INTO USERS (full_name, username, password) VALUES ($1, $2, $3) RETURNING id, full_name, username, password, permissions"#, create.full_name, create.username, hashed_password @@ -96,12 +97,10 @@ impl Crud for User { Ok(user) } - #[tracing::instrument(skip(pool))] - async fn read<'a, A>(pool: A, id: Self::Identifier) -> Result - where - A: sqlx::Acquire<'a, Database = sqlx::Postgres> - + sqlx::Executor<'a, Database = sqlx::Postgres>, - { + async fn read_inner<'a>( + pool: impl sqlx::Executor<'a, Database = sqlx::Postgres>, + id: Self::Identifier, + ) -> Result { match id { UserIdentifier::Username(username) => { sqlx::query_as!( @@ -125,46 +124,44 @@ impl Crud for User { .map_err(Into::into) } - #[tracing::instrument(skip(pool, self), fields(id = %self.id))] - async fn update<'a, A>(&mut self, pool: A, updates: UserUpdate) -> Result - where - A: sqlx::Acquire<'a, Database = sqlx::Postgres> - + sqlx::Executor<'a, Database = sqlx::Postgres>, - { - updates.apply(self); + async fn update_inner<'a>( + &mut self, + pool: impl sqlx::Executor<'a, Database = sqlx::Postgres>, + ) -> Result<()> { sqlx::query_as!( User, - r#"UPDATE users SET full_name = $1, username = $2, password = $3 WHERE id = $4 RETURNING *"#, + r#"UPDATE users SET full_name = $1, username = $2, password = $3, permissions = $4 WHERE id = $5 RETURNING *"#, self.full_name, self.username, self.password, + i64::from(self.permissions), self.id ) .fetch_one(pool) .await .map_err(Into::into) + .map(|_| ()) } - #[tracing::instrument(skip(self, pool), fields(id = %self.id))] - async fn delete<'a, A>(&self, pool: A) -> Result - where - A: sqlx::Acquire<'a, Database = sqlx::Postgres> - + sqlx::Executor<'a, Database = sqlx::Postgres>, - { + async fn delete_inner<'a>( + &self, + pool: impl sqlx::Executor<'a, Database = sqlx::Postgres>, + ) -> Result<()> { sqlx::query!(r#"DELETE FROM users WHERE id = $1"#, self.id) .execute(pool) .await .and_then(|r| Ok(r.rows_affected() >= 1)) .map_err(Into::into) + .map(|_| ()) } } impl User { - pub async fn reset_password<'a, A>(&mut self, pool: A, new_password: String) -> Result - where - A: sqlx::Acquire<'a, Database = sqlx::Postgres> - + sqlx::Executor<'a, Database = sqlx::Postgres>, - { + pub async fn reset_password<'a>( + &mut self, + pool: impl sqlx::Executor<'a, Database = sqlx::Postgres>, + new_password: String, + ) -> Result { let hashed_password = Self::hash_password(&new_password)?; sqlx::query_as!( User, @@ -183,6 +180,7 @@ impl User { id: self.id, full_name: self.full_name.clone(), username: self.username.clone(), + permissions: self.permissions, } } @@ -197,10 +195,103 @@ impl User { &self, password: &str, ) -> Result { - let parsed_hash = PasswordHash::new(&self.password.as_ref().unwrap())?; + let parsed_hash = PasswordHash::new(&self.password)?; let argon2 = Argon2::default(); Ok(argon2 .verify_password(password.as_bytes(), &parsed_hash) .is_ok()) } } + +bitflags::bitflags! { + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub struct Permissions: u64 { + const BatchRead = 1 << 0; + const BatchCreate = 1 << 1; + const BatchUpdate = 1 << 2; + const BatchDelete = 1 << 3; + + const SupplierRead = 1 << 4; + const SupplierCreate = 1 << 5; + const SupplierUpdate = 1 << 6; + const SupplierDelete = 1 << 7; + + const IngredientRead = 1 << 8; + const IngredientCreate = 1 << 9; + const IngredientUpdate = 1 << 10; + const IngredientDelete = 1 << 11; + + const FlavorRead = 1 << 12; + const FlavorCreate = 1 << 13; + const FlavorUpdate = 1 << 14; + const FlavorDelete = 1 << 15; + + const UserRead = 1 << 16; + const UserCreate = 1 << 17; + const UserUpdate = 1 << 18; + const UserDelete = 1 << 19; + + const CustomerRead = 1 << 20; + const CustomerCreate = 1 << 21; + const CustomerUpdate = 1 << 22; + const CustomerDelete = 1 << 23; + + const OrderRead = 1 << 24; + const OrderCreate = 1 << 25; + const OrderUpdate = 1 << 26; + const OrderDelete = 1 << 27; + + const ManageUsers = 1 << 28; + const RoleOwner = 1 << 29; + const RoleAdmin = 1 << 30; + const RoleManager = 1 << 31; + const RoleUser = 1 << 32; + + const Batch = Self::BatchRead.bits() | Self::BatchCreate.bits() | Self::BatchUpdate.bits() | Self::BatchDelete.bits(); + const Supplier = Self::SupplierRead.bits() | Self::SupplierCreate.bits() | Self::SupplierUpdate.bits() | Self::SupplierDelete.bits(); + const Ingredient = Self::IngredientRead.bits() | Self::IngredientCreate.bits() | Self::IngredientUpdate.bits() | Self::IngredientDelete.bits(); + const Flavor = Self::FlavorRead.bits() | Self::FlavorCreate.bits() | Self::FlavorUpdate.bits() | Self::FlavorDelete.bits(); + const User = Self::UserRead.bits() | Self::UserCreate.bits() | Self::UserUpdate.bits() | Self::UserDelete.bits(); + const Customer = Self::CustomerRead.bits() | Self::CustomerCreate.bits() | Self::CustomerUpdate.bits() | Self::CustomerDelete.bits(); + const Order = Self::OrderRead.bits() | Self::OrderCreate.bits() | Self::OrderUpdate.bits() | Self::OrderDelete.bits(); + + } +} + +impl std::fmt::Display for Permissions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.is_empty() { + return f.write_str("(none)"); + } + bitflags::parser::to_writer(self, f) + } +} + +impl Serialize for Permissions { + fn serialize(&self, serializer: S) -> Result { + if serializer.is_human_readable() { + serializer.serialize_str(&self.to_string()) + } else { + serializer.serialize_u64(self.bits()) + } + } +} + +impl<'de> Deserialize<'de> for Permissions { + fn deserialize>(deserializer: D) -> Result { + let bits = u64::deserialize(deserializer)?; + Ok(Permissions::from_bits_truncate(bits)) + } +} + +impl From for Permissions { + fn from(bits: i64) -> Self { + Permissions::from_bits_truncate(bits as u64) + } +} + +impl From for i64 { + fn from(perms: Permissions) -> Self { + perms.bits() as i64 + } +} diff --git a/backend/src/routes/template.rs b/backend/src/routes/template.rs index feb5c68..6d44b03 100644 --- a/backend/src/routes/template.rs +++ b/backend/src/routes/template.rs @@ -1,40 +1,182 @@ -use actix_web::{HttpResponse, Responder, Scope}; -const ROUTE: &'static str = todo!(); +use crate::models::{Crud, CrudBackend, user::User}; +use actix_web::{ + HttpResponse, Responder, Scope, + web::{Data, Json, Path, Query}, +}; +use sqlx::PgPool; +use uuid::Uuid; + +type Main = todo!(); type Create = todo!(); type Read = todo!(); type Update = todo!(); +#[derive(Debug, Clone, Deserialize)] +struct QueryParams { + pub limit: Option, + pub offset: Option, + pub sort_order: Option, +} + #[actix_web::post("")] -async fn post() -> impl Responder { - HttpResponse::NotImplemented().finish() +async fn post(pool: Data, body: Json, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_CREATE) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to create {}", + Main::TABLE_NAME + )); + } + match Main::create(pool.get_ref(), body.into_inner()).await { + Ok(batch) => HttpResponse::Created().json(batch), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } +} + +#[actix_web::get("")] +async fn get_all(pool: Data, options: Query, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_READ) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to get {}", + Main::TABLE_NAME + )); + } + + let limit = options.limit.unwrap_or(100) as i64; + let offset = options.offset.unwrap_or(0) as i64; + let sort_asc = options.sort_order.unwrap_or(SortOrder::Desc) == SortOrder::Asc; + + let mut query = sqlx::QueryBuilder::new(format!("SELECT * FROM {}", Main::TABLE_NAME)); + query.push(" WHERE 1=1"); + + // TODO: ADD more filters + + query.push(" ORDER BY created_at "); + if sort_asc { + query.push("ASC"); + } else { + query.push("DESC"); + } + + query.push(" LIMIT "); + query.push_bind(limit); + query.push(" OFFSET "); + query.push_bind(offset); + + match query + .build_query_as::
() + .fetch_all(pool.get_ref()) + .await + { + Ok(batches) => HttpResponse::Ok().json(batches), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } } #[actix_web::get("/{id}")] -async fn get() -> impl Responder { - HttpResponse::NotImplemented().finish() +async fn get(pool: Data, id: Path, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_READ) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to get {}", + Main::TABLE_NAME + )); + } + + match Main::read(pool.get_ref(), id.into_inner()).await { + Ok(resource) => HttpResponse::Ok().json(resource), + Err(err) => { + if let Some(sqlx_err) = err.downcast_ref::() { + match sqlx_err { + sqlx::Error::RowNotFound => return HttpResponse::NotFound().finish(), + _ => (), + } + } + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } } #[actix_web::patch("/{id}")] -async fn patch() -> impl Responder { - HttpResponse::NotImplemented().finish() -} +async fn patch( + pool: Data, + id: Path, + body: Json, + user: User, +) -> impl Responder { + if !user.permissions.contains(Main::PERM_UPDATE) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to update {}", + Main::TABLE_NAME + )); + } + + let mut resource = match Main::read(pool.get_ref(), id.into_inner()).await { + Ok(resource) => resource, + Err(err) => { + if let Some(sqlx_err) = err.downcast_ref::() { + match sqlx_err { + sqlx::Error::RowNotFound => return HttpResponse::NotFound().finish(), + _ => (), + } + } + tracing::error!("{err:?}"); + return HttpResponse::InternalServerError().finish(); + } + }; -#[actix_web::put("/{id}")] -async fn put() -> impl Responder { - HttpResponse::NotImplemented().finish() + match resource.update(pool.get_ref(), body.into_inner()).await { + Ok(()) => HttpResponse::Ok().json(resource), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } } #[actix_web::delete("/{id}")] -async fn delete() -> impl Responder { - HttpResponse::NotImplemented().finish() +async fn delete(pool: Data, id: Path, user: User) -> impl Responder { + if !user.permissions.contains(Main::PERM_DELETE) { + return HttpResponse::Forbidden().body(format!( + "You are not authorized to delete {}", + Main::TABLE_NAME + )); + } + + let resource = match Main::read(pool.get_ref(), id.into_inner()).await { + Ok(resource) => resource, + Err(err) => { + if let Some(sqlx_err) = err.downcast_ref::() { + match sqlx_err { + sqlx::Error::RowNotFound => return HttpResponse::NotFound().finish(), + _ => (), + } + } + tracing::error!("{err:?}"); + return HttpResponse::InternalServerError().finish(); + } + }; + + match resource.delete(pool.get_ref()).await { + Ok(()) => HttpResponse::NoContent().finish(), + Err(err) => { + tracing::error!("{err:?}"); + HttpResponse::InternalServerError().finish() + } + } } pub fn service() -> Scope { - actix_web::web::scope(ROUTE) + actix_web::web::scope(&format!("/{}", Main::TABLE_NAME)) .service(post) .service(get) + .service(get_all) .service(patch) - .service(put) .service(delete) } diff --git a/backend/src/routes/user.rs b/backend/src/routes/user.rs index 3f2abae..23f0d1e 100644 --- a/backend/src/routes/user.rs +++ b/backend/src/routes/user.rs @@ -59,7 +59,7 @@ async fn patch( } }; match user.update(pool.get_ref(), body.into_inner()).await { - Ok(user) => HttpResponse::Ok().json(user.without_password()), + Ok(_) => HttpResponse::Ok().json(user.without_password()), Err(_) => HttpResponse::InternalServerError() .json(serde_json::json!({"error": "Internal Server Error"})), } @@ -83,9 +83,7 @@ async fn delete(pool: actix_web::web::Data, identity: Option) }; match User::read(pool.get_ref(), UserIdentifier::UserId(user_id)).await { Ok(user) => match user.delete(pool.get_ref()).await { - Ok(true) => HttpResponse::Ok().json(serde_json::json!({"message": "Account deleted"})), - Ok(false) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Could not delete user"})), + Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Account deleted"})), Err(_) => HttpResponse::InternalServerError() .json(serde_json::json!({"error": "Internal Server Error"})), },