Add some documentation as an mdBook
This commit is contained in:
84
docs/development/architecture.md
Normal file
84
docs/development/architecture.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Architecture
|
||||
|
||||
The service is meant to be easily embeddable, with only a dependency to a database.
|
||||
It is also meant to stay lightweight in terms of resource usage and easily scalable horizontally.
|
||||
|
||||
## Workspace and crate split
|
||||
|
||||
The whole repository is a [Cargo Workspace](https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html) that includes multiple crates under the `/crates` directory.
|
||||
|
||||
This includes:
|
||||
|
||||
- `oauth2-types`: Useful structures and types to deal with OAuth 2.0/OpenID Connect endpoints. This might end up published as a standalone library as it can be useful in other contexts.
|
||||
- `mas-config`: Configuration parsing and loading
|
||||
- `mas-core`: Main logic, includes templates, database interactions and HTTP routes
|
||||
- `mas-cli`: Command line utility, main entry point
|
||||
|
||||
## Important crates
|
||||
|
||||
The project makes use of a few important crates.
|
||||
|
||||
### Async runtime: `tokio`
|
||||
|
||||
[Tokio](https://tokio.rs/) is the async runtime used by the project.
|
||||
The choice of runtime does not have much impact on most of the code.
|
||||
|
||||
It has an impact when:
|
||||
|
||||
- spawning asynchronous work (as in "not awaiting on it immediately")
|
||||
- running CPU-intensive tasks. They should be ran in a blocking context using `tokio::task::spawn_blocking`. This includes password hashing and other crypto operations.
|
||||
- when dealing with shared memory, e.g. mutexes, rwlocks, etc.
|
||||
|
||||
### Logging: `tracing`
|
||||
|
||||
Logging is handled through the [`tracing`](https://docs.rs/tracing/*/tracing/) crate.
|
||||
It provides a way to emit structured log messages at various levels.
|
||||
|
||||
```rust
|
||||
use tracing::{info, debug};
|
||||
|
||||
info!("Logging some things");
|
||||
debug!(user = "john", "Structured stuff");
|
||||
```
|
||||
|
||||
`tracing` also provides ways to create spans to better understand where a logging message comes from.
|
||||
In the future, it will help building OpenTelemetry-compatible distributed traces to help with debugging.
|
||||
|
||||
`tracing` is becoming the standard to log things in Rust.
|
||||
By itself it will do nothing unless a subscriber is installed to -for example- log the events to the console.
|
||||
|
||||
The CLI installs [`tracing-subcriber`](https://docs.rs/tracing-subscriber/*/tracing_subscriber/) on startup to log in the console.
|
||||
It looks for a `RUST_LOG` environment variable to determine what event should be logged.
|
||||
|
||||
### Error management: `thiserror` / `anyhow`
|
||||
|
||||
[`thiserror`](https://docs.rs/thiserror/*/thiserror/) helps defining custom error types.
|
||||
This is especially useful for errors that should be handled in a specific way, while being able to augment underlying errors with additional context.
|
||||
|
||||
[`anyhow`](https://docs.rs/anyhow/*/anyhow/) helps dealing with chains of errors.
|
||||
It allows for quickly adding additional context around an error while it is being propagated.
|
||||
|
||||
Both crates work well together and complement each other.
|
||||
|
||||
### Database interactions: `sqlx`
|
||||
|
||||
Interactions with the database are done through [`sqlx`](https://github.com/launchbadge/sqlx), an async, pure-Rust SQL library with compile-time check of queries.
|
||||
It also handles schema migrations.
|
||||
|
||||
### Web framework: `warp`
|
||||
|
||||
[`warp`](https://docs.rs/warp/*/warp/) is an easy, macro-free web framework.
|
||||
Its composability makes a lot of sense when implementing OAuth 2.0 endpoints, because of the need to deal with a lot of different scenarios.
|
||||
|
||||
### Templates: `tera`
|
||||
|
||||
[Tera](https://tera.netlify.app/) was chosen as template engine for its simplicity as well as its ability to load templates at runtime.
|
||||
The builtin templates are embedded in the final binary through some macro magic.
|
||||
|
||||
The downside of Tera compared to compile-time template engines is the possibility of runtime crashes.
|
||||
This can however be somewhat mitigated with unit tests.
|
||||
|
||||
### Crates from *RustCrypto*
|
||||
|
||||
The [RustCrypto team](https://github.com/RustCrypto) offer high quality, independent crates for dealing with cryptography.
|
||||
The whole project is highly modular and APIs are coherent between crates.
|
||||
96
docs/development/database.md
Normal file
96
docs/development/database.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Database
|
||||
|
||||
Interactions with the database goes through `sqlx`.
|
||||
It provides async database operations with connection pooling, migrations support and compile-time check of queries through macros.
|
||||
|
||||
## Compile-time check of queries
|
||||
|
||||
To be able to check queries, `sqlx` has to introspect the live database.
|
||||
Usually it does so by having the database available at compile time, but to avoid that we're using the `offline` feature of `sqlx`, which saves the introspection informatons as a flat file in the repository.
|
||||
|
||||
Preparing this flat file is done through `sqlx-cli`, and should be done everytime the database schema or the queries changed.
|
||||
|
||||
```sh
|
||||
# Install the CLI
|
||||
cargo install sqlx-cli --no-default-features --features postgres
|
||||
|
||||
cd crates/core/ # Must be in the mas-core crate folder
|
||||
export DATABASE_URL=postgresql:///matrix_auth
|
||||
cargo sqlx prepare
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
Migration files live in the `migrations` folder in the `mas-core` crate.
|
||||
|
||||
```sh
|
||||
cd crates/core/ # Again, in the mas-core crate folder
|
||||
export DATABASE_URL=postgresql:///matrix_auth
|
||||
cargo sqlx migrate run # Run pending migrations
|
||||
cargo sqlx migrate revert # Revert the last migration
|
||||
cargo sqlx migrate add -r [description] # Add new migration files
|
||||
```
|
||||
|
||||
Note that migrations are embedded in the final binary and can be run from the service CLI tool.
|
||||
|
||||
## Writing database interactions
|
||||
|
||||
A typical interaction with the database look like this:
|
||||
|
||||
```rust
|
||||
pub async fn lookup_session(
|
||||
executor: impl Executor<'_, Database = Postgres>,
|
||||
id: i64,
|
||||
) -> anyhow::Result<SessionInfo> {
|
||||
sqlx::query_as!(
|
||||
SessionInfo, // Struct that will be filled with the result
|
||||
r#"
|
||||
SELECT
|
||||
s.id,
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
s.active,
|
||||
s.created_at,
|
||||
a.created_at as "last_authd_at?"
|
||||
FROM user_sessions s
|
||||
INNER JOIN users u
|
||||
ON s.user_id = u.id
|
||||
LEFT JOIN user_session_authentications a
|
||||
ON a.session_id = s.id
|
||||
WHERE s.id = $1
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
id, // Query parameter
|
||||
)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
// Providing some context when there is an error
|
||||
.context("could not fetch session")
|
||||
}
|
||||
```
|
||||
|
||||
Note that we pass an `impl Executor` as parameter here.
|
||||
This allows us to use this function from either a simple connection or from an active transaction.
|
||||
|
||||
The caveat here is that the `executor` can be used only once, so if an interaction needs to do multiple queries, it should probably take an `impl Acquire` to then acquire a transaction and do multiple interactions.
|
||||
|
||||
```rust
|
||||
pub async fn login(
|
||||
conn: impl Acquire<'_, Database = Postgres>,
|
||||
username: &str,
|
||||
password: String,
|
||||
) -> Result<SessionInfo, LoginError> {
|
||||
let mut txn = conn.begin().await.context("could not start transaction")?;
|
||||
// First interaction
|
||||
let user = lookup_user_by_username(&mut txn, username)?;
|
||||
// Second interaction
|
||||
let mut session = start_session(&mut txn, user).await?;
|
||||
// Third interaction
|
||||
session.last_authd_at =
|
||||
Some(authenticate_session(&mut txn, session.id, password).await?);
|
||||
// Commit the transaction once everything went fine
|
||||
txn.commit().await.context("could not commit transaction")?;
|
||||
Ok(session)
|
||||
}
|
||||
```
|
||||
91
docs/development/warp.md
Normal file
91
docs/development/warp.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `warp`
|
||||
|
||||
Warp has a pretty unique approach in terms of routing.
|
||||
It does not have a central router, rather a chain of filters composed together.
|
||||
|
||||
It encourages writing reusable filters to handle stuff like authentication, extracting user sessions, starting database transactions, etc.
|
||||
|
||||
Everything related to `warp` currently lives in the `mas-core` crate:
|
||||
|
||||
- `crates/core/src/`
|
||||
- `handlers/`: The actual handlers for each route
|
||||
- `oauth2/`: Everything related to OAuth 2.0/OIDC endpoints
|
||||
- `views/`: HTML views (login, registration, account management, etc.)
|
||||
- `filters/`: Reusable, composable filters
|
||||
- `reply/`: Composable replies
|
||||
|
||||
## Defining a new endpoint
|
||||
|
||||
We usually keep one endpoint per file and use module roots to combine the filters of endpoints.
|
||||
|
||||
This is how it looks like in the current hierarchy at time of writing:
|
||||
- `mod.rs`: combines the filters from `oauth2`, `views` and `health`
|
||||
- `oauth2/`
|
||||
- `mod.rs`: combines filters from `authorization`, `discovery`, etc.
|
||||
- `authorization.rs`: handles `GET /oauth2/authorize` and `GET /oauth2/authorize/step`
|
||||
- `discovery.rs`: handles `GET /.well-known/openid-configuration`
|
||||
- ...
|
||||
- `views/`
|
||||
- `mod.rs`: combines the filters from `index`, `login`, `logout`, etc.
|
||||
- `index.rs`: handles `GET /`
|
||||
- `login.rs`: handles `GET /login` and `POST /login`
|
||||
- `logout.rs`: handles `POST /logout`
|
||||
- ...
|
||||
- `health.rs`: handles `GET /health`
|
||||
|
||||
All filters are functions that take their dependencies (the database connection pool, the template engine, etc.) as parameters and return an `impl warp::Filter<Extract = (impl warp::Reply,)>`.
|
||||
|
||||
```rust
|
||||
// crates/core/src/handlers/hello.rs
|
||||
|
||||
// Don't be scared by the type at the end, just copy-paste it
|
||||
pub(super) fn filter(
|
||||
pool: &PgPool,
|
||||
templates: &Templates,
|
||||
cookies_config: &CookiesConfig,
|
||||
) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone + Send + Sync + 'static {
|
||||
// Handles `GET /hello/:param`
|
||||
warp::path!("hello" / String)
|
||||
.and(warp::get())
|
||||
// Pass the template engine
|
||||
.and(with_templates(templates))
|
||||
// Extract the current user session
|
||||
.and(optional_session(pool, cookies_config))
|
||||
.and_then(get)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
// Parameter from the route
|
||||
parameter: String,
|
||||
// Template engine
|
||||
templates: Templates,
|
||||
// The current user session
|
||||
session: Option<SessionInfo>,
|
||||
) -> Result<impl Reply, Rejection> {
|
||||
let ctx = SomeTemplateContext::new(parameter)
|
||||
.maybe_with_session(session);
|
||||
|
||||
let content = templates.render_something(&ctx)?;
|
||||
let reply = html(content);
|
||||
Ok(reply)
|
||||
}
|
||||
```
|
||||
|
||||
And then, it can be attached to the root handler:
|
||||
|
||||
```rust
|
||||
// crates/core/src/handlers/mod.rs
|
||||
|
||||
use self::{health::filter as health, oauth2::filter as oauth2, hello::filter as hello};
|
||||
|
||||
pub fn root(
|
||||
pool: &PgPool,
|
||||
templates: &Templates,
|
||||
config: &RootConfig,
|
||||
) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone + Send + Sync + 'static {
|
||||
health(pool)
|
||||
.or(oauth2(pool, templates, &config.oauth2, &config.cookies))
|
||||
// Attach it here, passing the right dependencies
|
||||
.or(hello(pool, templates, &config.cookies))
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user