tangled
alpha
login
or
join now
eldridge.cam
/
cartography
0
fork
atom
Trading card city builder game?
0
fork
atom
overview
issues
pulls
pipelines
implement auth layer, with fake auth
eldridge.cam
1 month ago
6c0e696d
e5f0fec0
verified
This commit was signed with the committer's
known signature
.
eldridge.cam
SSH Key Fingerprint:
SHA256:MAgO4sya2MgvdgUjSGKAO0lQ9X2HQp1Jb+x/Tpeeims=
0/0
Waiting for spindle ...
+280
-57
15 changed files
expand all
collapse all
unified
split
Cargo.lock
packages
cartography
Cargo.toml
src
api
errors
banner_not_found_error.rs
forbidden_error.rs
internal_server_error.rs
mod.rs
unauthorized_error.rs
middleware
authorization.rs
mod.rs
mod.rs
operations
list_fields.rs
pull_banner.rs
app.rs
main.rs
test.rs
+50
-1
Cargo.lock
···
239
239
]
240
240
241
241
[[package]]
242
242
+
name = "axum-extra"
243
243
+
version = "0.12.5"
244
244
+
source = "registry+https://github.com/rust-lang/crates.io-index"
245
245
+
checksum = "fef252edff26ddba56bbcdf2ee3307b8129acb86f5749b68990c168a6fcc9c76"
246
246
+
dependencies = [
247
247
+
"axum 0.8.8",
248
248
+
"axum-core 0.5.6",
249
249
+
"bytes",
250
250
+
"futures-core",
251
251
+
"futures-util",
252
252
+
"headers",
253
253
+
"http",
254
254
+
"http-body",
255
255
+
"http-body-util",
256
256
+
"mime",
257
257
+
"pin-project-lite",
258
258
+
"tower-layer",
259
259
+
"tower-service",
260
260
+
"tracing",
261
261
+
]
262
262
+
263
263
+
[[package]]
242
264
name = "base64"
243
265
version = "0.22.1"
244
266
source = "registry+https://github.com/rust-lang/crates.io-index"
···
292
314
dependencies = [
293
315
"anyhow",
294
316
"axum 0.8.8",
317
317
+
"axum-extra 0.12.5",
295
318
"cartography-macros",
296
319
"clap",
297
320
"derive_more",
···
838
861
]
839
862
840
863
[[package]]
864
864
+
name = "headers"
865
865
+
version = "0.4.1"
866
866
+
source = "registry+https://github.com/rust-lang/crates.io-index"
867
867
+
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
868
868
+
dependencies = [
869
869
+
"base64",
870
870
+
"bytes",
871
871
+
"headers-core",
872
872
+
"http",
873
873
+
"httpdate",
874
874
+
"mime",
875
875
+
"sha1",
876
876
+
]
877
877
+
878
878
+
[[package]]
879
879
+
name = "headers-core"
880
880
+
version = "0.3.0"
881
881
+
source = "registry+https://github.com/rust-lang/crates.io-index"
882
882
+
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
883
883
+
dependencies = [
884
884
+
"http",
885
885
+
]
886
886
+
887
887
+
[[package]]
841
888
name = "heck"
842
889
version = "0.5.0"
843
890
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1742
1789
checksum = "7b3efdac145ec337b9db75e6d1d8732980551c07490ef9db72947e2af592f051"
1743
1790
dependencies = [
1744
1791
"axum 0.7.9",
1745
1745
-
"axum-extra",
1792
1792
+
"axum-extra 0.9.6",
1746
1793
"rust-embed",
1747
1794
"serde_json",
1748
1795
"tokio",
···
2363
2410
source = "registry+https://github.com/rust-lang/crates.io-index"
2364
2411
checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
2365
2412
dependencies = [
2413
2413
+
"base64",
2366
2414
"bitflags",
2367
2415
"bytes",
2368
2416
"http",
2369
2417
"http-body",
2418
2418
+
"mime",
2370
2419
"pin-project-lite",
2371
2420
"tower-layer",
2372
2421
"tower-service",
+2
-1
packages/cartography/Cargo.toml
···
13
13
[dependencies]
14
14
anyhow = "1.0.101"
15
15
axum = { version = "0.8.8", features = ["ws"] }
16
16
+
axum-extra = { version = "0.12.5", features = ["typed-header"] }
16
17
clap = { version = "4.5.58", features = ["derive"] }
17
18
derive_more = { version = "2.1.1", features = ["error", "display"] }
18
19
futures = "0.3.31"
···
28
29
time = { version = "0.3.47", features = ["serde", "serde-human-readable"] }
29
30
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
30
31
tokio-stream = { version = "0.1.18", features = ["sync"] }
31
31
-
tower-http = { version = "0.6.8", features = ["trace"] }
32
32
+
tower-http = { version = "0.6.8", features = ["auth", "trace"] }
32
33
tracing = "0.1.44"
33
34
tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
34
35
utoipa = { git = "https://github.com/foxfriends/utoipa", features = ["time"] } # { version = "5.4.0", features = ["time"] }
packages/cartography/src/api/errors/banner_not_found.rs
packages/cartography/src/api/errors/banner_not_found_error.rs
+14
packages/cartography/src/api/errors/forbidden_error.rs
···
1
1
+
use super::ApiError;
2
2
+
use axum::http::StatusCode;
3
3
+
4
4
+
#[derive(Debug, derive_more::Display, derive_more::Error)]
5
5
+
#[display("permission required")]
6
6
+
pub struct ForbiddenError;
7
7
+
8
8
+
impl ApiError for ForbiddenError {
9
9
+
const STATUS: StatusCode = StatusCode::FORBIDDEN;
10
10
+
const CODE: &str = "Forbidden";
11
11
+
type Detail = ();
12
12
+
13
13
+
fn detail(&self) -> Self::Detail {}
14
14
+
}
+33
packages/cartography/src/api/errors/internal_server_error.rs
···
1
1
+
use super::{ApiError, JsonError};
2
2
+
use axum::http::StatusCode;
3
3
+
4
4
+
#[derive(Debug, derive_more::Display, derive_more::Error)]
5
5
+
#[display("{_0}")]
6
6
+
pub struct InternalServerError(anyhow::Error);
7
7
+
8
8
+
#[allow(unused_macros)]
9
9
+
macro_rules! respond_internal_server_error {
10
10
+
($($fmt:tt)+) => {
11
11
+
return Err(internal_server_error(anyhow::anyhow!($($fmt)+)).into())
12
12
+
};
13
13
+
() => {
14
14
+
return Err(internal_server_error(anyhow::anyhow!("Unexpected server error")).into())
15
15
+
};
16
16
+
}
17
17
+
18
18
+
pub(crate) use respond_internal_server_error;
19
19
+
20
20
+
pub fn internal_server_error<T>(error: T) -> JsonError<InternalServerError>
21
21
+
where
22
22
+
anyhow::Error: From<T>,
23
23
+
{
24
24
+
JsonError(InternalServerError(error.into()))
25
25
+
}
26
26
+
27
27
+
impl ApiError for InternalServerError {
28
28
+
const STATUS: StatusCode = StatusCode::INTERNAL_SERVER_ERROR;
29
29
+
const CODE: &str = "Internal server error";
30
30
+
type Detail = ();
31
31
+
32
32
+
fn detail(&self) -> Self::Detail {}
33
33
+
}
+15
-33
packages/cartography/src/api/errors/mod.rs
···
1
1
use axum::body::Body;
2
2
use axum::http::{Response, StatusCode};
3
3
-
use axum::response::{IntoResponse, Json};
3
3
+
use axum::response::{AppendHeaders, IntoResponse, Json};
4
4
use serde_json::Value;
5
5
use std::error::Error;
6
6
7
7
-
mod banner_not_found;
7
7
+
mod banner_not_found_error;
8
8
+
mod forbidden_error;
9
9
+
mod internal_server_error;
10
10
+
mod unauthorized_error;
8
11
9
9
-
pub use banner_not_found::BannerNotFoundError;
12
12
+
pub use banner_not_found_error::BannerNotFoundError;
13
13
+
pub use forbidden_error::ForbiddenError;
14
14
+
#[allow(unused_imports)]
15
15
+
pub(crate) use internal_server_error::{internal_server_error, respond_internal_server_error};
16
16
+
pub use unauthorized_error::UnauthorizedError;
10
17
11
18
pub trait ApiError: Error {
12
19
const STATUS: StatusCode;
13
20
const CODE: &str;
14
21
type Detail: serde::Serialize;
15
22
16
16
-
fn detail(&self) -> Self::Detail;
17
17
-
}
18
18
-
19
19
-
#[derive(Debug, derive_more::Display, derive_more::Error)]
20
20
-
#[display("{_0}")]
21
21
-
pub struct InternalServerError(anyhow::Error);
22
22
-
23
23
-
macro_rules! respond_internal_server_error {
24
24
-
($($fmt:tt)+) => {
25
25
-
return Err(internal_server_error(anyhow::anyhow!($($fmt)+)).into())
26
26
-
};
27
27
-
() => {
28
28
-
return Err(internal_server_error(anyhow::anyhow!("Unexpected server error")).into())
29
29
-
};
30
30
-
}
31
31
-
32
32
-
pub(crate) use respond_internal_server_error;
23
23
+
fn headers(&self) -> AppendHeaders<Vec<(String, String)>> {
24
24
+
AppendHeaders(vec![])
25
25
+
}
33
26
34
34
-
pub fn internal_server_error<T>(error: T) -> JsonError<InternalServerError>
35
35
-
where
36
36
-
anyhow::Error: From<T>,
37
37
-
{
38
38
-
JsonError(InternalServerError(error.into()))
39
39
-
}
40
40
-
41
41
-
impl ApiError for InternalServerError {
42
42
-
const STATUS: StatusCode = StatusCode::INTERNAL_SERVER_ERROR;
43
43
-
const CODE: &str = "Internal server error";
44
44
-
type Detail = ();
45
45
-
46
46
-
fn detail(&self) -> Self::Detail {}
27
27
+
fn detail(&self) -> Self::Detail;
47
28
}
48
29
49
30
pub struct JsonError<T>(pub T);
···
76
57
77
58
(
78
59
T::STATUS,
60
60
+
self.0.headers(),
79
61
Json(ErrorData::<T> {
80
62
code: T::CODE,
81
63
message: self.0.to_string(),
+18
packages/cartography/src/api/errors/unauthorized_error.rs
···
1
1
+
use super::ApiError;
2
2
+
use axum::{http::StatusCode, response::AppendHeaders};
3
3
+
4
4
+
#[derive(Debug, derive_more::Display, derive_more::Error)]
5
5
+
#[display("authentication required")]
6
6
+
pub struct UnauthorizedError;
7
7
+
8
8
+
impl ApiError for UnauthorizedError {
9
9
+
const STATUS: StatusCode = StatusCode::UNAUTHORIZED;
10
10
+
const CODE: &str = "Unauthorized";
11
11
+
type Detail = ();
12
12
+
13
13
+
fn headers(&self) -> AppendHeaders<Vec<(String, String)>> {
14
14
+
AppendHeaders(vec![("WWW-Authenticate".to_owned(), "Trust".to_owned())])
15
15
+
}
16
16
+
17
17
+
fn detail(&self) -> Self::Detail {}
18
18
+
}
+82
packages/cartography/src/api/middleware/authorization.rs
···
1
1
+
use crate::api::errors::{ForbiddenError, JsonError, UnauthorizedError};
2
2
+
use crate::dto::AccountIdOrMe;
3
3
+
use axum::extract::Request;
4
4
+
use axum::http::HeaderValue;
5
5
+
use axum::middleware::Next;
6
6
+
use axum::response::Response;
7
7
+
use axum_extra::TypedHeader;
8
8
+
use axum_extra::headers;
9
9
+
use axum_extra::headers::authorization::Credentials;
10
10
+
11
11
+
#[derive(Clone, Debug)]
12
12
+
pub enum Authorization {
13
13
+
Unauthorized,
14
14
+
AccountOwner(String),
15
15
+
}
16
16
+
17
17
+
impl Authorization {
18
18
+
pub fn authorized_account_id(&self) -> Result<&str, JsonError<UnauthorizedError>> {
19
19
+
match self {
20
20
+
Authorization::Unauthorized => Err(JsonError(UnauthorizedError)),
21
21
+
Authorization::AccountOwner(account_id) => Ok(account_id),
22
22
+
}
23
23
+
}
24
24
+
25
25
+
pub fn resolve_account_id<'a>(
26
26
+
&'a self,
27
27
+
account_id: &'a AccountIdOrMe,
28
28
+
) -> Result<&'a str, JsonError<UnauthorizedError>> {
29
29
+
match account_id {
30
30
+
AccountIdOrMe::Me => self.authorized_account_id(),
31
31
+
AccountIdOrMe::AccountId(account_id) => Ok(account_id),
32
32
+
}
33
33
+
}
34
34
+
35
35
+
#[expect(clippy::result_large_err)]
36
36
+
pub fn require_authorization(&self, account_id: &str) -> axum::response::Result<()> {
37
37
+
let authorized_for = self.authorized_account_id()?;
38
38
+
if authorized_for == account_id {
39
39
+
Ok(())
40
40
+
} else {
41
41
+
Err(JsonError(ForbiddenError).into())
42
42
+
}
43
43
+
}
44
44
+
}
45
45
+
46
46
+
pub struct Trust(pub String);
47
47
+
48
48
+
impl Credentials for Trust {
49
49
+
const SCHEME: &'static str = "Trust";
50
50
+
51
51
+
fn decode(value: &HeaderValue) -> Option<Self> {
52
52
+
Some(Self(
53
53
+
value.to_str().ok()?["Trust ".len()..]
54
54
+
.trim_start()
55
55
+
.to_owned(),
56
56
+
))
57
57
+
}
58
58
+
59
59
+
/// Encode the credentials to a `HeaderValue`.
60
60
+
///
61
61
+
/// The `SCHEME` must be the first part of the `value`.
62
62
+
fn encode(&self) -> HeaderValue {
63
63
+
format!("Trust {}", self.0).try_into().unwrap()
64
64
+
}
65
65
+
}
66
66
+
67
67
+
/// DEV ONLY implementation of "authorization", in which we just trust that the caller
68
68
+
/// is not lying about who they are.
69
69
+
pub async fn trust(
70
70
+
authorization: Option<TypedHeader<headers::Authorization<Trust>>>,
71
71
+
mut request: Request,
72
72
+
next: Next,
73
73
+
) -> Response {
74
74
+
if let Some(TypedHeader(headers::Authorization(Trust(account_id)))) = authorization {
75
75
+
request
76
76
+
.extensions_mut()
77
77
+
.insert(Authorization::AccountOwner(account_id));
78
78
+
} else {
79
79
+
request.extensions_mut().insert(Authorization::Unauthorized);
80
80
+
}
81
81
+
next.run(request).await
82
82
+
}
+1
packages/cartography/src/api/middleware/mod.rs
···
1
1
+
pub mod authorization;
+1
packages/cartography/src/api/mod.rs
···
1
1
mod errors;
2
2
3
3
+
pub mod middleware;
3
4
pub mod operations;
4
5
pub mod ws;
+9
-9
packages/cartography/src/api/operations/list_fields.rs
···
1
1
-
use axum::Json;
2
2
-
use axum::extract::Path;
3
3
-
4
4
-
use crate::api::errors::{internal_server_error, respond_internal_server_error};
1
1
+
use crate::api::errors::internal_server_error;
2
2
+
use crate::api::middleware::authorization::Authorization;
5
3
use crate::dto::*;
4
4
+
use axum::extract::Path;
5
5
+
use axum::{Extension, Json};
6
6
7
7
#[derive(serde::Serialize, utoipa::ToSchema)]
8
8
pub struct ListFieldsResponse {
···
23
23
)]
24
24
pub async fn list_fields(
25
25
db: axum::Extension<sqlx::PgPool>,
26
26
-
Path(player_id): Path<AccountIdOrMe>,
26
26
+
Extension(authorization): Extension<Authorization>,
27
27
+
Path(account_id): Path<AccountIdOrMe>,
27
28
) -> axum::response::Result<Json<ListFieldsResponse>> {
28
28
-
dbg!(&player_id);
29
29
-
let AccountIdOrMe::AccountId(account_id) = player_id else {
30
30
-
respond_internal_server_error!("unimplemented");
31
31
-
};
29
29
+
let account_id = authorization.resolve_account_id(&account_id)?;
30
30
+
authorization.require_authorization(account_id)?;
31
31
+
32
32
let mut conn = db.acquire().await.map_err(internal_server_error)?;
33
33
let fields = sqlx::query_as!(
34
34
Field,
+26
-9
packages/cartography/src/api/operations/pull_banner.rs
···
1
1
use crate::api::errors::{
2
2
BannerNotFoundError, ErrorDetailResponse, JsonError, internal_server_error,
3
3
};
4
4
+
use crate::api::middleware::authorization::Authorization;
4
5
use crate::db::CardClass;
5
6
use crate::dto::*;
6
6
-
use axum::Json;
7
7
use axum::extract::Path;
8
8
+
use axum::{Extension, Json};
8
9
use rand::distr::weighted::WeightedIndex;
9
10
use rand::rngs::Xoshiro256PlusPlus;
10
11
use rand::{Rng, RngExt, SeedableRng, rng};
···
32
33
pub async fn pull_banner(
33
34
db: axum::Extension<sqlx::PgPool>,
34
35
Path(banner_id): Path<String>,
36
36
+
Extension(authorization): Extension<Authorization>,
35
37
) -> axum::response::Result<Json<PullBannerResponse>> {
38
38
+
let account_id = authorization.authorized_account_id()?;
39
39
+
36
40
let mut conn = db.begin().await.map_err(internal_server_error)?;
37
41
38
42
let banner = sqlx::query!(
···
149
153
inserted_pack.pack_banner_id,
150
154
inserted_pack.opened_at
151
155
"#,
152
152
-
"foxfriends",
156
156
+
account_id,
153
157
banner_id,
154
158
seed as i64,
155
159
&tile_type_ids,
···
179
183
mod tests {
180
184
use crate::test::prelude::*;
181
185
use axum::body::Body;
182
182
-
use axum::http::Request;
186
186
+
use axum::http::{Request, StatusCode};
183
187
use sqlx::PgPool;
184
188
185
189
use super::PullBannerResponse;
···
188
192
migrator = "MIGRATOR",
189
193
fixtures(path = "../../../fixtures", scripts("seed", "account"))
190
194
)]
191
191
-
async fn pull_banner_standard(pool: PgPool) {
195
195
+
async fn pull_banner_ok(pool: PgPool) {
192
196
let app = crate::app::Config::test(pool).into_router();
193
197
194
198
let request = Request::post("/api/v1/banners/base-standard/pull")
199
199
+
.header("Authorization", "Trust foxfriends")
195
200
.body(Body::empty())
196
201
.unwrap();
197
202
198
203
let Ok(response) = app.oneshot(request).await;
199
199
-
assert!(
200
200
-
response.status().is_success(),
201
201
-
"{}",
202
202
-
response.json::<serde_json::Value>().await.unwrap()
203
203
-
);
204
204
+
assert_success!(response);
205
205
+
204
206
let response: PullBannerResponse = response.json().await.unwrap();
205
207
assert_eq!(response.pack.pack_banner_id, "base-standard");
206
208
assert_eq!(response.pack.account_id, "foxfriends");
207
209
assert_eq!(response.pack.opened_at, None);
208
210
assert_eq!(response.pack_cards.len(), 5);
211
211
+
}
212
212
+
213
213
+
#[sqlx::test(
214
214
+
migrator = "MIGRATOR",
215
215
+
fixtures(path = "../../../fixtures", scripts("seed", "account"))
216
216
+
)]
217
217
+
async fn pull_banner_requires_authorization(pool: PgPool) {
218
218
+
let app = crate::app::Config::test(pool).into_router();
219
219
+
220
220
+
let request = Request::post("/api/v1/banners/base-standard/pull")
221
221
+
.body(Body::empty())
222
222
+
.unwrap();
223
223
+
224
224
+
let Ok(response) = app.oneshot(request).await;
225
225
+
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
209
226
}
210
227
}
+2
-1
packages/cartography/src/app.rs
···
1
1
-
use crate::api::{operations, ws};
1
1
+
use crate::api::{middleware, operations, ws};
2
2
use crate::bus::Bus;
3
3
use axum::Router;
4
4
use kameo::actor::Spawn as _;
···
84
84
axum::routing::get(axum::response::Json(ApiDoc::openapi())),
85
85
)
86
86
// .merge(scalar_api_reference::axum::router("/docs", &scalar_config)) // TODO: waiting on scalar_api_reference to re-publish
87
87
+
.layer(axum::middleware::from_fn(middleware::authorization::trust))
87
88
.layer(axum::Extension(bus))
88
89
.layer(axum::Extension(self.pool))
89
90
}
+3
-2
packages/cartography/src/main.rs
···
1
1
+
#[cfg(test)]
2
2
+
mod test;
3
3
+
1
4
mod actor;
2
5
mod api;
3
6
mod app;
4
7
mod bus;
5
8
mod db;
6
9
mod dto;
7
7
-
#[cfg(test)]
8
8
-
mod test;
9
10
10
11
use clap::Parser as _;
11
12
use tracing_subscriber::prelude::*;
+24
-1
packages/cartography/src/test.rs
···
2
2
3
3
pub trait ResponseExt {
4
4
async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T>;
5
5
+
async fn text(self) -> anyhow::Result<String>;
5
6
}
6
7
7
8
impl ResponseExt for axum::response::Response {
8
9
async fn json<T: serde::de::DeserializeOwned>(self) -> anyhow::Result<T> {
9
10
Ok(serde_json::from_slice(
10
10
-
&self.into_body().collect().await.unwrap().to_bytes(),
11
11
+
&self.into_body().collect().await?.to_bytes(),
12
12
+
)?)
13
13
+
}
14
14
+
15
15
+
async fn text(self) -> anyhow::Result<String> {
16
16
+
Ok(String::from_utf8(
17
17
+
self.into_body().collect().await?.to_bytes().to_vec(),
11
18
)?)
12
19
}
13
20
}
14
21
22
22
+
macro_rules! assert_success {
23
23
+
($response:expr) => {
24
24
+
assert!(
25
25
+
$response.status().is_success(),
26
26
+
"{}",
27
27
+
$response
28
28
+
.text()
29
29
+
.await
30
30
+
.expect("response body was not valid utf-8")
31
31
+
)
32
32
+
};
33
33
+
}
34
34
+
35
35
+
pub(crate) use assert_success;
36
36
+
15
37
pub mod prelude {
16
38
pub use super::ResponseExt as _;
17
39
pub use tower::ServiceExt as _;
18
40
19
41
pub const MIGRATOR: sqlx::migrate::Migrator =
20
42
cartography_macros::graphile_migrate!("migrations/committed");
43
43
+
pub(crate) use super::assert_success;
21
44
}