+
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)