From a931251019dcb8990c423c5713ae04d8c7bff90d Mon Sep 17 00:00:00 2001 From: Marek Fajkus Date: Tue, 5 Sep 2023 19:37:46 +0200 Subject: [PATCH] Orca: applications listing apis (#91) --- .github/workflows/acceptance-test.yaml | 2 +- .../V9__tighten_registration_requests.sql | 5 ++ orca/Cargo.lock | 1 + orca/Cargo.toml | 4 +- orca/README.md | 22 ++++++ orca/default.nix | 2 +- orca/src/api/applications/mod.rs | 71 ++++++++++++++++--- orca/src/api/applications/query.rs | 23 ++++++ orca/src/api/mod.rs | 8 ++- orca/src/api/stats/query.rs | 2 +- orca/src/config/templates.rs | 1 - orca/src/data/mod.rs | 10 +++ orca/src/server/keycloak.rs | 27 +++++-- 13 files changed, 158 insertions(+), 20 deletions(-) create mode 100644 gray-whale/migrations/V9__tighten_registration_requests.sql create mode 100644 orca/src/api/applications/query.rs diff --git a/.github/workflows/acceptance-test.yaml b/.github/workflows/acceptance-test.yaml index 59ba24b..bdb812c 100644 --- a/.github/workflows/acceptance-test.yaml +++ b/.github/workflows/acceptance-test.yaml @@ -52,7 +52,7 @@ jobs: nix build cp Rocket.example.toml Rocket.toml ./result/bin/orca & - sleep 500 + sleep 5 env: ROCKET_SMTP_HOST: "localhost" ROCKET_SMTP_USER: "" diff --git a/gray-whale/migrations/V9__tighten_registration_requests.sql b/gray-whale/migrations/V9__tighten_registration_requests.sql new file mode 100644 index 0000000..ef840d3 --- /dev/null +++ b/gray-whale/migrations/V9__tighten_registration_requests.sql @@ -0,0 +1,5 @@ +ALTER TABLE registration_requests +ALTER COLUMN registration_local SET NOT NULL; + +-- Fix typo +ALTER VIEW registration_requests_procession RENAME TO registration_requests_processing; diff --git a/orca/Cargo.lock b/orca/Cargo.lock index 0e0e56f..11e1590 100644 --- a/orca/Cargo.lock +++ b/orca/Cargo.lock @@ -319,6 +319,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "time 0.1.45", "wasm-bindgen", "winapi", diff --git a/orca/Cargo.toml b/orca/Cargo.toml index 8ec7af4..51748b1 100644 --- a/orca/Cargo.toml +++ b/orca/Cargo.toml @@ -12,7 +12,7 @@ rocket = { version = "0.5.0-rc.2", features = [ "json" ] } serde = { version = "1.0.147", features = ["derive"] } rocket-validation = "0.1.3" validator = "0.16.0" -sqlx = { version = "0.6.2", features = [ "runtime-tokio-rustls", "postgres", "macros", "all-types", "uuid" ] } +sqlx = { version = "0.6.2", features = [ "runtime-tokio-rustls", "postgres", "macros", "all-types", "uuid", "chrono" ] } time = { version = "0.3.19", features = [ "serde-human-readable" ] } rustc-serialize = "0.3.24" tokio = { version = "1.25.0", features = [ "io-std", "fs", "process" ] } @@ -20,7 +20,7 @@ image = "0.24.5" cfg-if = "1.0.0" log = "0.4.17" fern = "0.6.1" -chrono = "0.4.24" +chrono = { version = "0.4.24", features = [ "serde" ] } uuid = { version = "1.3.0", features = [ "v4", "serde" ] } phf = { version = "0.11.1", features = [ "macros" ] } lettre = { version = "0.10.4", features = [ "tokio1", "tokio1-native-tls" ] } diff --git a/orca/README.md b/orca/README.md index 87fc816..dc409c2 100644 --- a/orca/README.md +++ b/orca/README.md @@ -41,6 +41,28 @@ We also use cargo features for compile time configuration like: cargo build --features proxy-support ``` +### Keycloak Authorization + +Configure keycloak to enable administration APIs: + +```toml +# Part of Rocket.toml +keycloak_host = "https://keycloak.ictunion.cz" +keycloak_realm = "testing-members" +keycloak_client_id = "orca" +``` + +If you don't want to have administration features simply don't set these values. +Orca can run without keycloak but it won't allow any administration API to be used. + +The permissions to many admin features are granular. +Orca is using [Keycloak's client roles](https://www.keycloak.org/docs/latest/server_admin/#core-concepts-and-terms) +which needs to be configured for the `keycloak_client_id` set in the `Rocket.toml`: + +| Role Name | Description | +|-------------------|--------------------------------------------------------------| +| list-applications | Allow listing of applications/registrations in various state | + ## Developing ### Toolchain diff --git a/orca/default.nix b/orca/default.nix index 12d13ab..e77302b 100644 --- a/orca/default.nix +++ b/orca/default.nix @@ -19,7 +19,7 @@ rustPlatform.buildRustPackage { pname = "ict-union-orca"; version = "0.1.0"; src = nix-gitignore.gitignoreSource [] ./.; - cargoSha256 = "sha256-TcQ26NRmd7aCok2WvRWZBmLGNI2bmQZEqxwiNT1ys9o="; + cargoSha256 = "sha256-JGSLmdUXhhB0z8x2w1feKnHUGb7vWviJBP9dbq0E9aY="; nativeBuildInputs = [ pkg-config makeWrapper ]; diff --git a/orca/src/api/applications/mod.rs b/orca/src/api/applications/mod.rs index 2f06932..68bbb08 100644 --- a/orca/src/api/applications/mod.rs +++ b/orca/src/api/applications/mod.rs @@ -1,20 +1,73 @@ +use chrono::{DateTime, Utc}; +use rocket::serde::json::Json; use rocket::{Route, State}; +use serde::Serialize; -use super::Response; +use crate::api::Response; +use crate::data::{Id, RegistrationRequest}; use crate::db::DbPool; -use crate::server::keycloak::{JwtToken, Keycloak}; +use crate::server::keycloak::{JwtToken, Keycloak, Role}; -#[get("/test", format = "json")] -async fn test_api<'r>( - _db_pool: &State, +mod query; + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct UnverifiedSummary { + id: Id, + email: Option, + first_name: Option, + last_name: Option, + phone_number: Option, + city: Option, + company_name: Option, + registration_local: String, + created_at: DateTime, + verification_sent_at: Option>, +} + +#[get("/unverified")] +async fn list_unverified<'r>( + db_pool: &State, + keycloak: &State, + token: JwtToken<'r>, +) -> Response>> { + keycloak.require_role(token, Role::ListApplications)?; + + let summaries = query::get_unverified_summaries() + .fetch_all(db_pool.inner()) + .await?; + + Ok(Json(summaries)) +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct ProcessingSummary { + id: Id, + email: Option, + first_name: Option, + last_name: Option, + phone_number: Option, + city: Option, + company_name: Option, + registration_local: String, + created_at: DateTime, + confirmed_at: DateTime, +} + +#[get("/processing")] +async fn list_processing<'r>( + db_pool: &State, keycloak: &State, token: JwtToken<'r>, -) -> Response { - let res = keycloak.inner().decode_jwt(token); +) -> Response>> { + keycloak.require_role(token, Role::ListApplications)?; + + let summaries = query::get_processing_summaries() + .fetch_all(db_pool.inner()) + .await?; - Ok(format!("{:?}", res)) + Ok(Json(summaries)) } pub fn routes() -> Vec { - routes![test_api] + routes![list_unverified, list_processing] } diff --git a/orca/src/api/applications/query.rs b/orca/src/api/applications/query.rs new file mode 100644 index 0000000..f390e47 --- /dev/null +++ b/orca/src/api/applications/query.rs @@ -0,0 +1,23 @@ +use crate::db::QueryAs; + +use super::{ProcessingSummary, UnverifiedSummary}; + +pub fn get_unverified_summaries<'a>() -> QueryAs<'a, UnverifiedSummary> { + sqlx::query_as( + " +SELECT id, email, first_name, last_name, phone_number, city, company_name, registration_local, created_at, verification_sent_at +FROM registration_requests_unverified +ORDER BY created_at DESC +", + ) +} + +pub fn get_processing_summaries<'a>() -> QueryAs<'a, ProcessingSummary> { + sqlx::query_as( + " +SELECT id, email, first_name, last_name, phone_number, city, company_name, registration_local, created_at, confirmed_at +FROM registration_requests_processing +ORDER BY confirmed_at DESC +", + ) +} diff --git a/orca/src/api/mod.rs b/orca/src/api/mod.rs index 721fda1..fdd1ade 100644 --- a/orca/src/api/mod.rs +++ b/orca/src/api/mod.rs @@ -27,6 +27,8 @@ impl<'r> Responder<'r, 'static> for SqlError { use rocket::http::Status; use sqlx::error::Error::*; + error!("SQL Error: {:?}", self); + match self.0 { RowNotFound => Err(Status::NotFound), PoolTimedOut => Err(Status::ServiceUnavailable), @@ -46,12 +48,16 @@ impl From for ThreadingError { impl<'r> response::Responder<'r, 'static> for ThreadingError { fn respond_to(self, _request: &'r Request<'_>) -> response::Result<'static> { + error!("Threading Error: {:?}", self); + Err(rocket::http::Status::InternalServerError) } } impl<'r> response::Responder<'r, 'static> for SenderError { fn respond_to(self, _request: &'r Request<'_>) -> response::Result<'static> { + error!("Sender Error: {:?}", self); + Err(rocket::http::Status::InternalServerError) } } @@ -61,7 +67,7 @@ impl<'r> Responder<'r, 'static> for keycloak::Error { use keycloak::Error::*; use rocket::http::Status; - info!("JWT verification failed with {:?}", self); + warn!("JWT verification failed with {:?}", self); match self { Disabled => Err(Status::NotAcceptable), diff --git a/orca/src/api/stats/query.rs b/orca/src/api/stats/query.rs index 3ae0c53..e0142a2 100644 --- a/orca/src/api/stats/query.rs +++ b/orca/src/api/stats/query.rs @@ -27,7 +27,7 @@ SELECT count(*) FROM registration_requests_rejected pub fn count_processing<'a>() -> QueryAs<'a, (i64,)> { sqlx::query_as( " -SELECT count(*) FROM registration_requests_procession +SELECT count(*) FROM registration_requests_processing ", ) } diff --git a/orca/src/config/templates.rs b/orca/src/config/templates.rs index d347bab..eec83c9 100644 --- a/orca/src/config/templates.rs +++ b/orca/src/config/templates.rs @@ -62,7 +62,6 @@ impl<'a> Templates<'a> { } fn load_template(&mut self, path: &str, template: &Template) -> Result<(), Error> { - println!("{}", path); // Tempalte can be directory containing different translations // default.html is translation which gets used when no other translation is found. match fs::read_dir(format!("{}/{}", path, template.name)) { diff --git a/orca/src/data/mod.rs b/orca/src/data/mod.rs index 86980ec..8e4925e 100644 --- a/orca/src/data/mod.rs +++ b/orca/src/data/mod.rs @@ -1,3 +1,4 @@ +use rocket::serde::Serialize; use std::error::Error; use std::fmt::Display; use std::marker::PhantomData; @@ -26,6 +27,15 @@ impl Type for Id { } } +impl Serialize for Id { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.0.serialize(serializer) + } +} + // This nees to be implemented specifically for Postgres // because not all db drivers implement decoding for i32. // `'r` is the lifetime of the `Row` being decoded diff --git a/orca/src/server/keycloak.rs b/orca/src/server/keycloak.rs index 0373e08..865e782 100644 --- a/orca/src/server/keycloak.rs +++ b/orca/src/server/keycloak.rs @@ -14,6 +14,25 @@ struct ConnectedKeycloak { client_id: String, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Role { + ListApplications, +} + +impl Role { + fn to_json_val(&self) -> &str { + match self { + Self::ListApplications => "list-applications", + } + } +} + +impl ToString for Role { + fn to_string(&self) -> String { + self.to_json_val().to_string() + } +} + impl ConnectedKeycloak { pub async fn fetch(host: &str, realm: &str, client_id: String) -> Result { let key = jwk::fetch_jwk(&format!( @@ -40,9 +59,9 @@ impl ConnectedKeycloak { jsonwebtoken::decode::(token.0, &self.key, &self.validation) } - pub fn require_role(&self, token: JwtToken, role: &str) -> Result, Error> { + pub fn require_role(&self, token: JwtToken, role: Role) -> Result, Error> { let token_data = self.decode_jwt(token)?; - if self.has_role(&token_data.claims, role) { + if self.has_role(&token_data.claims, role.to_json_val()) { Ok(token_data) } else { Err(Error::MissingRole(role.to_string())) @@ -57,7 +76,7 @@ impl ConnectedKeycloak { } } -pub enum KeycloakState { +enum KeycloakState { Connected(Box), Disconnected, } @@ -85,7 +104,7 @@ impl Keycloak { } } - pub fn require_role(&self, token: JwtToken, role: &str) -> Result, Error> { + pub fn require_role(&self, token: JwtToken, role: Role) -> Result, Error> { match &self.0 { KeycloakState::Connected(k) => k.require_role(token, role), KeycloakState::Disconnected => Err(Error::Disabled),