1use std::sync::Arc;
2
3use axum::{
4 Json, Router,
5 body::Bytes,
6 extract::{Extension, Path},
7 http::{HeaderMap, HeaderValue, StatusCode, header},
8 response::{IntoResponse, Response},
9 routing::{get, post, put},
10};
11use kcode_k1_access::{
12 AccessCheck, AccessId, K1Access, ModelId, RequestPrincipal, SubsystemId, TxId, UserId,
13};
14use kcode_k1_access_persons::{K1AccessPersons, PersonId, ProfileSelection};
15use kcode_k1_access_profiles::ProfileId;
16use kcode_k1_http::Principal as HttpPrincipal;
17use serde::{Deserialize, Serialize};
18
19const PERSON_SUBSYSTEM: &str = "k1-person";
20
21pub fn authenticated_routes(
22 persons: Arc<K1AccessPersons>,
23 access: Arc<K1Access>,
24 model: ModelId,
25) -> Result<Router<()>, String> {
26 let subsystem = SubsystemId::from_str(PERSON_SUBSYSTEM)?;
27 Ok(Router::new()
28 .route("/persons", get(list_persons))
29 .route("/persons/{person_id}", get(get_person))
30 .route(
31 "/persons/{person_id}/access/{access_id}",
32 put(update_person),
33 )
34 .route("/persons/profiles/{profile_id}", post(create_person))
35 .layer(Extension(AppState {
36 persons,
37 access,
38 model,
39 subsystem,
40 })))
41}
42
43#[derive(Clone)]
44struct AppState {
45 persons: Arc<K1AccessPersons>,
46 access: Arc<K1Access>,
47 model: ModelId,
48 subsystem: SubsystemId,
49}
50
51#[derive(Deserialize)]
52#[serde(deny_unknown_fields)]
53struct NameInput {
54 name: String,
55}
56
57#[derive(Serialize, PartialEq, Debug)]
58struct PersonDto {
59 person_id: String,
60 name: String,
61}
62
63#[derive(Serialize, PartialEq, Debug)]
64struct SubmittedPersonDto {
65 person_id: String,
66 access_id: String,
67}
68
69#[derive(Serialize, PartialEq, Debug)]
70struct ListedPersonDto {
71 person_id: String,
72 access_id: String,
73 name: String,
74}
75
76#[derive(Serialize)]
77struct ErrorDto {
78 error: &'static str,
79 message: &'static str,
80}
81
82#[derive(Clone, Copy, Debug, Eq, PartialEq)]
83enum ApiError {
84 InvalidPersonId,
85 InvalidAccessId,
86 InvalidProfileId,
87 InvalidJson,
88 InvalidPersonName,
89 UnsupportedMediaType,
90 PersonNotFound,
91 ProfileNotFound,
92 PersonUpdateDenied,
93 Unavailable,
94}
95
96impl ApiError {
97 const fn code(self) -> &'static str {
98 match self {
99 Self::InvalidPersonId => "invalid_person_id",
100 Self::InvalidAccessId => "invalid_access_id",
101 Self::InvalidProfileId => "invalid_profile_id",
102 Self::InvalidJson => "invalid_json",
103 Self::InvalidPersonName => "invalid_person_name",
104 Self::UnsupportedMediaType => "unsupported_media_type",
105 Self::PersonNotFound => "person_not_found",
106 Self::ProfileNotFound => "profile_not_found",
107 Self::PersonUpdateDenied => "person_update_denied",
108 Self::Unavailable => "persons_unavailable",
109 }
110 }
111
112 const fn message(self) -> &'static str {
113 match self {
114 Self::InvalidPersonId => "person ID must be lowercase hexadecimal",
115 Self::InvalidAccessId => "access ID must be lowercase hexadecimal",
116 Self::InvalidProfileId => "profile ID must be lowercase hexadecimal",
117 Self::InvalidJson => "request JSON is invalid",
118 Self::InvalidPersonName => "person name is invalid",
119 Self::UnsupportedMediaType => "content type must be application/json",
120 Self::PersonNotFound => "person is not available",
121 Self::ProfileNotFound => "profile is not available",
122 Self::PersonUpdateDenied => "person update is not authorized",
123 Self::Unavailable => "persons service is unavailable",
124 }
125 }
126
127 const fn status(self) -> StatusCode {
128 match self {
129 Self::InvalidPersonId
130 | Self::InvalidAccessId
131 | Self::InvalidProfileId
132 | Self::InvalidJson
133 | Self::InvalidPersonName => StatusCode::BAD_REQUEST,
134 Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
135 Self::PersonNotFound | Self::ProfileNotFound => StatusCode::NOT_FOUND,
136 Self::PersonUpdateDenied => StatusCode::FORBIDDEN,
137 Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
138 }
139 }
140}
141
142impl IntoResponse for ApiError {
143 fn into_response(self) -> Response {
144 json_response(
145 self.status(),
146 ErrorDto {
147 error: self.code(),
148 message: self.message(),
149 },
150 )
151 }
152}
153
154async fn create_person(
155 Extension(http_principal): Extension<HttpPrincipal>,
156 Path(profile_id): Path<String>,
157 headers: HeaderMap,
158 Extension(state): Extension<AppState>,
159 body: Bytes,
160) -> Response {
161 if !is_json_content_type(&headers) {
162 return ApiError::UnsupportedMediaType.into_response();
163 }
164 let profile_id = match parse_profile_id(&profile_id) {
165 Ok(id) => id,
166 Err(error) => return error.into_response(),
167 };
168 let input = match parse_name(&body) {
169 Ok(input) => input,
170 Err(error) => return error.into_response(),
171 };
172 let user = user_id(&http_principal);
173 let persons = state.persons;
174 let model = state.model;
175 match spawn_facade(move || {
176 persons
177 .create(
178 RequestPrincipal::new(user, model),
179 ProfileSelection::Saved(profile_id),
180 input.name,
181 )
182 .map(|submitted| SubmittedPersonDto {
183 person_id: submitted.person_id.to_string(),
184 access_id: submitted.access_id.txid().to_string(),
185 })
186 .map_err(|error| {
187 if is_profile_unavailable(&error) {
188 ApiError::ProfileNotFound
189 } else {
190 ApiError::Unavailable
191 }
192 })
193 })
194 .await
195 {
196 Ok(dto) => json_response(StatusCode::CREATED, dto),
197 Err(error) => error.into_response(),
198 }
199}
200
201async fn get_person(
202 Extension(_http_principal): Extension<HttpPrincipal>,
203 Path(person_id): Path<String>,
204 Extension(state): Extension<AppState>,
205) -> Response {
206 let person_id = match parse_person_id(&person_id) {
207 Ok(id) => id,
208 Err(error) => return error.into_response(),
209 };
210 let persons = state.persons;
211 match spawn_facade(move || {
212 persons
213 .read_person(person_id)
214 .map(|view| PersonDto {
215 person_id: view.person_id.to_string(),
216 name: view.name,
217 })
218 .map_err(|error| {
219 if is_person_missing(&error) {
220 ApiError::PersonNotFound
221 } else {
222 ApiError::Unavailable
223 }
224 })
225 })
226 .await
227 {
228 Ok(dto) => json_response(StatusCode::OK, dto),
229 Err(error) => error.into_response(),
230 }
231}
232
233async fn update_person(
234 Extension(http_principal): Extension<HttpPrincipal>,
235 Path((person_id, access_id)): Path<(String, String)>,
236 headers: HeaderMap,
237 Extension(state): Extension<AppState>,
238 body: Bytes,
239) -> Response {
240 if !is_json_content_type(&headers) {
241 return ApiError::UnsupportedMediaType.into_response();
242 }
243 let person_id = match parse_person_id(&person_id) {
244 Ok(id) => id,
245 Err(error) => return error.into_response(),
246 };
247 let access_id = match parse_access_id(&access_id) {
248 Ok(id) => id,
249 Err(error) => return error.into_response(),
250 };
251 let input = match parse_name(&body) {
252 Ok(input) => input,
253 Err(error) => return error.into_response(),
254 };
255 let user = user_id(&http_principal);
256 match spawn_facade(move || {
257 update_managed_person(&state, user, person_id, access_id, input.name)
258 })
259 .await
260 {
261 Ok(()) => empty_response(StatusCode::NO_CONTENT),
262 Err(error) => error.into_response(),
263 }
264}
265
266async fn list_persons(
267 Extension(http_principal): Extension<HttpPrincipal>,
268 Extension(state): Extension<AppState>,
269) -> Response {
270 let user = user_id(&http_principal);
271 match spawn_facade(move || list_manageable(&state, user)).await {
272 Ok(dto) => json_response(StatusCode::OK, dto),
273 Err(error) => error.into_response(),
274 }
275}
276
277fn update_managed_person(
278 state: &AppState,
279 user: UserId,
280 person_id: PersonId,
281 access_id: AccessId,
282 name: String,
283) -> Result<(), ApiError> {
284 state
285 .persons
286 .update_person(
287 RequestPrincipal::new(user, state.model),
288 person_id,
289 access_id,
290 name,
291 )
292 .map_err(|error| update_error(&error))
293}
294
295fn list_manageable(state: &AppState, user: UserId) -> Result<Vec<ListedPersonDto>, ApiError> {
296 let access_ids = state
297 .access
298 .list_user(RequestPrincipal::new(user, state.model), state.subsystem)
299 .map_err(|_| ApiError::Unavailable)?;
300 let mut listed = Vec::new();
301 for access_id in access_ids {
302 let check = state
303 .access
304 .check(
305 RequestPrincipal::new(user, state.model),
306 access_id,
307 state.subsystem,
308 )
309 .map_err(|_| ApiError::Unavailable)?;
310 if let Some(dto) = managed_list_item(access_id, &check, &state.persons, &state.subsystem)? {
311 listed.push(dto);
312 }
313 }
314 Ok(listed)
315}
316
317fn managed_list_item(
318 access_id: AccessId,
319 check: &AccessCheck,
320 persons: &K1AccessPersons,
321 subsystem: &SubsystemId,
322) -> Result<Option<ListedPersonDto>, ApiError> {
323 if !check.can_view() || !check.can_manage() {
324 return Ok(None);
325 }
326 let Some(target) = check.target() else {
327 return Ok(None);
328 };
329 if target.subsystem() != *subsystem {
330 return Ok(None);
331 }
332 let Ok(bytes) = <[u8; 12]>::try_from(target.object_id()) else {
333 return Ok(None);
334 };
335 let person_id = PersonId::from_tx_id(TxId::from_bytes(bytes));
336 match persons.read_person(person_id) {
337 Ok(view) if view.person_id == person_id => Ok(Some(ListedPersonDto {
338 person_id: view.person_id.to_string(),
339 access_id: access_id.txid().to_string(),
340 name: view.name,
341 })),
342 Ok(_) => Ok(None),
343 Err(error) if is_person_missing(&error) => Ok(None),
344 Err(_) => Err(ApiError::Unavailable),
345 }
346}
347
348fn update_error(error: &str) -> ApiError {
349 match error {
350 "principal must be able to view and manage person"
351 | "person access target is unavailable"
352 | "person access target must contain exactly 12 person bytes"
353 | "person access target is no longer canonical"
354 | "person access target does not match supplied person"
355 | "person is unavailable"
356 | "person is unknown" => ApiError::PersonUpdateDenied,
357 error
358 if error.contains("unavailable")
359 || error.contains("fault")
360 || error.contains("failed") =>
361 {
362 ApiError::Unavailable
363 }
364 _ => ApiError::PersonUpdateDenied,
365 }
366}
367
368fn parse_name(body: &[u8]) -> Result<NameInput, ApiError> {
369 let input = serde_json::from_slice::<NameInput>(body).map_err(|_| ApiError::InvalidJson)?;
370 if input.name.is_empty()
371 || input.name.len() > 128
372 || input.name.chars().any(char::is_control)
373 || !input
374 .name
375 .chars()
376 .any(|character| !character.is_whitespace())
377 {
378 return Err(ApiError::InvalidPersonName);
379 }
380 Ok(input)
381}
382
383fn parse_person_id(value: &str) -> Result<PersonId, ApiError> {
384 lower_hex_id(value)
385 .map(|bytes| PersonId::from_tx_id(TxId::from_bytes(bytes)))
386 .ok_or(ApiError::InvalidPersonId)
387}
388
389fn parse_access_id(value: &str) -> Result<AccessId, ApiError> {
390 lower_hex_id(value)
391 .map(|bytes| AccessId::new(TxId::from_bytes(bytes)))
392 .ok_or(ApiError::InvalidAccessId)
393}
394
395fn parse_profile_id(value: &str) -> Result<ProfileId, ApiError> {
396 lower_hex_id(value)
397 .map(|bytes| ProfileId::new(TxId::from_bytes(bytes)))
398 .ok_or(ApiError::InvalidProfileId)
399}
400
401fn lower_hex_id(value: &str) -> Option<[u8; 12]> {
402 if value.len() != 24
403 || !value
404 .bytes()
405 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
406 {
407 return None;
408 }
409 let mut bytes = [0_u8; 12];
410 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
411 bytes[index] = hex_nibble(pair[0])? << 4 | hex_nibble(pair[1])?;
412 }
413 Some(bytes)
414}
415
416fn hex_nibble(byte: u8) -> Option<u8> {
417 match byte {
418 b'0'..=b'9' => Some(byte - b'0'),
419 b'a'..=b'f' => Some(byte - b'a' + 10),
420 _ => None,
421 }
422}
423
424fn user_id(principal: &HttpPrincipal) -> UserId {
425 UserId::from_tx_id(TxId::from_bytes(*principal.user_id()))
426}
427
428fn is_json_content_type(headers: &HeaderMap) -> bool {
429 let values = headers.get_all(header::CONTENT_TYPE);
430 values.iter().count() == 1
431 && values.iter().next().is_some_and(|value| {
432 value
433 .to_str()
434 .ok()
435 .and_then(|raw| raw.split(';').next())
436 .is_some_and(|media_type| {
437 media_type.trim().eq_ignore_ascii_case("application/json")
438 })
439 })
440}
441
442fn is_person_missing(error: &str) -> bool {
443 error == "person is unavailable" || error == "person is unknown"
444}
445
446fn is_profile_unavailable(error: &str) -> bool {
447 error == "profile is unavailable" || error == "profile not found"
448}
449
450async fn spawn_facade<T: Send + 'static>(
451 work: impl FnOnce() -> Result<T, ApiError> + Send + 'static,
452) -> Result<T, ApiError> {
453 tokio::task::spawn_blocking(work)
454 .await
455 .map_err(|_| ApiError::Unavailable)?
456}
457
458fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
459 let mut response = (status, Json(value)).into_response();
460 response
461 .headers_mut()
462 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
463 response
464}
465
466fn empty_response(status: StatusCode) -> Response {
467 let mut response = status.into_response();
468 response
469 .headers_mut()
470 .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
471 response
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use kcode_k1_access_profiles::{AuthorizationProfile, K1AccessProfiles, ProfileOwner};
478 use kcode_k1_groups::K1Groups;
479 use kcode_k1_peering::K1Peering;
480 use kcode_k1_persons::K1Persons;
481 use kcode_k1_txn_ordering::K1TxnOrdering;
482 use std::{path::Path, sync::Arc};
483 use tempfile::TempDir;
484
485 #[test]
486 fn ids_are_exact_lowercase_hex() {
487 assert_eq!(
488 lower_hex_id("00112233445566778899aabb"),
489 Some([0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187])
490 );
491 assert!(lower_hex_id("00112233445566778899AAbb").is_none());
492 assert!(lower_hex_id("00112233445566778899aab").is_none());
493 assert!(lower_hex_id("00112233445566778899aabg").is_none());
494 }
495
496 #[test]
497 fn dto_serialization_is_stable() {
498 let dto = ListedPersonDto {
499 person_id: "00112233445566778899aabb".into(),
500 access_id: "ffeeddccbbaa998877665544".into(),
501 name: "Ada".into(),
502 };
503 assert_eq!(
504 serde_json::to_string(&dto).unwrap(),
505 "{\"person_id\":\"00112233445566778899aabb\",\"access_id\":\"ffeeddccbbaa998877665544\",\"name\":\"Ada\"}"
506 );
507 }
508
509 #[test]
510 fn errors_are_redacted_and_mapped() {
511 assert_eq!(ApiError::PersonUpdateDenied.code(), "person_update_denied");
512 assert_eq!(ApiError::PersonUpdateDenied.status(), StatusCode::FORBIDDEN);
513 assert_eq!(
514 ApiError::PersonUpdateDenied.message(),
515 "person update is not authorized"
516 );
517 assert_eq!(
518 serde_json::to_string(&ErrorDto {
519 error: ApiError::Unavailable.code(),
520 message: ApiError::Unavailable.message()
521 })
522 .unwrap(),
523 "{\"error\":\"persons_unavailable\",\"message\":\"persons service is unavailable\"}"
524 );
525 assert!(!ApiError::PersonUpdateDenied.message().contains("principal"));
526 }
527
528 #[test]
529 fn update_error_distinguishes_policy_denials_from_facade_unavailability() {
530 assert_eq!(
531 update_error("person access target is unavailable"),
532 ApiError::PersonUpdateDenied
533 );
534 assert_eq!(
535 update_error("dependency facade unavailable"),
536 ApiError::Unavailable
537 );
538 }
539
540 struct Stack {
541 _ordering: Arc<K1TxnOrdering>,
542 _peering: Arc<K1Peering>,
543 state: AppState,
544 }
545
546 impl Stack {
547 fn open(root: &Path) -> Self {
548 let ordering = Arc::new(K1TxnOrdering::open(&root.join("ordering")).unwrap());
549 let peering =
550 Arc::new(K1Peering::open(&root.join("peering"), ordering.clone()).unwrap());
551 let groups = Arc::new(
552 K1Groups::open(&root.join("groups"), ordering.clone(), peering.clone()).unwrap(),
553 );
554 let profiles = Arc::new(
555 K1AccessProfiles::open(&root.join("profiles"), ordering.clone(), peering.clone())
556 .unwrap(),
557 );
558 let persons = Arc::new(
559 K1Persons::open(&root.join("persons"), ordering.clone(), peering.clone()).unwrap(),
560 );
561 let access = Arc::new(
562 K1Access::open(
563 &root.join("access"),
564 ordering.clone(),
565 peering.clone(),
566 groups,
567 )
568 .unwrap(),
569 );
570 let facade =
571 Arc::new(K1AccessPersons::open(access.clone(), profiles, persons).unwrap());
572 Self {
573 _ordering: ordering,
574 _peering: peering,
575 state: AppState {
576 persons: facade,
577 access,
578 model: ModelId::from_bytes([9; 32]),
579 subsystem: SubsystemId::from_str(PERSON_SUBSYSTEM).unwrap(),
580 },
581 }
582 }
583 }
584
585 fn principal(user: u8) -> RequestPrincipal {
586 RequestPrincipal::new(
587 UserId::from_tx_id(TxId::from_bytes([user; 12])),
588 ModelId::from_bytes([9; 32]),
589 )
590 }
591
592 fn inline_owner_profile() -> ProfileSelection {
593 ProfileSelection::Inline(
594 AuthorizationProfile::new(vec![ProfileOwner::RequestUser], vec![]).unwrap(),
595 )
596 }
597
598 #[test]
599 fn list_and_mismatched_update_use_the_real_facades() {
600 let root = TempDir::new().unwrap();
601 let stack = Stack::open(root.path());
602 let owner = principal(1);
603 let unrelated = principal(2);
604 let first = stack
605 .state
606 .persons
607 .create(owner, inline_owner_profile(), "Ada".into())
608 .unwrap();
609 let second = stack
610 .state
611 .persons
612 .create(owner, inline_owner_profile(), "Grace".into())
613 .unwrap();
614 let owner_list = list_manageable(&stack.state, owner.user()).unwrap();
615 assert_eq!(
616 owner_list
617 .iter()
618 .map(|item| (&item.person_id, &item.access_id, &item.name))
619 .collect::<Vec<_>>(),
620 vec![
621 (
622 &first.person_id.to_string(),
623 &first.access_id.txid().to_string(),
624 &"Ada".to_owned()
625 ),
626 (
627 &second.person_id.to_string(),
628 &second.access_id.txid().to_string(),
629 &"Grace".to_owned()
630 )
631 ]
632 );
633 assert!(
634 list_manageable(&stack.state, unrelated.user())
635 .unwrap()
636 .is_empty()
637 );
638 assert_eq!(
639 update_managed_person(
640 &stack.state,
641 owner.user(),
642 first.person_id,
643 second.access_id,
644 "Denied".into()
645 ),
646 Err(ApiError::PersonUpdateDenied)
647 );
648 assert_eq!(
649 stack
650 .state
651 .persons
652 .read_person(first.person_id)
653 .unwrap()
654 .name,
655 "Ada"
656 );
657 assert_eq!(
658 stack
659 .state
660 .persons
661 .read_person(second.person_id)
662 .unwrap()
663 .name,
664 "Grace"
665 );
666 }
667}