Skip to main content

kcode_k1_http_persons/
lib.rs

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}