+52
.sqlx/query-2c8868a59ae63dc65d996cf21fc1bec0c2c86d5d5f17d1454440c6fcd8d4d27a.json
+52
.sqlx/query-2c8868a59ae63dc65d996cf21fc1bec0c2c86d5d5f17d1454440c6fcd8d4d27a.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by\n FROM invite_codes ic\n JOIN users u ON ic.created_by_user = u.id\n WHERE ic.created_by_user = $1\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "code",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "available_uses",
14
+
"type_info": "Int4"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "disabled",
19
+
"type_info": "Bool"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "for_account",
24
+
"type_info": "Text"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "created_at",
29
+
"type_info": "Timestamptz"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "created_by",
34
+
"type_info": "Text"
35
+
}
36
+
],
37
+
"parameters": {
38
+
"Left": [
39
+
"Uuid"
40
+
]
41
+
},
42
+
"nullable": [
43
+
false,
44
+
false,
45
+
true,
46
+
false,
47
+
false,
48
+
false
49
+
]
50
+
},
51
+
"hash": "2c8868a59ae63dc65d996cf21fc1bec0c2c86d5d5f17d1454440c6fcd8d4d27a"
52
+
}
-14
.sqlx/query-413c5b03501a399dca13f345fcae05770517091d73db93966853e944c68ee237.json
-14
.sqlx/query-413c5b03501a399dca13f345fcae05770517091d73db93966853e944c68ee237.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1",
4
-
"describe": {
5
-
"columns": [],
6
-
"parameters": {
7
-
"Left": [
8
-
"Uuid"
9
-
]
10
-
},
11
-
"nullable": []
12
-
},
13
-
"hash": "413c5b03501a399dca13f345fcae05770517091d73db93966853e944c68ee237"
14
-
}
···
+28
.sqlx/query-46ea5ceff2a8f3f2b72c9c6a1bb69ce28efe8594fda026b6f9b298ef0597b40e.json
+28
.sqlx/query-46ea5ceff2a8f3f2b72c9c6a1bb69ce28efe8594fda026b6f9b298ef0597b40e.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, did FROM users WHERE id = ANY($1)",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"UuidArray"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
false
25
+
]
26
+
},
27
+
"hash": "46ea5ceff2a8f3f2b72c9c6a1bb69ce28efe8594fda026b6f9b298ef0597b40e"
28
+
}
-22
.sqlx/query-5a98e015997942835800fcd326e69b4f54b9830d0490c4f8841f8435478c57d3.json
-22
.sqlx/query-5a98e015997942835800fcd326e69b4f54b9830d0490c4f8841f8435478c57d3.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT code FROM invite_codes WHERE created_by_user = $1\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "code",
9
-
"type_info": "Text"
10
-
}
11
-
],
12
-
"parameters": {
13
-
"Left": [
14
-
"Uuid"
15
-
]
16
-
},
17
-
"nullable": [
18
-
false
19
-
]
20
-
},
21
-
"hash": "5a98e015997942835800fcd326e69b4f54b9830d0490c4f8841f8435478c57d3"
22
-
}
···
-28
.sqlx/query-5d5442136932d4088873a935c41cb3a683c4771e4fb8c151b3fd5119fb6c1068.json
-28
.sqlx/query-5d5442136932d4088873a935c41cb3a683c4771e4fb8c151b3fd5119fb6c1068.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT u.did, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "did",
9
-
"type_info": "Text"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "used_at",
14
-
"type_info": "Timestamptz"
15
-
}
16
-
],
17
-
"parameters": {
18
-
"Left": [
19
-
"Text"
20
-
]
21
-
},
22
-
"nullable": [
23
-
false,
24
-
false
25
-
]
26
-
},
27
-
"hash": "5d5442136932d4088873a935c41cb3a683c4771e4fb8c151b3fd5119fb6c1068"
28
-
}
···
+3
-3
.sqlx/query-7b2d1d4ac06063e07a7c7a7d0fb434db08ce312eb2864405d7f96f4e985ed036.json
.sqlx/query-888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600.json
+3
-3
.sqlx/query-7b2d1d4ac06063e07a7c7a7d0fb434db08ce312eb2864405d7f96f4e985ed036.json
.sqlx/query-888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600.json
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)",
4
"describe": {
5
"columns": [],
6
"parameters": {
7
"Left": [
8
+
"TextArray"
9
]
10
},
11
"nullable": []
12
},
13
+
"hash": "888f8724cfc2ed27391b661a5cfe423d28c77e1a368df7edc81708eb3038f600"
14
}
+34
.sqlx/query-ae6695ae53fc5e5293f17ddf8cc4532d549d1ad8a9835da4a5c001eee89db076.json
+34
.sqlx/query-ae6695ae53fc5e5293f17ddf8cc4532d549d1ad8a9835da4a5c001eee89db076.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT icu.code, u.did as used_by, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = ANY($1)\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "code",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "used_by",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "used_at",
19
+
"type_info": "Timestamptz"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"TextArray"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
false
31
+
]
32
+
},
33
+
"hash": "ae6695ae53fc5e5293f17ddf8cc4532d549d1ad8a9835da4a5c001eee89db076"
34
+
}
+34
.sqlx/query-ed1ccbaaed6e3f34982dc118ddd9bde7269879c0547ad43f30b78bfeeef5a920.json
+34
.sqlx/query-ed1ccbaaed6e3f34982dc118ddd9bde7269879c0547ad43f30b78bfeeef5a920.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT icu.code, u.did, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = ANY($1)\n ORDER BY icu.used_at DESC\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "code",
9
+
"type_info": "Text"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "used_at",
19
+
"type_info": "Timestamptz"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"TextArray"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
false
31
+
]
32
+
},
33
+
"hash": "ed1ccbaaed6e3f34982dc118ddd9bde7269879c0547ad43f30b78bfeeef5a920"
34
+
}
+14
.sqlx/query-eec42a3a4b1440aa8dd580b5d0bbd1184b909f781d131aa2c69368ed021e87e4.json
+14
.sqlx/query-eec42a3a4b1440aa8dd580b5d0bbd1184b909f781d131aa2c69368ed021e87e4.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE invite_codes SET disabled = TRUE WHERE created_by_user IN (SELECT id FROM users WHERE did = ANY($1))",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"TextArray"
9
+
]
10
+
},
11
+
"nullable": []
12
+
},
13
+
"hash": "eec42a3a4b1440aa8dd580b5d0bbd1184b909f781d131aa2c69368ed021e87e4"
14
+
}
+1
-1
Cargo.toml
+1
-1
Cargo.toml
···
92
tracing = "0.1"
93
tracing-subscriber = "0.3"
94
urlencoding = "2.1"
95
-
uuid = { version = "1.19", features = ["v4", "v5", "fast-rng"] }
96
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
97
webauthn-rs-proto = "0.5"
98
zip = { version = "7.0", default-features = false, features = ["deflate"] }
···
92
tracing = "0.1"
93
tracing-subscriber = "0.3"
94
urlencoding = "2.1"
95
+
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng"] }
96
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
97
webauthn-rs-proto = "0.5"
98
zip = { version = "7.0", default-features = false, features = ["deflate"] }
+4
-5
crates/tranquil-comms/src/locale.rs
+4
-5
crates/tranquil-comms/src/locale.rs
+22
-4
crates/tranquil-oauth/src/client.rs
+22
-4
crates/tranquil-oauth/src/client.rs
···
568
.get("y")
569
.and_then(|v| v.as_str())
570
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
571
-
let x_bytes = URL_SAFE_NO_PAD
572
.decode(x)
573
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
574
-
let y_bytes = URL_SAFE_NO_PAD
575
.decode(y)
576
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
577
let mut point_bytes = vec![0x04];
578
point_bytes.extend_from_slice(&x_bytes);
579
point_bytes.extend_from_slice(&y_bytes);
···
604
.get("y")
605
.and_then(|v| v.as_str())
606
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
607
-
let x_bytes = URL_SAFE_NO_PAD
608
.decode(x)
609
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
610
-
let y_bytes = URL_SAFE_NO_PAD
611
.decode(y)
612
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
613
let mut point_bytes = vec![0x04];
614
point_bytes.extend_from_slice(&x_bytes);
615
point_bytes.extend_from_slice(&y_bytes);
···
568
.get("y")
569
.and_then(|v| v.as_str())
570
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
571
+
let x_decoded = URL_SAFE_NO_PAD
572
.decode(x)
573
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
574
+
let y_decoded = URL_SAFE_NO_PAD
575
.decode(y)
576
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
577
+
if x_decoded.len() > 32 || y_decoded.len() > 32 {
578
+
return Err(OAuthError::InvalidClient(
579
+
"EC coordinate too long".to_string(),
580
+
));
581
+
}
582
+
let mut x_bytes = [0u8; 32];
583
+
let mut y_bytes = [0u8; 32];
584
+
x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded);
585
+
y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded);
586
let mut point_bytes = vec![0x04];
587
point_bytes.extend_from_slice(&x_bytes);
588
point_bytes.extend_from_slice(&y_bytes);
···
613
.get("y")
614
.and_then(|v| v.as_str())
615
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
616
+
let x_decoded = URL_SAFE_NO_PAD
617
.decode(x)
618
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
619
+
let y_decoded = URL_SAFE_NO_PAD
620
.decode(y)
621
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
622
+
if x_decoded.len() > 48 || y_decoded.len() > 48 {
623
+
return Err(OAuthError::InvalidClient(
624
+
"EC coordinate too long".to_string(),
625
+
));
626
+
}
627
+
let mut x_bytes = [0u8; 48];
628
+
let mut y_bytes = [0u8; 48];
629
+
x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded);
630
+
y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded);
631
let mut point_bytes = vec![0x04];
632
point_bytes.extend_from_slice(&x_bytes);
633
point_bytes.extend_from_slice(&y_bytes);
+72
-14
crates/tranquil-oauth/src/dpop.rs
+72
-14
crates/tranquil-oauth/src/dpop.rs
···
218
crv
219
)));
220
}
221
-
let x_bytes = URL_SAFE_NO_PAD
222
.decode(
223
jwk.x
224
.as_ref()
225
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
226
)
227
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
228
-
let y_bytes = URL_SAFE_NO_PAD
229
.decode(
230
jwk.y
231
.as_ref()
232
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
233
)
234
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
235
-
let point = EncodedPoint::from_affine_coordinates(
236
-
x_bytes.as_slice().into(),
237
-
y_bytes.as_slice().into(),
238
-
false,
239
-
);
240
let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
241
let affine =
242
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
···
264
crv
265
)));
266
}
267
-
let x_bytes = URL_SAFE_NO_PAD
268
.decode(
269
jwk.x
270
.as_ref()
271
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
272
)
273
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
274
-
let y_bytes = URL_SAFE_NO_PAD
275
.decode(
276
jwk.y
277
.as_ref()
278
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
279
)
280
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
281
-
let point = EncodedPoint::from_affine_coordinates(
282
-
x_bytes.as_slice().into(),
283
-
y_bytes.as_slice().into(),
284
-
false,
285
-
);
286
let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
287
let affine =
288
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
···
397
};
398
let thumbprint = compute_jwk_thumbprint(&jwk).unwrap();
399
assert!(!thumbprint.is_empty());
400
}
401
}
···
218
crv
219
)));
220
}
221
+
let x_decoded = URL_SAFE_NO_PAD
222
.decode(
223
jwk.x
224
.as_ref()
225
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
226
)
227
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
228
+
let y_decoded = URL_SAFE_NO_PAD
229
.decode(
230
jwk.y
231
.as_ref()
232
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
233
)
234
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
235
+
let mut x_bytes = [0u8; 32];
236
+
let mut y_bytes = [0u8; 32];
237
+
if x_decoded.len() > 32 || y_decoded.len() > 32 {
238
+
return Err(OAuthError::InvalidDpopProof(
239
+
"EC coordinate too long".to_string(),
240
+
));
241
+
}
242
+
x_bytes[32 - x_decoded.len()..].copy_from_slice(&x_decoded);
243
+
y_bytes[32 - y_decoded.len()..].copy_from_slice(&y_decoded);
244
+
let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false);
245
let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
246
let affine =
247
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
···
269
crv
270
)));
271
}
272
+
let x_decoded = URL_SAFE_NO_PAD
273
.decode(
274
jwk.x
275
.as_ref()
276
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
277
)
278
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
279
+
let y_decoded = URL_SAFE_NO_PAD
280
.decode(
281
jwk.y
282
.as_ref()
283
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
284
)
285
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
286
+
let mut x_bytes = [0u8; 48];
287
+
let mut y_bytes = [0u8; 48];
288
+
if x_decoded.len() > 48 || y_decoded.len() > 48 {
289
+
return Err(OAuthError::InvalidDpopProof(
290
+
"EC coordinate too long".to_string(),
291
+
));
292
+
}
293
+
x_bytes[48 - x_decoded.len()..].copy_from_slice(&x_decoded);
294
+
y_bytes[48 - y_decoded.len()..].copy_from_slice(&y_decoded);
295
+
let point = EncodedPoint::from_affine_coordinates((&x_bytes).into(), (&y_bytes).into(), false);
296
let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
297
let affine =
298
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
···
407
};
408
let thumbprint = compute_jwk_thumbprint(&jwk).unwrap();
409
assert!(!thumbprint.is_empty());
410
+
}
411
+
412
+
#[test]
413
+
fn test_es256_short_coordinate_no_panic() {
414
+
let short_31_bytes = vec![0x42u8; 31];
415
+
let short_30_bytes = vec![0x42u8; 30];
416
+
let x_b64 = URL_SAFE_NO_PAD.encode(&short_31_bytes);
417
+
let y_b64 = URL_SAFE_NO_PAD.encode(&short_30_bytes);
418
+
let jwk = DPoPJwk {
419
+
kty: "EC".to_string(),
420
+
crv: Some("P-256".to_string()),
421
+
x: Some(x_b64),
422
+
y: Some(y_b64),
423
+
};
424
+
let result = verify_es256(&jwk, b"test", &[0u8; 64]);
425
+
assert!(result.is_err(), "Invalid coordinates should return error, not panic");
426
+
}
427
+
428
+
#[test]
429
+
fn test_es256_valid_key_with_trimmed_coordinates() {
430
+
use p256::ecdsa::{SigningKey, signature::Signer};
431
+
use p256::elliptic_curve::rand_core::OsRng;
432
+
433
+
let signing_key = SigningKey::random(&mut OsRng);
434
+
let verifying_key = signing_key.verifying_key();
435
+
let point = verifying_key.to_encoded_point(false);
436
+
let x_bytes = point.x().unwrap();
437
+
let y_bytes = point.y().unwrap();
438
+
let x_trimmed: Vec<u8> = x_bytes.iter().copied().skip_while(|&b| b == 0).collect();
439
+
let y_trimmed: Vec<u8> = y_bytes.iter().copied().skip_while(|&b| b == 0).collect();
440
+
let x_b64 = URL_SAFE_NO_PAD.encode(&x_trimmed);
441
+
let y_b64 = URL_SAFE_NO_PAD.encode(&y_trimmed);
442
+
let jwk = DPoPJwk {
443
+
kty: "EC".to_string(),
444
+
crv: Some("P-256".to_string()),
445
+
x: Some(x_b64),
446
+
y: Some(y_b64),
447
+
};
448
+
let message = b"test message for signature verification";
449
+
let signature: p256::ecdsa::Signature = signing_key.sign(message);
450
+
let result = verify_es256(&jwk, message, signature.to_bytes().as_slice());
451
+
assert!(
452
+
result.is_ok(),
453
+
"Should verify signature with trimmed coordinates (x={}, y={}): {:?}",
454
+
x_trimmed.len(),
455
+
y_trimmed.len(),
456
+
result
457
+
);
458
}
459
}
+101
-62
crates/tranquil-pds/src/api/admin/account/info.rs
+101
-62
crates/tranquil-pds/src/api/admin/account/info.rs
···
130
db: &sqlx::PgPool,
131
user_id: uuid::Uuid,
132
) -> Option<Vec<InviteCodeInfo>> {
133
-
let codes = sqlx::query_scalar!(
134
r#"
135
-
SELECT code FROM invite_codes WHERE created_by_user = $1
136
"#,
137
user_id
138
)
139
.fetch_all(db)
140
.await
141
.ok()?;
142
-
if codes.is_empty() {
143
return None;
144
}
145
-
let mut invites = Vec::new();
146
-
for code in codes {
147
-
if let Some(info) = get_invite_code_info(db, &code).await {
148
-
invites.push(info);
149
-
}
150
-
}
151
if invites.is_empty() {
152
None
153
} else {
···
276
.map(|r| (r.used_by_user, r.code))
277
.collect();
278
279
-
let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> =
280
-
std::collections::HashMap::new();
281
-
for u in all_invite_uses {
282
-
uses_by_code
283
-
.entry(u.code.clone())
284
-
.or_default()
285
-
.push(InviteCodeUseInfo {
286
-
used_by: u.used_by.into(),
287
-
used_at: u.used_at.to_rfc3339(),
288
});
289
-
}
290
291
-
let mut codes_by_user: std::collections::HashMap<uuid::Uuid, Vec<InviteCodeInfo>> =
292
-
std::collections::HashMap::new();
293
-
let mut code_info_map: std::collections::HashMap<String, InviteCodeInfo> =
294
-
std::collections::HashMap::new();
295
-
for ic in all_invite_codes {
296
-
let info = InviteCodeInfo {
297
-
code: ic.code.clone(),
298
-
available: ic.available_uses,
299
-
disabled: ic.disabled.unwrap_or(false),
300
-
for_account: ic.for_account.into(),
301
-
created_by: ic.created_by.into(),
302
-
created_at: ic.created_at.to_rfc3339(),
303
-
uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(),
304
-
};
305
-
code_info_map.insert(ic.code.clone(), info.clone());
306
-
codes_by_user
307
-
.entry(ic.created_by_user)
308
-
.or_default()
309
-
.push(info);
310
-
}
311
312
-
let mut infos = Vec::with_capacity(users.len());
313
-
for row in users {
314
-
let invited_by = invited_by_map
315
-
.get(&row.id)
316
-
.and_then(|code| code_info_map.get(code).cloned());
317
-
let invites = codes_by_user.get(&row.id).cloned();
318
-
infos.push(AccountInfo {
319
-
did: row.did.into(),
320
-
handle: row.handle.into(),
321
-
email: row.email,
322
-
indexed_at: row.created_at.to_rfc3339(),
323
-
invite_note: None,
324
-
invites_disabled: row.invites_disabled.unwrap_or(false),
325
-
email_confirmed_at: if row.email_verified {
326
-
Some(row.created_at.to_rfc3339())
327
-
} else {
328
-
None
329
-
},
330
-
deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()),
331
-
invited_by,
332
-
invites,
333
-
});
334
-
}
335
(StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response()
336
}
···
130
db: &sqlx::PgPool,
131
user_id: uuid::Uuid,
132
) -> Option<Vec<InviteCodeInfo>> {
133
+
let invite_codes = sqlx::query!(
134
r#"
135
+
SELECT ic.code, ic.available_uses, ic.disabled, ic.for_account, ic.created_at, u.did as created_by
136
+
FROM invite_codes ic
137
+
JOIN users u ON ic.created_by_user = u.id
138
+
WHERE ic.created_by_user = $1
139
"#,
140
user_id
141
)
142
.fetch_all(db)
143
.await
144
.ok()?;
145
+
146
+
if invite_codes.is_empty() {
147
return None;
148
}
149
+
150
+
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
151
+
let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> =
152
+
std::collections::HashMap::new();
153
+
sqlx::query!(
154
+
r#"
155
+
SELECT icu.code, u.did as used_by, icu.used_at
156
+
FROM invite_code_uses icu
157
+
JOIN users u ON icu.used_by_user = u.id
158
+
WHERE icu.code = ANY($1)
159
+
"#,
160
+
&code_strings
161
+
)
162
+
.fetch_all(db)
163
+
.await
164
+
.ok()?
165
+
.into_iter()
166
+
.for_each(|r| {
167
+
uses_by_code
168
+
.entry(r.code)
169
+
.or_default()
170
+
.push(InviteCodeUseInfo {
171
+
used_by: r.used_by.into(),
172
+
used_at: r.used_at.to_rfc3339(),
173
+
});
174
+
});
175
+
176
+
let invites: Vec<InviteCodeInfo> = invite_codes
177
+
.into_iter()
178
+
.map(|ic| InviteCodeInfo {
179
+
code: ic.code.clone(),
180
+
available: ic.available_uses,
181
+
disabled: ic.disabled.unwrap_or(false),
182
+
for_account: ic.for_account.into(),
183
+
created_by: ic.created_by.into(),
184
+
created_at: ic.created_at.to_rfc3339(),
185
+
uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(),
186
+
})
187
+
.collect();
188
+
189
if invites.is_empty() {
190
None
191
} else {
···
314
.map(|r| (r.used_by_user, r.code))
315
.collect();
316
317
+
let uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> =
318
+
all_invite_uses
319
+
.into_iter()
320
+
.fold(std::collections::HashMap::new(), |mut acc, u| {
321
+
acc.entry(u.code.clone()).or_default().push(InviteCodeUseInfo {
322
+
used_by: u.used_by.into(),
323
+
used_at: u.used_at.to_rfc3339(),
324
+
});
325
+
acc
326
});
327
328
+
let (codes_by_user, code_info_map): (
329
+
std::collections::HashMap<uuid::Uuid, Vec<InviteCodeInfo>>,
330
+
std::collections::HashMap<String, InviteCodeInfo>,
331
+
) = all_invite_codes.into_iter().fold(
332
+
(std::collections::HashMap::new(), std::collections::HashMap::new()),
333
+
|(mut by_user, mut by_code), ic| {
334
+
let info = InviteCodeInfo {
335
+
code: ic.code.clone(),
336
+
available: ic.available_uses,
337
+
disabled: ic.disabled.unwrap_or(false),
338
+
for_account: ic.for_account.into(),
339
+
created_by: ic.created_by.into(),
340
+
created_at: ic.created_at.to_rfc3339(),
341
+
uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(),
342
+
};
343
+
by_code.insert(ic.code.clone(), info.clone());
344
+
by_user.entry(ic.created_by_user).or_default().push(info);
345
+
(by_user, by_code)
346
+
},
347
+
);
348
349
+
let infos: Vec<AccountInfo> = users
350
+
.into_iter()
351
+
.map(|row| {
352
+
let invited_by = invited_by_map
353
+
.get(&row.id)
354
+
.and_then(|code| code_info_map.get(code).cloned());
355
+
let invites = codes_by_user.get(&row.id).cloned();
356
+
AccountInfo {
357
+
did: row.did.into(),
358
+
handle: row.handle.into(),
359
+
email: row.email,
360
+
indexed_at: row.created_at.to_rfc3339(),
361
+
invite_note: None,
362
+
invites_disabled: row.invites_disabled.unwrap_or(false),
363
+
email_confirmed_at: if row.email_verified {
364
+
Some(row.created_at.to_rfc3339())
365
+
} else {
366
+
None
367
+
},
368
+
deactivated_at: row.deactivated_at.map(|dt| dt.to_rfc3339()),
369
+
invited_by,
370
+
invites,
371
+
}
372
+
})
373
+
.collect();
374
(StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response()
375
}
+11
-24
crates/tranquil-pds/src/api/admin/config.rs
+11
-24
crates/tranquil-pds/src/api/admin/config.rs
···
48
.fetch_all(&state.db)
49
.await?;
50
51
-
let mut server_name = "Tranquil PDS".to_string();
52
-
let mut primary_color = None;
53
-
let mut primary_color_dark = None;
54
-
let mut secondary_color = None;
55
-
let mut secondary_color_dark = None;
56
-
let mut logo_cid = None;
57
-
58
-
for (key, value) in rows {
59
-
match key.as_str() {
60
-
"server_name" => server_name = value,
61
-
"primary_color" => primary_color = Some(value),
62
-
"primary_color_dark" => primary_color_dark = Some(value),
63
-
"secondary_color" => secondary_color = Some(value),
64
-
"secondary_color_dark" => secondary_color_dark = Some(value),
65
-
"logo_cid" => logo_cid = Some(value),
66
-
_ => {}
67
-
}
68
-
}
69
70
Ok(Json(ServerConfigResponse {
71
-
server_name,
72
-
primary_color,
73
-
primary_color_dark,
74
-
secondary_color,
75
-
secondary_color_dark,
76
-
logo_cid,
77
}))
78
}
79
···
48
.fetch_all(&state.db)
49
.await?;
50
51
+
let config_map: std::collections::HashMap<String, String> =
52
+
rows.into_iter().collect();
53
54
Ok(Json(ServerConfigResponse {
55
+
server_name: config_map
56
+
.get("server_name")
57
+
.cloned()
58
+
.unwrap_or_else(|| "Tranquil PDS".to_string()),
59
+
primary_color: config_map.get("primary_color").cloned(),
60
+
primary_color_dark: config_map.get("primary_color_dark").cloned(),
61
+
secondary_color: config_map.get("secondary_color").cloned(),
62
+
secondary_color_dark: config_map.get("secondary_color_dark").cloned(),
63
+
logo_cid: config_map.get("logo_cid").cloned(),
64
}))
65
}
66
+69
-54
crates/tranquil-pds/src/api/admin/invite.rs
+69
-54
crates/tranquil-pds/src/api/admin/invite.rs
···
24
Json(input): Json<DisableInviteCodesInput>,
25
) -> Response {
26
if let Some(codes) = &input.codes {
27
-
for code in codes {
28
-
let _ = sqlx::query!(
29
-
"UPDATE invite_codes SET disabled = TRUE WHERE code = $1",
30
-
code
31
-
)
32
-
.execute(&state.db)
33
-
.await;
34
-
}
35
}
36
if let Some(accounts) = &input.accounts {
37
-
for account in accounts {
38
-
let user = sqlx::query!("SELECT id FROM users WHERE did = $1", account)
39
-
.fetch_optional(&state.db)
40
-
.await;
41
-
if let Ok(Some(user_row)) = user {
42
-
let _ = sqlx::query!(
43
-
"UPDATE invite_codes SET disabled = TRUE WHERE created_by_user = $1",
44
-
user_row.id
45
-
)
46
-
.execute(&state.db)
47
-
.await;
48
-
}
49
-
}
50
}
51
EmptyResponse::ok().into_response()
52
}
···
70
pub uses: Vec<InviteCodeUseInfo>,
71
}
72
73
-
#[derive(Serialize)]
74
#[serde(rename_all = "camelCase")]
75
pub struct InviteCodeUseInfo {
76
pub used_by: String,
···
149
return ApiError::InternalError(None).into_response();
150
}
151
};
152
-
let mut codes = Vec::new();
153
-
for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows {
154
-
let creator_did =
155
-
sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user)
156
-
.fetch_optional(&state.db)
157
-
.await
158
-
.ok()
159
-
.flatten()
160
-
.unwrap_or_else(|| "unknown".to_string());
161
-
let uses_result = sqlx::query!(
162
r#"
163
-
SELECT u.did, icu.used_at
164
FROM invite_code_uses icu
165
JOIN users u ON icu.used_by_user = u.id
166
-
WHERE icu.code = $1
167
ORDER BY icu.used_at DESC
168
"#,
169
-
code
170
)
171
.fetch_all(&state.db)
172
-
.await;
173
-
let uses = match uses_result {
174
-
Ok(use_rows) => use_rows
175
-
.iter()
176
-
.map(|u| InviteCodeUseInfo {
177
-
used_by: u.did.clone(),
178
-
used_at: u.used_at.to_rfc3339(),
179
-
})
180
-
.collect(),
181
-
Err(_) => Vec::new(),
182
-
};
183
-
codes.push(InviteCodeInfo {
184
-
code: code.clone(),
185
-
available: *available_uses,
186
-
disabled: disabled.unwrap_or(false),
187
-
for_account: creator_did.clone(),
188
-
created_by: creator_did,
189
-
created_at: created_at.to_rfc3339(),
190
-
uses,
191
});
192
}
193
let next_cursor = if codes_rows.len() == limit as usize {
194
codes_rows.last().map(|(code, _, _, _, _)| code.clone())
195
} else {
···
24
Json(input): Json<DisableInviteCodesInput>,
25
) -> Response {
26
if let Some(codes) = &input.codes {
27
+
let _ = sqlx::query!(
28
+
"UPDATE invite_codes SET disabled = TRUE WHERE code = ANY($1)",
29
+
codes as &[String]
30
+
)
31
+
.execute(&state.db)
32
+
.await;
33
}
34
if let Some(accounts) = &input.accounts {
35
+
let _ = sqlx::query!(
36
+
"UPDATE invite_codes SET disabled = TRUE WHERE created_by_user IN (SELECT id FROM users WHERE did = ANY($1))",
37
+
accounts as &[String]
38
+
)
39
+
.execute(&state.db)
40
+
.await;
41
}
42
EmptyResponse::ok().into_response()
43
}
···
61
pub uses: Vec<InviteCodeUseInfo>,
62
}
63
64
+
#[derive(Clone, Serialize)]
65
#[serde(rename_all = "camelCase")]
66
pub struct InviteCodeUseInfo {
67
pub used_by: String,
···
140
return ApiError::InternalError(None).into_response();
141
}
142
};
143
+
144
+
let user_ids: Vec<uuid::Uuid> = codes_rows.iter().map(|(_, _, _, uid, _)| *uid).collect();
145
+
let code_strings: Vec<String> = codes_rows.iter().map(|(c, _, _, _, _)| c.clone()).collect();
146
+
147
+
let mut creator_dids: std::collections::HashMap<uuid::Uuid, String> =
148
+
std::collections::HashMap::new();
149
+
sqlx::query!(
150
+
"SELECT id, did FROM users WHERE id = ANY($1)",
151
+
&user_ids
152
+
)
153
+
.fetch_all(&state.db)
154
+
.await
155
+
.unwrap_or_default()
156
+
.into_iter()
157
+
.for_each(|r| {
158
+
creator_dids.insert(r.id, r.did);
159
+
});
160
+
161
+
let mut uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> =
162
+
std::collections::HashMap::new();
163
+
if !code_strings.is_empty() {
164
+
sqlx::query!(
165
r#"
166
+
SELECT icu.code, u.did, icu.used_at
167
FROM invite_code_uses icu
168
JOIN users u ON icu.used_by_user = u.id
169
+
WHERE icu.code = ANY($1)
170
ORDER BY icu.used_at DESC
171
"#,
172
+
&code_strings
173
)
174
.fetch_all(&state.db)
175
+
.await
176
+
.unwrap_or_default()
177
+
.into_iter()
178
+
.for_each(|r| {
179
+
uses_by_code
180
+
.entry(r.code)
181
+
.or_default()
182
+
.push(InviteCodeUseInfo {
183
+
used_by: r.did,
184
+
used_at: r.used_at.to_rfc3339(),
185
+
});
186
});
187
}
188
+
189
+
let codes: Vec<InviteCodeInfo> = codes_rows
190
+
.iter()
191
+
.map(|(code, available_uses, disabled, created_by_user, created_at)| {
192
+
let creator_did = creator_dids
193
+
.get(created_by_user)
194
+
.cloned()
195
+
.unwrap_or_else(|| "unknown".to_string());
196
+
InviteCodeInfo {
197
+
code: code.clone(),
198
+
available: *available_uses,
199
+
disabled: disabled.unwrap_or(false),
200
+
for_account: creator_did.clone(),
201
+
created_by: creator_did,
202
+
created_at: created_at.to_rfc3339(),
203
+
uses: uses_by_code.get(code).cloned().unwrap_or_default(),
204
+
}
205
+
})
206
+
.collect();
207
+
208
let next_cursor = if codes_rows.len() == limit as usize {
209
codes_rows.last().map(|(code, _, _, _, _)| code.clone())
210
} else {
+26
-9
crates/tranquil-pds/src/api/error.rs
+26
-9
crates/tranquil-pds/src/api/error.rs
···
22
InvalidRequest(String),
23
InvalidToken(Option<String>),
24
ExpiredToken(Option<String>),
25
TokenRequired,
26
AccountDeactivated,
27
AccountTakedown,
···
127
| Self::InvalidCode(_)
128
| Self::InvalidPassword(_)
129
| Self::InvalidToken(_)
130
-
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
131
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
132
Self::Forbidden
133
| Self::AdminRequired
···
216
Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"),
217
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
218
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
219
-
Self::ExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
220
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
221
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
222
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
···
298
| Self::AuthenticationFailed(msg)
299
| Self::InvalidToken(msg)
300
| Self::ExpiredToken(msg)
301
| Self::RepoNotFound(msg)
302
| Self::BlobNotFound(msg)
303
| Self::InvalidHandle(msg)
···
428
message: self.message(),
429
};
430
let mut response = (self.status_code(), Json(body)).into_response();
431
-
if matches!(self, Self::ExpiredToken(_)) {
432
-
response.headers_mut().insert(
433
-
"WWW-Authenticate",
434
-
"Bearer error=\"invalid_token\", error_description=\"Token has expired\""
435
-
.parse()
436
-
.unwrap(),
437
-
);
438
}
439
response
440
}
···
457
Self::AuthenticationFailed(None)
458
}
459
crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None),
460
}
461
}
462
}
···
22
InvalidRequest(String),
23
InvalidToken(Option<String>),
24
ExpiredToken(Option<String>),
25
+
OAuthExpiredToken(Option<String>),
26
TokenRequired,
27
AccountDeactivated,
28
AccountTakedown,
···
128
| Self::InvalidCode(_)
129
| Self::InvalidPassword(_)
130
| Self::InvalidToken(_)
131
+
| Self::PasskeyCounterAnomaly
132
+
| Self::OAuthExpiredToken(_) => StatusCode::UNAUTHORIZED,
133
Self::ExpiredToken(_) => StatusCode::BAD_REQUEST,
134
Self::Forbidden
135
| Self::AdminRequired
···
218
Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"),
219
Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"),
220
Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"),
221
+
Self::ExpiredToken(_) | Self::OAuthExpiredToken(_) => Cow::Borrowed("ExpiredToken"),
222
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
223
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
224
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
···
300
| Self::AuthenticationFailed(msg)
301
| Self::InvalidToken(msg)
302
| Self::ExpiredToken(msg)
303
+
| Self::OAuthExpiredToken(msg)
304
| Self::RepoNotFound(msg)
305
| Self::BlobNotFound(msg)
306
| Self::InvalidHandle(msg)
···
431
message: self.message(),
432
};
433
let mut response = (self.status_code(), Json(body)).into_response();
434
+
match &self {
435
+
Self::ExpiredToken(_) => {
436
+
response.headers_mut().insert(
437
+
"WWW-Authenticate",
438
+
"Bearer error=\"invalid_token\", error_description=\"Token has expired\""
439
+
.parse()
440
+
.unwrap(),
441
+
);
442
+
}
443
+
Self::OAuthExpiredToken(_) => {
444
+
response.headers_mut().insert(
445
+
"WWW-Authenticate",
446
+
"DPoP error=\"invalid_token\", error_description=\"Token has expired\""
447
+
.parse()
448
+
.unwrap(),
449
+
);
450
+
}
451
+
_ => {}
452
}
453
response
454
}
···
471
Self::AuthenticationFailed(None)
472
}
473
crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None),
474
+
crate::auth::TokenValidationError::OAuthTokenExpired => {
475
+
Self::OAuthExpiredToken(Some("Token has expired".to_string()))
476
+
}
477
}
478
}
479
}
+1
-1
crates/tranquil-pds/src/api/moderation/mod.rs
+1
-1
crates/tranquil-pds/src/api/moderation/mod.rs
+14
-25
crates/tranquil-pds/src/api/proxy.rs
+14
-25
crates/tranquil-pds/src/api/proxy.rs
···
268
}
269
Err(e) => {
270
warn!("Token validation failed: {:?}", e);
271
-
if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop
272
-
{
273
-
let www_auth =
274
-
"DPoP error=\"invalid_token\", error_description=\"Token has expired\"";
275
-
let mut response =
276
-
ApiError::ExpiredToken(Some("Token has expired".into())).into_response();
277
-
*response.status_mut() = axum::http::StatusCode::UNAUTHORIZED;
278
-
response
279
-
.headers_mut()
280
-
.insert("WWW-Authenticate", www_auth.parse().unwrap());
281
-
let nonce = crate::oauth::verify::generate_dpop_nonce();
282
-
response
283
-
.headers_mut()
284
-
.insert("DPoP-Nonce", nonce.parse().unwrap());
285
-
return response;
286
}
287
}
288
}
···
291
if let Some(val) = auth_header_val {
292
request_builder = request_builder.header("Authorization", val);
293
}
294
-
for header_name in crate::api::proxy_client::HEADERS_TO_FORWARD {
295
-
if let Some(val) = headers.get(*header_name) {
296
-
request_builder = request_builder.header(*header_name, val);
297
-
}
298
-
}
299
if !body.is_empty() {
300
request_builder = request_builder.body(body);
301
}
···
313
}
314
};
315
let mut response_builder = Response::builder().status(status);
316
-
for header_name in crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD {
317
-
if let Some(val) = headers.get(*header_name) {
318
-
response_builder = response_builder.header(*header_name, val);
319
-
}
320
-
}
321
match response_builder.body(axum::body::Body::from(body)) {
322
Ok(r) => r,
323
Err(e) => {
···
268
}
269
Err(e) => {
270
warn!("Token validation failed: {:?}", e);
271
+
if matches!(e, crate::auth::TokenValidationError::OAuthTokenExpired) {
272
+
return ApiError::from(e).into_response();
273
}
274
}
275
}
···
278
if let Some(val) = auth_header_val {
279
request_builder = request_builder.header("Authorization", val);
280
}
281
+
request_builder = crate::api::proxy_client::HEADERS_TO_FORWARD
282
+
.iter()
283
+
.filter_map(|name| headers.get(*name).map(|val| (*name, val)))
284
+
.fold(request_builder, |builder, (name, val)| {
285
+
builder.header(name, val)
286
+
});
287
if !body.is_empty() {
288
request_builder = request_builder.body(body);
289
}
···
301
}
302
};
303
let mut response_builder = Response::builder().status(status);
304
+
response_builder = crate::api::proxy_client::RESPONSE_HEADERS_TO_FORWARD
305
+
.iter()
306
+
.filter_map(|name| headers.get(*name).map(|val| (*name, val)))
307
+
.fold(response_builder, |builder, (name, val)| {
308
+
builder.header(name, val)
309
+
});
310
match response_builder.body(axum::body::Body::from(body)) {
311
Ok(r) => r,
312
Err(e) => {
+7
-9
crates/tranquil-pds/src/api/proxy_client.rs
+7
-9
crates/tranquil-pds/src/api/proxy_client.rs
···
88
Ok(addrs) => addrs.collect(),
89
Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())),
90
};
91
-
for addr in &socket_addrs {
92
-
if !is_unicast_ip(&addr.ip()) {
93
-
warn!(
94
-
"DNS resolution for {} returned non-unicast IP: {}",
95
-
host,
96
-
addr.ip()
97
-
);
98
-
return Err(SsrfError::NonUnicastIp(addr.ip().to_string()));
99
-
}
100
}
101
Ok(())
102
}
···
88
Ok(addrs) => addrs.collect(),
89
Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())),
90
};
91
+
if let Some(addr) = socket_addrs.iter().find(|addr| !is_unicast_ip(&addr.ip())) {
92
+
warn!(
93
+
"DNS resolution for {} returned non-unicast IP: {}",
94
+
host,
95
+
addr.ip()
96
+
);
97
+
return Err(SsrfError::NonUnicastIp(addr.ip().to_string()));
98
}
99
Ok(())
100
}
+1
-13
crates/tranquil-pds/src/api/repo/record/write.rs
+1
-13
crates/tranquil-pds/src/api/repo/record/write.rs
···
82
.await
83
.map_err(|e| {
84
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
85
-
let mut response = ApiError::from(e).into_response();
86
-
if matches!(e, crate::auth::TokenValidationError::TokenExpired) && extracted.is_dpop {
87
-
*response.status_mut() = axum::http::StatusCode::UNAUTHORIZED;
88
-
let www_auth =
89
-
"DPoP error=\"invalid_token\", error_description=\"Token has expired\"";
90
-
response.headers_mut().insert(
91
-
"WWW-Authenticate",
92
-
www_auth.parse().unwrap(),
93
-
);
94
-
let nonce = crate::oauth::verify::generate_dpop_nonce();
95
-
response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap());
96
-
}
97
-
response
98
})?;
99
if repo.as_str() != auth_user.did.as_str() {
100
return Err(
+30
-38
crates/tranquil-pds/src/api/validation.rs
+30
-38
crates/tranquil-pds/src/api/validation.rs
···
181
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
182
return Err(EmailValidationError::InvalidLocalPart);
183
}
184
-
for c in local.chars() {
185
-
if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) {
186
-
return Err(EmailValidationError::InvalidLocalPart);
187
-
}
188
}
189
if domain.is_empty() {
190
return Err(EmailValidationError::EmptyDomain);
···
195
if !domain.contains('.') {
196
return Err(EmailValidationError::MissingDomainDot);
197
}
198
-
for label in domain.split('.') {
199
-
if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH {
200
-
return Err(EmailValidationError::InvalidDomainLabel);
201
-
}
202
-
if label.starts_with('-') || label.ends_with('-') {
203
-
return Err(EmailValidationError::InvalidDomainLabel);
204
-
}
205
-
for c in label.chars() {
206
-
if !c.is_ascii_alphanumeric() && c != '-' {
207
-
return Err(EmailValidationError::InvalidDomainLabel);
208
-
}
209
-
}
210
}
211
Ok(())
212
}
···
293
return Err(HandleValidationError::EndsWithInvalidChar);
294
}
295
296
-
for c in handle.chars() {
297
-
if !c.is_ascii_alphanumeric() && c != '-' {
298
-
return Err(HandleValidationError::InvalidCharacters);
299
-
}
300
}
301
302
if crate::moderation::has_explicit_slur(handle) {
···
330
if local.contains("..") {
331
return false;
332
}
333
-
for c in local.chars() {
334
-
if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) {
335
-
return false;
336
-
}
337
}
338
if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH {
339
return false;
···
341
if !domain.contains('.') {
342
return false;
343
}
344
-
for label in domain.split('.') {
345
-
if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH {
346
-
return false;
347
-
}
348
-
if label.starts_with('-') || label.ends_with('-') {
349
-
return false;
350
-
}
351
-
for c in label.chars() {
352
-
if !c.is_ascii_alphanumeric() && c != '-' {
353
-
return false;
354
-
}
355
-
}
356
-
}
357
-
true
358
}
359
360
#[cfg(test)]
···
181
if local.starts_with('.') || local.ends_with('.') || local.contains("..") {
182
return Err(EmailValidationError::InvalidLocalPart);
183
}
184
+
if !local
185
+
.chars()
186
+
.all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c))
187
+
{
188
+
return Err(EmailValidationError::InvalidLocalPart);
189
}
190
if domain.is_empty() {
191
return Err(EmailValidationError::EmptyDomain);
···
196
if !domain.contains('.') {
197
return Err(EmailValidationError::MissingDomainDot);
198
}
199
+
if !domain.split('.').all(|label| {
200
+
!label.is_empty()
201
+
&& label.len() <= MAX_DOMAIN_LABEL_LENGTH
202
+
&& !label.starts_with('-')
203
+
&& !label.ends_with('-')
204
+
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
205
+
}) {
206
+
return Err(EmailValidationError::InvalidDomainLabel);
207
}
208
Ok(())
209
}
···
290
return Err(HandleValidationError::EndsWithInvalidChar);
291
}
292
293
+
if !handle
294
+
.chars()
295
+
.all(|c| c.is_ascii_alphanumeric() || c == '-')
296
+
{
297
+
return Err(HandleValidationError::InvalidCharacters);
298
}
299
300
if crate::moderation::has_explicit_slur(handle) {
···
328
if local.contains("..") {
329
return false;
330
}
331
+
if !local
332
+
.chars()
333
+
.all(|c| c.is_ascii_alphanumeric() || EMAIL_LOCAL_SPECIAL_CHARS.contains(c))
334
+
{
335
+
return false;
336
}
337
if domain.is_empty() || domain.len() > MAX_DOMAIN_LENGTH {
338
return false;
···
340
if !domain.contains('.') {
341
return false;
342
}
343
+
domain.split('.').all(|label| {
344
+
!label.is_empty()
345
+
&& label.len() <= MAX_DOMAIN_LABEL_LENGTH
346
+
&& !label.starts_with('-')
347
+
&& !label.ends_with('-')
348
+
&& label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
349
+
})
350
}
351
352
#[cfg(test)]
+5
-2
crates/tranquil-pds/src/auth/mod.rs
+5
-2
crates/tranquil-pds/src/auth/mod.rs
···
61
KeyDecryptionFailed,
62
AuthenticationFailed,
63
TokenExpired,
64
}
65
66
impl fmt::Display for TokenValidationError {
···
70
Self::AccountTakedown => write!(f, "AccountTakedown"),
71
Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"),
72
Self::AuthenticationFailed => write!(f, "AuthenticationFailed"),
73
-
Self::TokenExpired => write!(f, "ExpiredToken"),
74
}
75
}
76
}
···
497
controller_did: None,
498
})
499
}
500
-
Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired),
501
Err(_) => Err(TokenValidationError::AuthenticationFailed),
502
}
503
}
···
61
KeyDecryptionFailed,
62
AuthenticationFailed,
63
TokenExpired,
64
+
OAuthTokenExpired,
65
}
66
67
impl fmt::Display for TokenValidationError {
···
71
Self::AccountTakedown => write!(f, "AccountTakedown"),
72
Self::KeyDecryptionFailed => write!(f, "KeyDecryptionFailed"),
73
Self::AuthenticationFailed => write!(f, "AuthenticationFailed"),
74
+
Self::TokenExpired | Self::OAuthTokenExpired => write!(f, "ExpiredToken"),
75
}
76
}
77
}
···
498
controller_did: None,
499
})
500
}
501
+
Err(crate::oauth::OAuthError::ExpiredToken(_)) => {
502
+
Err(TokenValidationError::OAuthTokenExpired)
503
+
}
504
Err(_) => Err(TokenValidationError::AuthenticationFailed),
505
}
506
}
+21
-18
crates/tranquil-pds/src/util.rs
+21
-18
crates/tranquil-pds/src/util.rs
···
106
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
107
query
108
.map(|q| {
109
-
let mut values = Vec::new();
110
-
for pair in q.split('&') {
111
-
if let Some((k, v)) = pair.split_once('=')
112
-
&& k == key
113
-
&& let Ok(decoded) = urlencoding::decode(v)
114
-
{
115
-
let decoded = decoded.into_owned();
116
if decoded.contains(',') {
117
-
for part in decoded.split(',') {
118
-
let trimmed = part.trim();
119
-
if !trimmed.is_empty() {
120
-
values.push(trimmed.to_string());
121
-
}
122
-
}
123
-
} else if !decoded.is_empty() {
124
-
values.push(decoded);
125
}
126
-
}
127
-
}
128
-
values
129
})
130
.unwrap_or_default()
131
}
···
106
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
107
query
108
.map(|q| {
109
+
q.split('&')
110
+
.filter_map(|pair| {
111
+
pair.split_once('=')
112
+
.filter(|(k, _)| *k == key)
113
+
.and_then(|(_, v)| urlencoding::decode(v).ok())
114
+
.map(|decoded| decoded.into_owned())
115
+
})
116
+
.flat_map(|decoded| {
117
if decoded.contains(',') {
118
+
decoded
119
+
.split(',')
120
+
.filter_map(|part| {
121
+
let trimmed = part.trim();
122
+
(!trimmed.is_empty()).then(|| trimmed.to_string())
123
+
})
124
+
.collect::<Vec<_>>()
125
+
} else if decoded.is_empty() {
126
+
vec![]
127
+
} else {
128
+
vec![decoded]
129
}
130
+
})
131
+
.collect()
132
})
133
.unwrap_or_default()
134
}
+1
-1
crates/tranquil-pds/tests/common/mod.rs
+1
-1
crates/tranquil-pds/tests/common/mod.rs
+11
-7
crates/tranquil-pds/tests/helpers/mod.rs
+11
-7
crates/tranquil-pds/tests/helpers/mod.rs
···
4
5
pub use crate::common::*;
6
7
#[allow(dead_code)]
8
pub async fn setup_new_user(handle_prefix: &str) -> (String, String) {
9
let client = client();
10
-
let ts = Utc::now().timestamp_millis();
11
-
let handle = format!("{}-{}.test", handle_prefix, ts);
12
-
let email = format!("{}-{}@test.com", handle_prefix, ts);
13
let password = "E2epass123!";
14
let create_account_payload = json!({
15
"handle": handle,
···
51
text: &str,
52
) -> (String, String) {
53
let collection = "app.bsky.feed.post";
54
-
let rkey = format!("e2e_social_{}", Utc::now().timestamp_millis());
55
let now = Utc::now().to_rfc3339();
56
let create_payload = json!({
57
"repo": did,
···
95
followee_did: &str,
96
) -> (String, String) {
97
let collection = "app.bsky.graph.follow";
98
-
let rkey = format!("e2e_follow_{}", Utc::now().timestamp_millis());
99
let now = Utc::now().to_rfc3339();
100
let create_payload = json!({
101
"repo": follower_did,
···
140
subject_cid: &str,
141
) -> (String, String) {
142
let collection = "app.bsky.feed.like";
143
-
let rkey = format!("e2e_like_{}", Utc::now().timestamp_millis());
144
let now = Utc::now().to_rfc3339();
145
let payload = json!({
146
"repo": liker_did,
···
182
subject_cid: &str,
183
) -> (String, String) {
184
let collection = "app.bsky.feed.repost";
185
-
let rkey = format!("e2e_repost_{}", Utc::now().timestamp_millis());
186
let now = Utc::now().to_rfc3339();
187
let payload = json!({
188
"repo": reposter_did,
···
4
5
pub use crate::common::*;
6
7
+
fn unique_id() -> String {
8
+
uuid::Uuid::new_v4().simple().to_string()[..12].to_string()
9
+
}
10
+
11
#[allow(dead_code)]
12
pub async fn setup_new_user(handle_prefix: &str) -> (String, String) {
13
let client = client();
14
+
let uid = unique_id();
15
+
let handle = format!("{}-{}.test", handle_prefix, uid);
16
+
let email = format!("{}-{}@test.com", handle_prefix, uid);
17
let password = "E2epass123!";
18
let create_account_payload = json!({
19
"handle": handle,
···
55
text: &str,
56
) -> (String, String) {
57
let collection = "app.bsky.feed.post";
58
+
let rkey = format!("e2e_social_{}", unique_id());
59
let now = Utc::now().to_rfc3339();
60
let create_payload = json!({
61
"repo": did,
···
99
followee_did: &str,
100
) -> (String, String) {
101
let collection = "app.bsky.graph.follow";
102
+
let rkey = format!("e2e_follow_{}", unique_id());
103
let now = Utc::now().to_rfc3339();
104
let create_payload = json!({
105
"repo": follower_did,
···
144
subject_cid: &str,
145
) -> (String, String) {
146
let collection = "app.bsky.feed.like";
147
+
let rkey = format!("e2e_like_{}", unique_id());
148
let now = Utc::now().to_rfc3339();
149
let payload = json!({
150
"repo": liker_did,
···
186
subject_cid: &str,
187
) -> (String, String) {
188
let collection = "app.bsky.feed.repost";
189
+
let rkey = format!("e2e_repost_{}", unique_id());
190
let now = Utc::now().to_rfc3339();
191
let payload = json!({
192
"repo": reposter_did,
+47
-48
crates/tranquil-scopes/src/parser.rs
+47
-48
crates/tranquil-scopes/src/parser.rs
···
55
if self.accept.is_empty() || self.accept.contains("*/*") {
56
return true;
57
}
58
-
for pattern in &self.accept {
59
-
if pattern == mime {
60
-
return true;
61
-
}
62
-
if let Some(prefix) = pattern.strip_suffix("/*")
63
-
&& mime.starts_with(prefix)
64
-
&& mime.chars().nth(prefix.len()) == Some('/')
65
-
{
66
-
return true;
67
-
}
68
-
}
69
-
false
70
}
71
}
72
···
170
Some(rest.to_string())
171
};
172
173
-
let mut actions = HashSet::new();
174
-
if let Some(action_values) = params.get("action") {
175
-
for action_str in action_values {
176
-
if let Some(action) = RepoAction::parse_str(action_str) {
177
-
actions.insert(action);
178
-
}
179
-
}
180
-
}
181
-
if actions.is_empty() {
182
-
actions.insert(RepoAction::Create);
183
-
actions.insert(RepoAction::Update);
184
-
actions.insert(RepoAction::Delete);
185
-
}
186
187
return ParsedScope::Repo(RepoScope {
188
collection,
···
191
}
192
193
if base == "repo" {
194
-
let mut actions = HashSet::new();
195
-
if let Some(action_values) = params.get("action") {
196
-
for action_str in action_values {
197
-
if let Some(action) = RepoAction::parse_str(action_str) {
198
-
actions.insert(action);
199
-
}
200
-
}
201
-
}
202
-
if actions.is_empty() {
203
-
actions.insert(RepoAction::Create);
204
-
actions.insert(RepoAction::Update);
205
-
actions.insert(RepoAction::Delete);
206
-
}
207
return ParsedScope::Repo(RepoScope {
208
collection: None,
209
actions,
···
212
213
if base.starts_with("blob") {
214
let positional = base.strip_prefix("blob:").unwrap_or("");
215
-
let mut accept = HashSet::new();
216
-
217
-
if !positional.is_empty() {
218
-
accept.insert(positional.to_string());
219
-
}
220
-
if let Some(accept_values) = params.get("accept") {
221
-
for v in accept_values {
222
-
accept.insert(v.to_string());
223
-
}
224
-
}
225
226
return ParsedScope::Blob(BlobScope { accept });
227
}
···
55
if self.accept.is_empty() || self.accept.contains("*/*") {
56
return true;
57
}
58
+
self.accept.iter().any(|pattern| {
59
+
pattern == mime
60
+
|| pattern
61
+
.strip_suffix("/*")
62
+
.is_some_and(|prefix| {
63
+
mime.starts_with(prefix) && mime.chars().nth(prefix.len()) == Some('/')
64
+
})
65
+
})
66
}
67
}
68
···
166
Some(rest.to_string())
167
};
168
169
+
let actions: HashSet<RepoAction> = params
170
+
.get("action")
171
+
.map(|action_values| {
172
+
action_values
173
+
.iter()
174
+
.filter_map(|s| RepoAction::parse_str(s))
175
+
.collect()
176
+
})
177
+
.filter(|set: &HashSet<RepoAction>| !set.is_empty())
178
+
.unwrap_or_else(|| {
179
+
[RepoAction::Create, RepoAction::Update, RepoAction::Delete]
180
+
.into_iter()
181
+
.collect()
182
+
});
183
184
return ParsedScope::Repo(RepoScope {
185
collection,
···
188
}
189
190
if base == "repo" {
191
+
let actions: HashSet<RepoAction> = params
192
+
.get("action")
193
+
.map(|action_values| {
194
+
action_values
195
+
.iter()
196
+
.filter_map(|s| RepoAction::parse_str(s))
197
+
.collect()
198
+
})
199
+
.filter(|set: &HashSet<RepoAction>| !set.is_empty())
200
+
.unwrap_or_else(|| {
201
+
[RepoAction::Create, RepoAction::Update, RepoAction::Delete]
202
+
.into_iter()
203
+
.collect()
204
+
});
205
return ParsedScope::Repo(RepoScope {
206
collection: None,
207
actions,
···
210
211
if base.starts_with("blob") {
212
let positional = base.strip_prefix("blob:").unwrap_or("");
213
+
let accept: HashSet<String> = std::iter::once(positional)
214
+
.filter(|s| !s.is_empty())
215
+
.map(String::from)
216
+
.chain(
217
+
params
218
+
.get("accept")
219
+
.into_iter()
220
+
.flatten()
221
+
.map(String::clone),
222
+
)
223
+
.collect();
224
225
return ParsedScope::Blob(BlobScope { accept });
226
}
+78
-78
crates/tranquil-scopes/src/permissions.rs
+78
-78
crates/tranquil-scopes/src/permissions.rs
···
113
return Ok(());
114
}
115
116
-
for repo_scope in self.find_repo_scopes() {
117
-
if !repo_scope.actions.contains(&action) {
118
-
continue;
119
-
}
120
-
121
-
match &repo_scope.collection {
122
-
None => return Ok(()),
123
-
Some(coll) if coll == collection => return Ok(()),
124
-
Some(coll) if coll.ends_with(".*") => {
125
-
let prefix = coll.strip_suffix(".*").unwrap();
126
-
if collection.starts_with(prefix)
127
-
&& collection.chars().nth(prefix.len()) == Some('.')
128
-
{
129
-
return Ok(());
130
}
131
}
132
-
_ => {}
133
-
}
134
-
}
135
136
-
Err(ScopeError::InsufficientScope {
137
-
required: format!("repo:{}?action={}", collection, action_str(action)),
138
-
message: format!(
139
-
"Insufficient scope to {} records in {}",
140
-
action_str(action),
141
-
collection
142
-
),
143
-
})
144
}
145
146
pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> {
···
148
return Ok(());
149
}
150
151
-
for blob_scope in self.find_blob_scopes() {
152
-
if blob_scope.matches_mime(mime) {
153
-
return Ok(());
154
-
}
155
}
156
-
157
-
Err(ScopeError::InsufficientScope {
158
-
required: format!("blob:{}", mime),
159
-
message: format!("Insufficient scope to upload blob with mime type {}", mime),
160
-
})
161
}
162
163
pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> {
···
169
return Ok(());
170
}
171
172
-
for rpc_scope in self.find_rpc_scopes() {
173
let lxm_matches = match &rpc_scope.lxm {
174
None => true,
175
Some(scope_lxm) if scope_lxm == lxm => true,
···
186
Some(scope_aud) => scope_aud == aud,
187
};
188
189
-
if lxm_matches && aud_matches {
190
-
return Ok(());
191
-
}
192
-
}
193
194
-
Err(ScopeError::InsufficientScope {
195
-
required: format!("rpc:{}?aud={}", lxm, aud),
196
-
message: format!("Insufficient scope to call {} on {}", lxm, aud),
197
-
})
198
}
199
200
pub fn assert_account(
···
211
return Ok(());
212
}
213
214
-
for account_scope in self.find_account_scopes() {
215
-
if account_scope.attr == attr && account_scope.action == action {
216
-
return Ok(());
217
-
}
218
-
if account_scope.attr == attr && account_scope.action == AccountAction::Manage {
219
-
return Ok(());
220
-
}
221
-
}
222
223
-
Err(ScopeError::InsufficientScope {
224
-
required: format!(
225
-
"account:{}?action={}",
226
-
attr_str(attr),
227
-
action_str_account(action)
228
-
),
229
-
message: format!(
230
-
"Insufficient scope to {} account {}",
231
-
action_str_account(action),
232
-
attr_str(attr)
233
-
),
234
-
})
235
}
236
237
pub fn allows_email_read(&self) -> bool {
···
264
return Ok(());
265
}
266
267
-
for identity_scope in self.find_identity_scopes() {
268
-
if identity_scope.attr == IdentityAttr::Wildcard {
269
-
return Ok(());
270
-
}
271
-
if identity_scope.attr == attr {
272
-
return Ok(());
273
-
}
274
}
275
-
276
-
Err(ScopeError::InsufficientScope {
277
-
required: format!("identity:{}", identity_attr_str(attr)),
278
-
message: format!(
279
-
"Insufficient scope to modify identity {}",
280
-
identity_attr_str(attr)
281
-
),
282
-
})
283
}
284
285
pub fn allows_identity(&self, attr: IdentityAttr) -> bool {
···
113
return Ok(());
114
}
115
116
+
let has_permission = self.find_repo_scopes().any(|repo_scope| {
117
+
repo_scope.actions.contains(&action)
118
+
&& match &repo_scope.collection {
119
+
None => true,
120
+
Some(coll) if coll == collection => true,
121
+
Some(coll) if coll.ends_with(".*") => {
122
+
let prefix = coll.strip_suffix(".*").unwrap();
123
+
collection.starts_with(prefix)
124
+
&& collection.chars().nth(prefix.len()) == Some('.')
125
}
126
+
_ => false,
127
}
128
+
});
129
130
+
if has_permission {
131
+
Ok(())
132
+
} else {
133
+
Err(ScopeError::InsufficientScope {
134
+
required: format!("repo:{}?action={}", collection, action_str(action)),
135
+
message: format!(
136
+
"Insufficient scope to {} records in {}",
137
+
action_str(action),
138
+
collection
139
+
),
140
+
})
141
+
}
142
}
143
144
pub fn assert_blob(&self, mime: &str) -> Result<(), ScopeError> {
···
146
return Ok(());
147
}
148
149
+
if self.find_blob_scopes().any(|blob_scope| blob_scope.matches_mime(mime)) {
150
+
Ok(())
151
+
} else {
152
+
Err(ScopeError::InsufficientScope {
153
+
required: format!("blob:{}", mime),
154
+
message: format!("Insufficient scope to upload blob with mime type {}", mime),
155
+
})
156
}
157
}
158
159
pub fn assert_rpc(&self, aud: &str, lxm: &str) -> Result<(), ScopeError> {
···
165
return Ok(());
166
}
167
168
+
let has_permission = self.find_rpc_scopes().any(|rpc_scope| {
169
let lxm_matches = match &rpc_scope.lxm {
170
None => true,
171
Some(scope_lxm) if scope_lxm == lxm => true,
···
182
Some(scope_aud) => scope_aud == aud,
183
};
184
185
+
lxm_matches && aud_matches
186
+
});
187
188
+
if has_permission {
189
+
Ok(())
190
+
} else {
191
+
Err(ScopeError::InsufficientScope {
192
+
required: format!("rpc:{}?aud={}", lxm, aud),
193
+
message: format!("Insufficient scope to call {} on {}", lxm, aud),
194
+
})
195
+
}
196
}
197
198
pub fn assert_account(
···
209
return Ok(());
210
}
211
212
+
let has_permission = self.find_account_scopes().any(|account_scope| {
213
+
account_scope.attr == attr
214
+
&& (account_scope.action == action
215
+
|| account_scope.action == AccountAction::Manage)
216
+
});
217
218
+
if has_permission {
219
+
Ok(())
220
+
} else {
221
+
Err(ScopeError::InsufficientScope {
222
+
required: format!(
223
+
"account:{}?action={}",
224
+
attr_str(attr),
225
+
action_str_account(action)
226
+
),
227
+
message: format!(
228
+
"Insufficient scope to {} account {}",
229
+
action_str_account(action),
230
+
attr_str(attr)
231
+
),
232
+
})
233
+
}
234
}
235
236
pub fn allows_email_read(&self) -> bool {
···
263
return Ok(());
264
}
265
266
+
let has_permission = self
267
+
.find_identity_scopes()
268
+
.any(|identity_scope| {
269
+
identity_scope.attr == IdentityAttr::Wildcard || identity_scope.attr == attr
270
+
});
271
+
272
+
if has_permission {
273
+
Ok(())
274
+
} else {
275
+
Err(ScopeError::InsufficientScope {
276
+
required: format!("identity:{}", identity_attr_str(attr)),
277
+
message: format!(
278
+
"Insufficient scope to modify identity {}",
279
+
identity_attr_str(attr)
280
+
),
281
+
})
282
}
283
}
284
285
pub fn allows_identity(&self, attr: IdentityAttr) -> bool {