Skip to content

Commit

Permalink
Orca: applications listing apis (#91)
Browse files Browse the repository at this point in the history
  • Loading branch information
turboMaCk authored and ICTGuerrilla committed Sep 5, 2023
1 parent 64cc002 commit a931251
Show file tree
Hide file tree
Showing 13 changed files with 158 additions and 20 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/acceptance-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: ""
Expand Down
5 changes: 5 additions & 0 deletions gray-whale/migrations/V9__tighten_registration_requests.sql
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions orca/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions orca/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ 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" ] }
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" ] }
Expand Down
22 changes: 22 additions & 0 deletions orca/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion orca/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];

Expand Down
71 changes: 62 additions & 9 deletions orca/src/api/applications/mod.rs
Original file line number Diff line number Diff line change
@@ -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<DbPool>,
mod query;

#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct UnverifiedSummary {
id: Id<RegistrationRequest>,
email: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
phone_number: Option<String>,
city: Option<String>,
company_name: Option<String>,
registration_local: String,
created_at: DateTime<Utc>,
verification_sent_at: Option<DateTime<Utc>>,
}

#[get("/unverified")]
async fn list_unverified<'r>(
db_pool: &State<DbPool>,
keycloak: &State<Keycloak>,
token: JwtToken<'r>,
) -> Response<Json<Vec<UnverifiedSummary>>> {
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<RegistrationRequest>,
email: Option<String>,
first_name: Option<String>,
last_name: Option<String>,
phone_number: Option<String>,
city: Option<String>,
company_name: Option<String>,
registration_local: String,
created_at: DateTime<Utc>,
confirmed_at: DateTime<Utc>,
}

#[get("/processing")]
async fn list_processing<'r>(
db_pool: &State<DbPool>,
keycloak: &State<Keycloak>,
token: JwtToken<'r>,
) -> Response<String> {
let res = keycloak.inner().decode_jwt(token);
) -> Response<Json<Vec<ProcessingSummary>>> {
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<Route> {
routes![test_api]
routes![list_unverified, list_processing]
}
23 changes: 23 additions & 0 deletions orca/src/api/applications/query.rs
Original file line number Diff line number Diff line change
@@ -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
",
)
}
8 changes: 7 additions & 1 deletion orca/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -46,12 +48,16 @@ impl From<JoinError> 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)
}
}
Expand All @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion orca/src/api/stats/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
",
)
}
1 change: 0 additions & 1 deletion orca/src/config/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
10 changes: 10 additions & 0 deletions orca/src/data/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use rocket::serde::Serialize;
use std::error::Error;
use std::fmt::Display;
use std::marker::PhantomData;
Expand Down Expand Up @@ -26,6 +27,15 @@ impl<T> Type<Postgres> for Id<T> {
}
}

impl<T> Serialize for Id<T> {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
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
Expand Down
27 changes: 23 additions & 4 deletions orca/src/server/keycloak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, Error> {
let key = jwk::fetch_jwk(&format!(
Expand All @@ -40,9 +59,9 @@ impl ConnectedKeycloak {
jsonwebtoken::decode::<JwtClaims>(token.0, &self.key, &self.validation)
}

pub fn require_role(&self, token: JwtToken, role: &str) -> Result<TokenData<JwtClaims>, Error> {
pub fn require_role(&self, token: JwtToken, role: Role) -> Result<TokenData<JwtClaims>, 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()))
Expand All @@ -57,7 +76,7 @@ impl ConnectedKeycloak {
}
}

pub enum KeycloakState {
enum KeycloakState {
Connected(Box<ConnectedKeycloak>),
Disconnected,
}
Expand Down Expand Up @@ -85,7 +104,7 @@ impl Keycloak {
}
}

pub fn require_role(&self, token: JwtToken, role: &str) -> Result<TokenData<JwtClaims>, Error> {
pub fn require_role(&self, token: JwtToken, role: Role) -> Result<TokenData<JwtClaims>, Error> {
match &self.0 {
KeycloakState::Connected(k) => k.require_role(token, role),
KeycloakState::Disconnected => Err(Error::Disabled),
Expand Down

0 comments on commit a931251

Please sign in to comment.