Add some documentation as an mdBook

This commit is contained in:
Quentin Gliech
2021-09-24 19:10:17 +02:00
parent 2792d6c6b0
commit 29378581a3
15 changed files with 816 additions and 0 deletions

View 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.

View 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
View 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))
}
```