Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ There are tests trying out the OAuth2 flow which can be run with `cargo test`.
You can also test the OAuth2 flow manually by running the flask application in
`test_client/client.py`.

### Creating a migration

To make a change to the database scheme, we use diesel migrations

1. To create a migration, run `diesel migration generate <migration name>`
2. Fill in the generated `up.sql` and `down.sql`
3. Re-generate `src/models/schema.rs` by running `diesel print-schema`
> Caution: at the moment, the `users` schema cannot be generated correctly automatically.

### Using Nix

We have provided a [flake.nix](./flake.nix) for easy setup for Nix users. With [flakes enabled](https://nixos.wiki/wiki/Flakes), run `nix develop`.
2 changes: 1 addition & 1 deletion migrations/2025-01-23-180624_create_passkeys/up.sql
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
-- Your SQL goes here
CREATE TABLE passkeys (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
name VARCHAR(255) NOT NULL,
cred VARCHAR NOT NULL,
cred_id VARCHAR NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id),
last_used TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
Expand Down
3 changes: 3 additions & 0 deletions migrations/2025-07-01-174809_add_roles_table/down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
DROP TABLE users_roles;
DROP TABLE roles;
13 changes: 13 additions & 0 deletions migrations/2025-07-01-174809_add_roles_table/up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Your SQL goes here
CREATE TABLE roles (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description VARCHAR(255) NOT NULL,
client_id INTEGER REFERENCES clients(id)
);

CREATE TABLE users_roles (
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
)
1 change: 1 addition & 0 deletions src/controllers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ pub mod clients_controller;
pub mod mailing_list_controller;
pub mod oauth_controller;
pub mod pages_controller;
pub mod roles_controller;
pub mod sessions_controller;
pub mod users_controller;
pub mod webauthn_controller;
25 changes: 12 additions & 13 deletions src/controllers/oauth_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::jwt::JWTBuilder;
use crate::models::client::*;
use crate::models::session::*;
use crate::models::user::*;
use crate::util::{roles_optional, scopes};

use crate::ephemeral::session::ensure_logged_in_and_redirect;
use crate::errors::OAuthError::InvalidCookie;
Expand Down Expand Up @@ -327,24 +328,22 @@ pub async fn token(
)))
} else {
let user = User::find(token.user_id, &db).await?;
let id_token = token
.scope
.as_ref()
.map(|scope| -> Option<String> {
match scope.contains("openid") {
true => {
jwt_builder.encode_id_token(&client, &user, config).ok()
},
false => None,
}
})
.flatten();
let scopes = scopes(&token.scope);
let id_token = if scopes.contains(&"openid".into()) {
let roles = roles_optional(&scopes, &user, client.id, &db).await?;
jwt_builder
.encode_id_token(&client, &user, config, roles)
.await
.ok()
} else {
None
};

let session = Session::create_client_session(
&user,
&client,
token.scope,
&config,
config,
&db,
)
.await?;
Expand Down
179 changes: 179 additions & 0 deletions src/controllers/roles_controller.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
use diesel::result::DatabaseErrorKind;
use rocket::form::Form;
use rocket::http::Status;
use rocket::response::status::Custom;
use rocket::response::{Redirect, Responder, status};
use rocket::serde::json::Json;
use std::fmt::Debug;

use crate::DbConn;
use crate::ephemeral::from_api::Api;
use crate::ephemeral::session::AdminSession;
use crate::errors::{Either, InternalError, Result, ZauthError};
use crate::models::client::Client;
use crate::models::role::{NewRole, Role};
use crate::models::user::User;
use crate::views::accepter::Accepter;

#[get("/roles?<error>")]
pub async fn list_roles<'r>(
error: Option<String>,
db: DbConn,
session: AdminSession,
) -> Result<impl Responder<'r, 'static>> {
let roles = Role::all(&db).await?;
let clients = Client::all(&db).await?;

Ok(Accepter {
html: template! {
"roles/index.html";
roles: Vec<Role> = roles.clone(),
clients: Vec<Client> = clients,
error: Option<String> = error,
current_user: User = session.admin,
},
json: Json(roles),
})
}

#[post("/roles", data = "<role>")]
pub async fn create_role<'r, 'a>(
role: Api<NewRole>,
db: DbConn,
_admin: AdminSession,
) -> Result<
Either<impl Responder<'a, 'static>, impl Responder<'r, 'static> + use<'r>>,
> {
let role = Role::create(role.into_inner(), &db).await;
match role {
Ok(role) => Ok(Either::Left(Accepter {
html: Redirect::to(uri!(list_roles(None::<String>))),
json: status::Created::new(String::from("/role")).body(Json(role)),
})),
Err(ZauthError::Internal(InternalError::DatabaseError(
diesel::result::Error::DatabaseError(
DatabaseErrorKind::UniqueViolation,
_,
),
))) => Ok(Either::Right(Accepter {
html: Redirect::to(uri!(list_roles(Some(
"role name already exists"
)))),
json: "role name already exists",
})),
Err(err) => Err(err),
}
}

#[get("/roles/add?<username>")]
pub async fn add_user_page<'r>(
username: Option<String>,
db: DbConn,
session: AdminSession,
) -> Result<impl Responder<'r, 'static>> {
let roles = Role::all(&db).await?;
Ok(template! {
"roles/add_user.html";
roles: Vec<Role> = roles,
username: String = username.unwrap_or_default(),
current_user: User = session.admin
})
}

#[get("/roles/<id>/show?<error>&<info>")]
pub async fn show_role_page<'r>(
id: i32,
error: Option<String>,
info: Option<String>,
session: AdminSession,
db: DbConn,
) -> Result<impl Responder<'r, 'static>> {
let role = Role::find(id, &db).await?;
let users = role.clone().users(&db).await?;

Ok(template! { "roles/show_role.html";
current_user: User = session.admin,
role: Role = role,
users: Vec<User> = users,
error: Option<String> = error,
info: Option<String> = info
})
}

#[delete("/roles/<id>")]
pub async fn delete_role<'r>(
id: i32,
_session: AdminSession,
db: DbConn,
) -> Result<impl Responder<'r, 'static>> {
let role = Role::find(id, &db).await?;
role.delete(&db).await?;
Ok(Accepter {
html: Redirect::to(uri!(list_roles(None::<String>))),
json: Custom(Status::NoContent, ()),
})
}

#[derive(FromForm)]
pub struct Mapping {
username: String,
role_id: i32,
}

#[post("/roles/mapping", data = "<form>")]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Change to POST /roles/<role_id>/users/<username>

This does feel more REST-like and additionally avoids the need for a Mapping struct.

I personally find /roles/mapping a confusing route. But feel free to leave as-is if there was a particular reason for it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, previously two forms used this one route, but I have now refactored it into their own different different post endpoints:

#[post("/users/<username>/roles", data = "<role_id>")] and #[post("/roles/<role_id>/users", data = "<username>")]

Form data for role_id and username are still necesarry, as these come from a form input.

pub async fn add_user<'r>(
form: Form<Mapping>,
db: DbConn,
_session: AdminSession,
) -> Result<impl Responder<'r, 'static>> {
let role = Role::find(form.role_id, &db).await?;
let user_result = User::find_by_username(form.username.clone(), &db).await;
Ok(match user_result {
Ok(user) => {
role.add_user(user.id, &db).await?;
Accepter {
html: Redirect::to(uri!(show_role_page(
role.id,
None::<String>,
Some("user added")
))),
json: Custom(Status::NoContent, ()),
}
},
Err(ZauthError::NotFound(_)) => Accepter {
html: Redirect::to(uri!(show_role_page(
role.id,
Some("user not found"),
None::<String>
))),
json: Custom(Status::NotFound, ()),
},
_ => Accepter {
html: Redirect::to(uri!(show_role_page(
role.id,
Some("error occured"),
None::<String>
))),
json: Custom(Status::InternalServerError, ()),
},
})
}

#[delete("/roles/<role_id>/mapping/<user_id>")]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Change to DELETE /roles/<role_id>/users/<username>

Consistent with my previous comment. I would also user <username> to be consistent with other methods.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, changed to your suggestion.

pub async fn delete_user<'r>(
role_id: i32,
user_id: i32,
_session: AdminSession,
db: DbConn,
) -> Result<impl Responder<'r, 'static>> {
let role = Role::find(role_id, &db).await?;
role.remove_user(user_id, &db).await?;
Ok(Accepter {
html: Redirect::to(uri!(show_role_page(
role_id,
None::<String>,
Some("user deleted")
))),
json: Custom(Status::Ok, ()),
})
}
Loading