diff --git a/.gitignore b/.gitignore index 3c37637..76c8f59 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ config.yaml /LEGACY summary.ai .env +.env.example \ No newline at end of file diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index 000487b..492b098 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -1,7 +1,7 @@ + + diff --git a/src/api/mod.rs b/src/api/mod.rs index de9dec6..04a5a05 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -36,6 +36,7 @@ mod bikes; mod docs; mod helpers; mod reservations; +mod users; pub fn get_router(aac: AnonAppController) -> Router { aide::generate::on_error(|err| error!("aide generated error: {err}")); @@ -63,6 +64,7 @@ pub fn get_router(aac: AnonAppController) -> Router { .merge(auth::routes()) .nest_api_service("/api/bikes", bikes::routes()) .nest_api_service("/api/reservations", reservations::routes()) + .nest_api_service("/api/users", users::routes()) .nest_api_service("/api/docs", docs::routes()) .finish_api_with(&mut api, docs::api_docs_metadata) .layer(Extension(aac)) diff --git a/src/api/users.rs b/src/api/users.rs new file mode 100644 index 0000000..ba2b6df --- /dev/null +++ b/src/api/users.rs @@ -0,0 +1,101 @@ +//! Who administers the app. +//! +//! Users themselves are not managed here: they come from the authentication +//! provider. The one decision that belongs to this app is `admin`, and this is +//! where it is taken. + +use aide::{ + axum::{ + ApiRouter, + routing::{delete_with, get_with, post_with}, + }, + transform::TransformOperation, +}; +use axum::{Json, extract::Path, http::StatusCode}; +use schemars::JsonSchema; +use serde::Deserialize; + +use crate::{ + api::helpers::{IdPath, admin, admin_desc, desc, unexpected_error}, + core::{ + controller::{AppController, ControllerError, users::UsersControllerError}, + models::user::Administrator, + }, +}; + +pub fn routes() -> ApiRouter { + ApiRouter::new() + .api_route("/admins", get_with(get_admins, get_admins_docs)) + .api_route("/admins", post_with(grant_admin, grant_admin_docs)) + .api_route("/admins/{id}", delete_with(revoke_admin, revoke_admin_docs)) +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct GrantAdminForm { + /// The address of the person to promote, whether or not they have ever + /// logged in + email: String, +} + +#[axum::debug_handler] +async fn get_admins(ac: AppController) -> Result>, (StatusCode, String)> { + match admin(ac)?.get_admins().await { + Ok(admins) => Ok(Json(admins)), + Err(err) => unexpected_error("get_admins", err), + } +} + +fn get_admins_docs(op: TransformOperation) -> TransformOperation { + op.tag("Users") + .summary("List the administrators") + .response_with::<403, (), _>(admin_desc) +} + +#[axum::debug_handler] +async fn grant_admin( + ac: AppController, + Json(GrantAdminForm { email }): Json, +) -> Result, (StatusCode, String)> { + match admin(ac)?.grant_admin(&email).await { + Ok(administrator) => Ok(Json(administrator)), + Err(ControllerError::User(err @ UsersControllerError::EmailInvalid)) => { + Err((StatusCode::BAD_REQUEST, err.to_string())) + } + Err(err) => unexpected_error("grant_admin", err), + } +} + +fn grant_admin_docs(op: TransformOperation) -> TransformOperation { + op.tag("Users") + .summary("Make somebody an administrator, by email") + .description( + "The person need not have logged in yet: the row created is adopted \ + at their first login. Granting to somebody who already is one \ + changes nothing.", + ) + .response_with::<403, (), _>(admin_desc) + .response_with::<400, (), _>(desc("Not an email address")) +} + +#[axum::debug_handler] +async fn revoke_admin( + ac: AppController, + Path(IdPath { id }): Path, +) -> Result<(), (StatusCode, String)> { + match admin(ac)?.revoke_admin(id).await { + Ok(()) => Ok(()), + Err(ControllerError::User(err @ UsersControllerError::CannotDemoteSelf)) => { + Err((StatusCode::CONFLICT, err.to_string())) + } + Err(err) if err.is_not_found() => Err((StatusCode::NOT_FOUND, "No such user".to_owned())), + Err(err) => unexpected_error("revoke_admin", err), + } +} + +fn revoke_admin_docs(op: TransformOperation) -> TransformOperation { + op.tag("Users") + .summary("Take the administrator rights away") + .response_with::<403, (), _>(admin_desc) + .response_with::<409, (), _>(desc("An administrator cannot demote themselves")) + .response::<404, ()>() +} diff --git a/src/core/controller/mod.rs b/src/core/controller/mod.rs index ff725ff..825d1b7 100644 --- a/src/core/controller/mod.rs +++ b/src/core/controller/mod.rs @@ -23,7 +23,7 @@ use thiserror::Error; use crate::core::{ controller::{ authn::AuthnControllerError, bikes::BikesControllerError, - reservations::ReservationsControllerError, + reservations::ReservationsControllerError, users::UsersControllerError, }, models::{unit::UnitId, user::User}, repositories::{DatabaseRepository, RepositoryError}, @@ -153,6 +153,8 @@ pub enum ControllerError { Bike(#[from] BikesControllerError), #[error("Reservation specific error: {0}")] Reservation(#[from] ReservationsControllerError), + #[error("User specific error: {0}")] + User(#[from] UsersControllerError), } impl ControllerError { diff --git a/src/core/controller/reservations.rs b/src/core/controller/reservations.rs index 8e87a63..188d2b7 100644 --- a/src/core/controller/reservations.rs +++ b/src/core/controller/reservations.rs @@ -6,7 +6,7 @@ use crate::{ core::{ controller::{ AdminAppController, AnonAppController, AppController, ControllerError, - ManagerAppController, + ManagerAppController, linka::Sweep, }, models::{ bike::{BikeId, BikeStatus}, @@ -98,7 +98,7 @@ impl AnonAppController { self.announce_approval(&updated).await; // A booking that starts within the half hour is one somebody may be // standing next to: the access list is settled now, not at the next tick - self.sync_access_list().await; + self.sync_access_list(Sweep::Diff).await; Ok(updated) } @@ -506,7 +506,7 @@ impl AppController { let after = self.db.get_reservation(current.id).await?; self.announce_edit(¤t, &after).await; // An address added to a live booking can unlock a bike straight away - self.sync_access_list().await; + self.sync_access_list(Sweep::Diff).await; Ok(()) } @@ -633,7 +633,7 @@ impl ManagerAppController { _ => {} } // Approving lets its riders in; anything else may take them back out - self.sync_access_list().await; + self.sync_access_list(Sweep::Diff).await; Ok(()) } diff --git a/src/core/controller/users.rs b/src/core/controller/users.rs index 28b0300..0a7c7f6 100644 --- a/src/core/controller/users.rs +++ b/src/core/controller/users.rs @@ -1,9 +1,11 @@ //! Users are not created by the app: they are mirrored from the authentication //! provider on login. The only decision that belongs to us is `admin`. +use thiserror::Error; + use crate::core::{ controller::{AdminAppController, AnonAppController, ControllerError}, - models::user::{User, UserId}, + models::user::{Administrator, User, UserId, is_valid_email}, }; impl AnonAppController { @@ -27,4 +29,57 @@ impl AdminAppController { pub async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), ControllerError> { self.db.set_user_admin(id, admin).await.map_err(Into::into) } + + pub async fn get_admins(&self) -> Result, ControllerError> { + self.db.get_admins().await.map_err(Into::into) + } + + /// Makes whoever holds `email` an administrator. + /// + /// The address is the identity, so nobody has to have logged in first: a + /// placeholder row is created and adopted the day that person does, which + /// is the same rule as filing a reservation for somebody. + /// + /// Granting to somebody who already is one changes nothing and is not an + /// error: the page asks for a state, not for a transition. + pub async fn grant_admin(&self, email: &str) -> Result { + let email = email.trim(); + if !is_valid_email(email) { + return Err(UsersControllerError::EmailInvalid.into()); + } + // Nothing is known of a person named by their address alone; the local + // part is a stand-in until their first login brings the real names + let placeholder = email.split('@').next().unwrap_or(email); + let user = self.db.get_or_create_user(email, placeholder, "").await?; + if !user.admin { + self.db.set_user_admin(user.id, true).await?; + } + Ok(Administrator { + id: user.id, + firstname: user.firstname, + name: user.name, + email: user.email, + pending: user.oidc_sub.starts_with("pending:"), + }) + } + + /// Takes the rights away from `id`. + /// + /// Never from oneself: an admin who demotes themselves cannot undo it, and + /// the last one doing so would leave the app with nobody able to grant them + /// back. + pub async fn revoke_admin(&self, id: UserId) -> Result<(), ControllerError> { + if self.user().id == id { + return Err(UsersControllerError::CannotDemoteSelf.into()); + } + self.db.set_user_admin(id, false).await.map_err(Into::into) + } +} + +#[derive(Error, Debug)] +pub enum UsersControllerError { + #[error("That is not an email address")] + EmailInvalid, + #[error("An administrator cannot take their own rights away")] + CannotDemoteSelf, } diff --git a/src/core/models/user.rs b/src/core/models/user.rs index 339caaf..e1b7fa5 100644 --- a/src/core/models/user.rs +++ b/src/core/models/user.rs @@ -40,17 +40,40 @@ pub struct Person { impl Person { pub fn is_valid(&self) -> bool { - let email = self.email.trim(); - // Same shape the form checks: something, an @, something with a dot - email.len() >= 3 - && email.split('@').count() == 2 - && !email.starts_with('@') - && !email.ends_with('@') + is_valid_email(&self.email) && !self.firstname.trim().is_empty() && !self.name.trim().is_empty() } } +/// The shape an address must have to be worth storing. +/// +/// Deliberately loose — something, an `@`, something — because the only real +/// check is that mail reaches it, and refusing an unusual but valid address +/// would be worse than accepting a wrong one. +pub fn is_valid_email(email: &str) -> bool { + let email = email.trim(); + email.len() >= 3 + && email.split('@').count() == 2 + && !email.starts_with('@') + && !email.ends_with('@') + && !email.contains(char::is_whitespace) +} + +/// One administrator, as the page that manages them lists them. +/// +/// `pending` is somebody named by their address who has never logged in: the +/// row is a placeholder waiting to be adopted at their first login, and the +/// names on it are not to be trusted. +#[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] +pub struct Administrator { + pub id: UserId, + pub firstname: String, + pub name: String, + pub email: String, + pub pending: bool, +} + /// A user as they appear inside another object (a reservation, ...): enough to /// show who they are, without dragging their units along. #[derive(Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq, Eq)] @@ -60,3 +83,20 @@ pub struct UserSummary { pub name: String, pub email: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_address_needs_a_local_part_an_at_and_a_domain() { + assert!(is_valid_email("antoine.pelletier@epfl.ch")); + assert!(is_valid_email(" spaced@epfl.ch ")); + assert!(!is_valid_email("@epfl.ch")); + assert!(!is_valid_email("nobody@")); + assert!(!is_valid_email("no-at-sign")); + assert!(!is_valid_email("two@at@signs")); + assert!(!is_valid_email("a space@epfl.ch")); + assert!(!is_valid_email("")); + } +} diff --git a/src/core/repositories/users_repository.rs b/src/core/repositories/users_repository.rs index b10481f..f054d88 100644 --- a/src/core/repositories/users_repository.rs +++ b/src/core/repositories/users_repository.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use crate::core::{ models::{ unit::UnitId, - user::{NewUser, User, UserId}, + user::{Administrator, NewUser, User, UserId}, }, repositories::RepositoryError, }; @@ -41,5 +41,8 @@ pub trait UsersRepository { /// Replaces the whole set of units the user belongs to async fn set_user_units(&self, id: UserId, units: Vec) -> Result<(), RepositoryError>; + /// Everybody who can administer the app, by name + async fn get_admins(&self) -> Result, RepositoryError>; + async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError>; } diff --git a/src/main.rs b/src/main.rs index 24c6324..5cfbeb4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,10 @@ use tower_http::services::{ServeDir, ServeFile}; use tracing::info; use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; -use crate::{core::controller::AnonAppController, services::database::SqlxDatabase}; +use crate::{ + core::controller::{AnonAppController, linka::Sweep}, + services::database::SqlxDatabase, +}; mod api; mod core; @@ -83,11 +86,23 @@ fn spawn_status_ticker(controller: AnonAppController) { /// decision taken in the meantime does not wait for the tick, since approving /// reconciles straight away. fn spawn_linka_ticker(controller: AnonAppController) { + /// The platform cannot be read back, so the access list is asserted in full + /// every so often — and always on the first pass. That is what repairs a + /// list changed on the platform itself, or while this app was down. + const FULL_SWEEP_EVERY: u32 = 30; + tokio::spawn(async move { let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); + let mut passes: u32 = 0; loop { tick.tick().await; - controller.sync_linka().await; + let sweep = if passes.is_multiple_of(FULL_SWEEP_EVERY) { + Sweep::Full + } else { + Sweep::Diff + }; + controller.sync_linka(sweep).await; + passes = passes.wrapping_add(1); } }); } diff --git a/src/services/database/users.rs b/src/services/database/users.rs index e3b3771..c6c205d 100644 --- a/src/services/database/users.rs +++ b/src/services/database/users.rs @@ -5,7 +5,7 @@ use crate::{ core::{ models::{ unit::{Unit, UnitId}, - user::{NewUser, User, UserId}, + user::{Administrator, NewUser, User, UserId}, }, repositories::{RepositoryError, users_repository::UsersRepository}, }, @@ -215,6 +215,22 @@ impl UsersRepository for SqlxDatabase { Ok(()) } + async fn get_admins(&self) -> Result, RepositoryError> { + // The placeholder subject is what marks somebody named by an admin who + // has never logged in — see `get_or_create_user` + let admins = query_as!( + Administrator, + r#"SELECT id, firstname, "name", email, + oidc_sub LIKE 'pending:%' AS "pending!" + FROM users + WHERE admin = true + ORDER BY lower(email)"# + ) + .fetch_all(&self.pool) + .await?; + Ok(admins) + } + async fn set_user_admin(&self, id: UserId, admin: bool) -> Result<(), RepositoryError> { let result = query!(r#"UPDATE users SET admin = $2 WHERE id = $1"#, id, admin) .execute(&self.pool)