Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ chrono = { version = "0.4", default-features = false, features = ["std", "clock"
serde = { version = "1", features = ["derive"] }
# Content hashes: a config file's ETag, and the directory's
sha2 = "0.11"
# The browsable pages read and render the JSON a handler produced
serde_json = "1"
# The ServeDir/ServeFile services the web UI is served with
tower = { version = "0.5", default-features = false, features = ["util"] }
tower-http = { version = "0.7", features = ["fs"] }
Expand All @@ -37,5 +39,3 @@ tempfile = "3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
# A log subscriber, so the fields of an audit event are evaluated in tests
tracing-subscriber = "0.3"
# The OpenAPI documents the spec tests ask about, written as JSON
serde_json = "1"
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ The web layer of a JSON API service, written in Rust.

What a service's routes do is its own; how it answers on the web does not have to be. Web holds
that part: one error shape for every failure, an audit event for every change, the web UI served
from the API's own address behind sign-in, help for each route read from the OpenAPI document, and
the configuration directory readable and writable over HTTP.
from the API's own address behind sign-in, help for each route read from the OpenAPI document, the
configuration directory readable and writable over HTTP, and the same routes as browsable pages.

One crate, `web-rs`, built on axum and utoipa. Who a caller is comes from the `auth-rs` crate of
the [auth](https://github.com/OpusProjects/auth) repository, so the parts that check it are generic
Expand All @@ -31,6 +31,9 @@ over the service's own permissions type.
- **Validated before it lands**: a change is loaded in a staged copy, refused whole if it fails
- **Atomic, careful writes**: temp file and rename per file, unchanged files never rewritten
- **ETags and If-Match**: a push against a directory someone else changed is refused, not merged
- **Browsable API**: a browser gets a page for any JSON route, with help, links and action buttons
- **Safe pages**: every value escaped, a strict CSP, never cached, never framed
- **Sign-in form**: an API key or a directory account becomes a session; no password stored here
- **Pinned by tag**: a service depends on a `vX.Y.Z` tag of this repository, never on `main`

---
Expand All @@ -39,7 +42,7 @@ over the service's own permissions type.

| Document | What it covers |
|---|---|
| [Usage](docs/usage.md) | Errors, audit events, the web UI, route help, and the configuration API |
| [Usage](docs/usage.md) | Errors, audit events, the web UI, route help, the configuration API, pages and sign-in |
| [Development](docs/development.md) | The layout, the CI gates, the tests, and working on a service against a local copy |
| [Releasing](docs/releasing.md) | How release-please cuts a version, and how a service moves to it |

Expand Down
5 changes: 5 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ src/
├── ui_assets.rs # UiAssets, serve, page_for_browsers
├── spec.rs # Spec: route help from the OpenAPI document
├── config/ # files (which files), store (staged, atomic), api (the routes' logic)
├── site.rs # Site: the service's name, document and cookies
├── pages.rs # the browsable API (negotiate, page)
├── login.rs # the sign-in form and its routes, SignIn
└── url.rs # query_escape
tests/ # integration tests: a real router, a JSON document, a log subscriber
docs/ # this documentation
Expand Down Expand Up @@ -61,6 +64,8 @@ Every line of the crate runs in a test, and the `test` check fails a pull reques
| `tests/spec_test.rs` | `Spec` asked about a small OpenAPI document written as JSON |
| `tests/audit_test.rs` | An audit event read back from a log subscriber |
| `tests/config_store_test.rs` | `FsConfigStore` on real directories, with a stand-in loader, including what the filesystem refuses |
| `tests/pages_test.rs` | `negotiate` in a real router: pages, themes, what is left alone, bodies too big to draw |
| `tests/login_test.rs` | The sign-in routes with a test directory: keys, accounts, every refusal, sign-out |
| `tests/config_api_test.rs` | Every config route against a test `ConfigHost` and a store that fails on purpose |

Test code under `tests/` is not counted by `cargo llvm-cov`, which is why helpers live there.
Expand Down
77 changes: 77 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ its web UI, and the route help its browsable pages read from the OpenAPI documen
- [Serving the web UI](#serving-the-web-ui)
- [Route help from the OpenAPI document](#route-help-from-the-openapi-document)
- [The configuration API](#the-configuration-api)
- [The service's Site](#the-services-site)
- [Browsable pages](#browsable-pages)
- [The sign-in form](#the-sign-in-form)

---

Expand Down Expand Up @@ -168,3 +171,77 @@ A service gives the module three things:
`files::config_files` lists a directory the way the loader should read it: every `.yaml` and `.yml`
file at any depth, hidden entries skipped, symlinks followed up to a depth cap. `is_config_path` is
the rule a file name must meet to be written at all.

---

## The service's Site

`Site` is what the pages and the sign-in form know about the service, fixed for the life of the process.

| Field | What it is |
|---|---|
| `product` | The name in every page's title and header |
| `spec` | The service's `Spec`, built once from its OpenAPI document |
| `session_cookie`, `login_cookie`, `theme_cookie` | The cookie names, so two services on one host never overwrite each other's |
| `raw_routes` | Route templates whose body is the service's product (an output it serves), never drawn as a page |

---

## Browsable pages

`pages::negotiate` is a middleware: the same route answers JSON to a client and a page to a browser.

```rust
router.layer(middleware::from_fn_with_state(site(), web_rs::pages::negotiate))
```

A request whose `Accept` names `text/html` gets the JSON drawn as a page.

What the page shows:
- every value escaped;
- every path the document answers with a GET, as a link;
- breadcrumbs that link only real routes;
- the route's help from the document (description, parameters, responses, the methods it allows);
- for a signed-in browser, a button for each write the object's own `related` declares, with the
session's CSRF token in its form.

Left as they are:
- `?format=json` gives the bytes back;
- a response that is not JSON passes through;
- a route in `raw_routes` passes through.

A body too large to draw sends the browser to the first page (`?limit=200`) when the route pages,
and to a short explanation when it does not. A window the caller named is drawn whatever its size.

Pages are served with a CSP that allows only their inline style, `Cache-Control: no-store`, and
`Vary: accept, cookie`. `?theme=` picks one of six palettes and the theme cookie remembers it.

---

## The sign-in form

`login` holds the routes behind the sign-in form: an API key or a directory account becomes a browser session.

| Function | Route |
|---|---|
| `login_form(site, sign_in, next, headers)` | `GET /api/v1/login`: the form, with a fresh challenge |
| `login_submit(site, sign_in, keys, sessions, ttl, headers, form)` | `POST /api/v1/login`: checks it, opens the session, redirects to `next` |
| `logout(site, sessions, headers, next)` | `POST /api/v1/logout`: ends the session server-side |

The form only accepts a submission paired with the challenge cookie it planted, so a page on another
site cannot sign a browser in under someone else's key. `next` must be a path on this origin, or
the service's home is used instead. An API key is checked against the key registry in constant time.
A directory account goes through the service's `SignIn`:

```rust
impl SignIn<Permissions> for Directory<'_> {
fn ldap_enabled(&self) -> bool { ... }
fn home(&self) -> String { ... }
async fn ldap_login(&self, username: &str, password: &str)
-> Result<(String, Permissions), LoginError> { ... }
}
```

What an account may touch comes from the service's own access rules, which is why checking it is
the service's: `ldap_login` answers the account's name and its permissions, or one of the
`LoginError`s. Each error is shown in its own words, with a status that tells the cases apart.
9 changes: 8 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
// an API answer
// - `spec`: what the OpenAPI document says about a route, for the pages that
// explain themselves
// - `url`: escaping a value for a query string
// - `pages`: the browsable API, a page instead of JSON when a browser asks
// - `login`: the sign-in form, by API key or directory account, and sign-out
// - `site`: what those pages know of the service (its name, its cookies)
// - `url`: escaping a value for a query string, and the local-path rule
//
// Who a caller is comes from auth-rs (API keys and browser sessions), so
// the gate and the audit events are generic over the service's permissions
Expand All @@ -22,10 +25,14 @@
pub mod audit;
pub mod config;
pub mod error;
pub mod login;
pub mod pages;
pub mod site;
pub mod spec;
pub mod ui_assets;
pub mod url;

pub use error::{ApiError, ErrorBody};
pub use site::Site;
pub use spec::Spec;
pub use ui_assets::UiAssets;
Loading
Loading