use std::sync::Arc;
use axum::{
Json, Router,
body::Bytes,
extract::{Extension, Path},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
routing::{get, post, put},
};
use kcode_k1_access::{
AccessCheck, AccessId, K1Access, ModelId, RequestPrincipal, SubsystemId, TxId, UserId,
};
use kcode_k1_access_persons::{K1AccessPersons, PersonId, ProfileSelection};
use kcode_k1_access_profiles::ProfileId;
use kcode_k1_http::Principal as HttpPrincipal;
use serde::{Deserialize, Serialize};
const PERSON_SUBSYSTEM: &str = "k1-person";
pub fn authenticated_routes(
persons: Arc<K1AccessPersons>,
access: Arc<K1Access>,
model: ModelId,
) -> Result<Router<()>, String> {
let subsystem = SubsystemId::from_str(PERSON_SUBSYSTEM)?;
Ok(Router::new()
.route("/persons", get(list_persons))
.route("/persons/{person_id}", get(get_person))
.route(
"/persons/{person_id}/access/{access_id}",
put(update_person),
)
.route("/persons/profiles/{profile_id}", post(create_person))
.layer(Extension(AppState {
persons,
access,
model,
subsystem,
})))
}
#[derive(Clone)]
struct AppState {
persons: Arc<K1AccessPersons>,
access: Arc<K1Access>,
model: ModelId,
subsystem: SubsystemId,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NameInput {
name: String,
}
#[derive(Serialize, PartialEq, Debug)]
struct PersonDto {
person_id: String,
name: String,
}
#[derive(Serialize, PartialEq, Debug)]
struct SubmittedPersonDto {
person_id: String,
access_id: String,
}
#[derive(Serialize, PartialEq, Debug)]
struct ListedPersonDto {
person_id: String,
access_id: String,
name: String,
}
#[derive(Serialize)]
struct ErrorDto {
error: &'static str,
message: &'static str,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ApiError {
InvalidPersonId,
InvalidAccessId,
InvalidProfileId,
InvalidJson,
InvalidPersonName,
UnsupportedMediaType,
PersonNotFound,
ProfileNotFound,
PersonUpdateDenied,
Unavailable,
}
impl ApiError {
const fn code(self) -> &'static str {
match self {
Self::InvalidPersonId => "invalid_person_id",
Self::InvalidAccessId => "invalid_access_id",
Self::InvalidProfileId => "invalid_profile_id",
Self::InvalidJson => "invalid_json",
Self::InvalidPersonName => "invalid_person_name",
Self::UnsupportedMediaType => "unsupported_media_type",
Self::PersonNotFound => "person_not_found",
Self::ProfileNotFound => "profile_not_found",
Self::PersonUpdateDenied => "person_update_denied",
Self::Unavailable => "persons_unavailable",
}
}
const fn message(self) -> &'static str {
match self {
Self::InvalidPersonId => "person ID must be lowercase hexadecimal",
Self::InvalidAccessId => "access ID must be lowercase hexadecimal",
Self::InvalidProfileId => "profile ID must be lowercase hexadecimal",
Self::InvalidJson => "request JSON is invalid",
Self::InvalidPersonName => "person name is invalid",
Self::UnsupportedMediaType => "content type must be application/json",
Self::PersonNotFound => "person is not available",
Self::ProfileNotFound => "profile is not available",
Self::PersonUpdateDenied => "person update is not authorized",
Self::Unavailable => "persons service is unavailable",
}
}
const fn status(self) -> StatusCode {
match self {
Self::InvalidPersonId
| Self::InvalidAccessId
| Self::InvalidProfileId
| Self::InvalidJson
| Self::InvalidPersonName => StatusCode::BAD_REQUEST,
Self::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE,
Self::PersonNotFound | Self::ProfileNotFound => StatusCode::NOT_FOUND,
Self::PersonUpdateDenied => StatusCode::FORBIDDEN,
Self::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
json_response(
self.status(),
ErrorDto {
error: self.code(),
message: self.message(),
},
)
}
}
async fn create_person(
Extension(http_principal): Extension<HttpPrincipal>,
Path(profile_id): Path<String>,
headers: HeaderMap,
Extension(state): Extension<AppState>,
body: Bytes,
) -> Response {
if !is_json_content_type(&headers) {
return ApiError::UnsupportedMediaType.into_response();
}
let profile_id = match parse_profile_id(&profile_id) {
Ok(id) => id,
Err(error) => return error.into_response(),
};
let input = match parse_name(&body) {
Ok(input) => input,
Err(error) => return error.into_response(),
};
let user = user_id(&http_principal);
let persons = state.persons;
let model = state.model;
match spawn_facade(move || {
persons
.create(
RequestPrincipal::new(user, model),
ProfileSelection::Saved(profile_id),
input.name,
)
.map(|submitted| SubmittedPersonDto {
person_id: submitted.person_id.to_string(),
access_id: submitted.access_id.txid().to_string(),
})
.map_err(|error| {
if is_profile_unavailable(&error) {
ApiError::ProfileNotFound
} else {
ApiError::Unavailable
}
})
})
.await
{
Ok(dto) => json_response(StatusCode::CREATED, dto),
Err(error) => error.into_response(),
}
}
async fn get_person(
Extension(_http_principal): Extension<HttpPrincipal>,
Path(person_id): Path<String>,
Extension(state): Extension<AppState>,
) -> Response {
let person_id = match parse_person_id(&person_id) {
Ok(id) => id,
Err(error) => return error.into_response(),
};
let persons = state.persons;
match spawn_facade(move || {
persons
.read_person(person_id)
.map(|view| PersonDto {
person_id: view.person_id.to_string(),
name: view.name,
})
.map_err(|error| {
if is_person_missing(&error) {
ApiError::PersonNotFound
} else {
ApiError::Unavailable
}
})
})
.await
{
Ok(dto) => json_response(StatusCode::OK, dto),
Err(error) => error.into_response(),
}
}
async fn update_person(
Extension(http_principal): Extension<HttpPrincipal>,
Path((person_id, access_id)): Path<(String, String)>,
headers: HeaderMap,
Extension(state): Extension<AppState>,
body: Bytes,
) -> Response {
if !is_json_content_type(&headers) {
return ApiError::UnsupportedMediaType.into_response();
}
let person_id = match parse_person_id(&person_id) {
Ok(id) => id,
Err(error) => return error.into_response(),
};
let access_id = match parse_access_id(&access_id) {
Ok(id) => id,
Err(error) => return error.into_response(),
};
let input = match parse_name(&body) {
Ok(input) => input,
Err(error) => return error.into_response(),
};
let user = user_id(&http_principal);
match spawn_facade(move || {
update_managed_person(&state, user, person_id, access_id, input.name)
})
.await
{
Ok(()) => empty_response(StatusCode::NO_CONTENT),
Err(error) => error.into_response(),
}
}
async fn list_persons(
Extension(http_principal): Extension<HttpPrincipal>,
Extension(state): Extension<AppState>,
) -> Response {
let user = user_id(&http_principal);
match spawn_facade(move || list_manageable(&state, user)).await {
Ok(dto) => json_response(StatusCode::OK, dto),
Err(error) => error.into_response(),
}
}
fn update_managed_person(
state: &AppState,
user: UserId,
person_id: PersonId,
access_id: AccessId,
name: String,
) -> Result<(), ApiError> {
state
.persons
.update_person(
RequestPrincipal::new(user, state.model),
person_id,
access_id,
name,
)
.map_err(|error| update_error(&error))
}
fn list_manageable(state: &AppState, user: UserId) -> Result<Vec<ListedPersonDto>, ApiError> {
let access_ids = state
.access
.list_user(RequestPrincipal::new(user, state.model), state.subsystem)
.map_err(|_| ApiError::Unavailable)?;
let mut listed = Vec::new();
for access_id in access_ids {
let check = state
.access
.check(
RequestPrincipal::new(user, state.model),
access_id,
state.subsystem,
)
.map_err(|_| ApiError::Unavailable)?;
if let Some(dto) = managed_list_item(access_id, &check, &state.persons, &state.subsystem)? {
listed.push(dto);
}
}
Ok(listed)
}
fn managed_list_item(
access_id: AccessId,
check: &AccessCheck,
persons: &K1AccessPersons,
subsystem: &SubsystemId,
) -> Result<Option<ListedPersonDto>, ApiError> {
if !check.can_view() || !check.can_manage() {
return Ok(None);
}
let Some(target) = check.target() else {
return Ok(None);
};
if target.subsystem() != *subsystem {
return Ok(None);
}
let Ok(bytes) = <[u8; 12]>::try_from(target.object_id()) else {
return Ok(None);
};
let person_id = PersonId::from_tx_id(TxId::from_bytes(bytes));
match persons.read_person(person_id) {
Ok(view) if view.person_id == person_id => Ok(Some(ListedPersonDto {
person_id: view.person_id.to_string(),
access_id: access_id.txid().to_string(),
name: view.name,
})),
Ok(_) => Ok(None),
Err(error) if is_person_missing(&error) => Ok(None),
Err(_) => Err(ApiError::Unavailable),
}
}
fn update_error(error: &str) -> ApiError {
match error {
"principal must be able to view and manage person"
| "person access target is unavailable"
| "person access target must contain exactly 12 person bytes"
| "person access target is no longer canonical"
| "person access target does not match supplied person"
| "person is unavailable"
| "person is unknown" => ApiError::PersonUpdateDenied,
error
if error.contains("unavailable")
|| error.contains("fault")
|| error.contains("failed") =>
{
ApiError::Unavailable
}
_ => ApiError::PersonUpdateDenied,
}
}
fn parse_name(body: &[u8]) -> Result<NameInput, ApiError> {
let input = serde_json::from_slice::<NameInput>(body).map_err(|_| ApiError::InvalidJson)?;
if input.name.is_empty()
|| input.name.len() > 128
|| input.name.chars().any(char::is_control)
|| !input
.name
.chars()
.any(|character| !character.is_whitespace())
{
return Err(ApiError::InvalidPersonName);
}
Ok(input)
}
fn parse_person_id(value: &str) -> Result<PersonId, ApiError> {
lower_hex_id(value)
.map(|bytes| PersonId::from_tx_id(TxId::from_bytes(bytes)))
.ok_or(ApiError::InvalidPersonId)
}
fn parse_access_id(value: &str) -> Result<AccessId, ApiError> {
lower_hex_id(value)
.map(|bytes| AccessId::new(TxId::from_bytes(bytes)))
.ok_or(ApiError::InvalidAccessId)
}
fn parse_profile_id(value: &str) -> Result<ProfileId, ApiError> {
lower_hex_id(value)
.map(|bytes| ProfileId::new(TxId::from_bytes(bytes)))
.ok_or(ApiError::InvalidProfileId)
}
fn lower_hex_id(value: &str) -> Option<[u8; 12]> {
if value.len() != 24
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return None;
}
let mut bytes = [0_u8; 12];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
bytes[index] = hex_nibble(pair[0])? << 4 | hex_nibble(pair[1])?;
}
Some(bytes)
}
fn hex_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
_ => None,
}
}
fn user_id(principal: &HttpPrincipal) -> UserId {
UserId::from_tx_id(TxId::from_bytes(*principal.user_id()))
}
fn is_json_content_type(headers: &HeaderMap) -> bool {
let values = headers.get_all(header::CONTENT_TYPE);
values.iter().count() == 1
&& values.iter().next().is_some_and(|value| {
value
.to_str()
.ok()
.and_then(|raw| raw.split(';').next())
.is_some_and(|media_type| {
media_type.trim().eq_ignore_ascii_case("application/json")
})
})
}
fn is_person_missing(error: &str) -> bool {
error == "person is unavailable" || error == "person is unknown"
}
fn is_profile_unavailable(error: &str) -> bool {
error == "profile is unavailable" || error == "profile not found"
}
async fn spawn_facade<T: Send + 'static>(
work: impl FnOnce() -> Result<T, ApiError> + Send + 'static,
) -> Result<T, ApiError> {
tokio::task::spawn_blocking(work)
.await
.map_err(|_| ApiError::Unavailable)?
}
fn json_response<T: Serialize>(status: StatusCode, value: T) -> Response {
let mut response = (status, Json(value)).into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
fn empty_response(status: StatusCode) -> Response {
let mut response = status.into_response();
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
#[cfg(test)]
mod tests {
use super::*;
use kcode_k1_access_profiles::{AuthorizationProfile, K1AccessProfiles, ProfileOwner};
use kcode_k1_groups::K1Groups;
use kcode_k1_peering::K1Peering;
use kcode_k1_persons::K1Persons;
use kcode_k1_txn_ordering::K1TxnOrdering;
use std::{path::Path, sync::Arc};
use tempfile::TempDir;
#[test]
fn ids_are_exact_lowercase_hex() {
assert_eq!(
lower_hex_id("00112233445566778899aabb"),
Some([0, 17, 34, 51, 68, 85, 102, 119, 136, 153, 170, 187])
);
assert!(lower_hex_id("00112233445566778899AAbb").is_none());
assert!(lower_hex_id("00112233445566778899aab").is_none());
assert!(lower_hex_id("00112233445566778899aabg").is_none());
}
#[test]
fn dto_serialization_is_stable() {
let dto = ListedPersonDto {
person_id: "00112233445566778899aabb".into(),
access_id: "ffeeddccbbaa998877665544".into(),
name: "Ada".into(),
};
assert_eq!(
serde_json::to_string(&dto).unwrap(),
"{\"person_id\":\"00112233445566778899aabb\",\"access_id\":\"ffeeddccbbaa998877665544\",\"name\":\"Ada\"}"
);
}
#[test]
fn errors_are_redacted_and_mapped() {
assert_eq!(ApiError::PersonUpdateDenied.code(), "person_update_denied");
assert_eq!(ApiError::PersonUpdateDenied.status(), StatusCode::FORBIDDEN);
assert_eq!(
ApiError::PersonUpdateDenied.message(),
"person update is not authorized"
);
assert_eq!(
serde_json::to_string(&ErrorDto {
error: ApiError::Unavailable.code(),
message: ApiError::Unavailable.message()
})
.unwrap(),
"{\"error\":\"persons_unavailable\",\"message\":\"persons service is unavailable\"}"
);
assert!(!ApiError::PersonUpdateDenied.message().contains("principal"));
}
#[test]
fn update_error_distinguishes_policy_denials_from_facade_unavailability() {
assert_eq!(
update_error("person access target is unavailable"),
ApiError::PersonUpdateDenied
);
assert_eq!(
update_error("dependency facade unavailable"),
ApiError::Unavailable
);
}
struct Stack {
_ordering: Arc<K1TxnOrdering>,
_peering: Arc<K1Peering>,
state: AppState,
}
impl Stack {
fn open(root: &Path) -> Self {
let ordering = Arc::new(K1TxnOrdering::open(&root.join("ordering")).unwrap());
let peering =
Arc::new(K1Peering::open(&root.join("peering"), ordering.clone()).unwrap());
let groups = Arc::new(
K1Groups::open(&root.join("groups"), ordering.clone(), peering.clone()).unwrap(),
);
let profiles = Arc::new(
K1AccessProfiles::open(&root.join("profiles"), ordering.clone(), peering.clone())
.unwrap(),
);
let persons = Arc::new(
K1Persons::open(&root.join("persons"), ordering.clone(), peering.clone()).unwrap(),
);
let access = Arc::new(
K1Access::open(
&root.join("access"),
ordering.clone(),
peering.clone(),
groups,
)
.unwrap(),
);
let facade =
Arc::new(K1AccessPersons::open(access.clone(), profiles, persons).unwrap());
Self {
_ordering: ordering,
_peering: peering,
state: AppState {
persons: facade,
access,
model: ModelId::from_bytes([9; 32]),
subsystem: SubsystemId::from_str(PERSON_SUBSYSTEM).unwrap(),
},
}
}
}
fn principal(user: u8) -> RequestPrincipal {
RequestPrincipal::new(
UserId::from_tx_id(TxId::from_bytes([user; 12])),
ModelId::from_bytes([9; 32]),
)
}
fn inline_owner_profile() -> ProfileSelection {
ProfileSelection::Inline(
AuthorizationProfile::new(vec![ProfileOwner::RequestUser], vec![]).unwrap(),
)
}
#[test]
fn list_and_mismatched_update_use_the_real_facades() {
let root = TempDir::new().unwrap();
let stack = Stack::open(root.path());
let owner = principal(1);
let unrelated = principal(2);
let first = stack
.state
.persons
.create(owner, inline_owner_profile(), "Ada".into())
.unwrap();
let second = stack
.state
.persons
.create(owner, inline_owner_profile(), "Grace".into())
.unwrap();
let owner_list = list_manageable(&stack.state, owner.user()).unwrap();
assert_eq!(
owner_list
.iter()
.map(|item| (&item.person_id, &item.access_id, &item.name))
.collect::<Vec<_>>(),
vec![
(
&first.person_id.to_string(),
&first.access_id.txid().to_string(),
&"Ada".to_owned()
),
(
&second.person_id.to_string(),
&second.access_id.txid().to_string(),
&"Grace".to_owned()
)
]
);
assert!(
list_manageable(&stack.state, unrelated.user())
.unwrap()
.is_empty()
);
assert_eq!(
update_managed_person(
&stack.state,
owner.user(),
first.person_id,
second.access_id,
"Denied".into()
),
Err(ApiError::PersonUpdateDenied)
);
assert_eq!(
stack
.state
.persons
.read_person(first.person_id)
.unwrap()
.name,
"Ada"
);
assert_eq!(
stack
.state
.persons
.read_person(second.person_id)
.unwrap()
.name,
"Grace"
);
}
}