diff --git a/Cargo.lock b/Cargo.lock index 4e30653fe..0ee5bc3c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2952,6 +2952,7 @@ dependencies = [ "tracing", "url", "urlencoding", + "utoipa", "uuid", "webauthn-rs-proto", ] @@ -3081,7 +3082,10 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "utoipa", + "utoipa-swagger-ui", "uuid", + "walkdir", ] [[package]] @@ -3158,6 +3162,7 @@ dependencies = [ "compact_jwt", "fantoccini", "futures", + "http", "hyper-tls", "kanidm_build_profiles", "kanidm_client", @@ -4544,6 +4549,41 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rust-embed" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.38", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "7.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74" +dependencies = [ + "sha2 0.10.8", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.23" @@ -5656,6 +5696,47 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "utoipa" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82b1bc5417102a73e8464c686eef947bdfb99fcdfc0a4f228e81afa9526470a" +dependencies = [ + "indexmap 2.0.2", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d96dcd6fc96f3df9b3280ef480770af1b7c5d14bc55192baa9b067976d920c" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 2.0.38", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "3.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84614caa239fb25b2bb373a52859ffd94605ceb256eeb1d63436325cf81e3653" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.4.1" @@ -6317,6 +6398,18 @@ dependencies = [ "syn 2.0.38", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zstd" version = "0.12.4" diff --git a/Cargo.toml b/Cargo.toml index 998674c56..910949816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,7 +91,6 @@ axum = { version = "0.6.20", features = [ "form", "headers", "http2", - "http2", "json", "macros", "multipart", @@ -208,6 +207,7 @@ tss-esapi = "^7.3.0" url = "^2.4.1" urlencoding = "2.1.3" users = "^0.11.0" +utoipa = "3.5.0" uuid = "^1.4.1" wasm-bindgen = "^0.2.86" diff --git a/book/src/authentication.md b/book/src/authentication.md index 984370736..64f8bbd69 100644 --- a/book/src/authentication.md +++ b/book/src/authentication.md @@ -135,14 +135,14 @@ reauthenticate for short periods to access higher levels of privilege. When using a user command that requires these privileges you will be warned: -``` +```shell kanidm person credential update william # Privileges have expired for william@idm.example.com - you need to re-authenticate again. ``` To reauthenticate -``` +```shell kanidm reauth -D william ``` diff --git a/libs/client/src/lib.rs b/libs/client/src/lib.rs index 7362ba176..2c6c29bf2 100644 --- a/libs/client/src/lib.rs +++ b/libs/client/src/lib.rs @@ -24,7 +24,7 @@ use std::os::unix::fs::MetadataExt; use std::path::Path; use std::time::Duration; -use kanidm_proto::constants::{APPLICATION_JSON, ATTR_NAME}; +use kanidm_proto::constants::{APPLICATION_JSON, ATTR_NAME, KOPID, KSESSIONID, KVERSION}; use kanidm_proto::v1::*; use reqwest::header::CONTENT_TYPE; use reqwest::Response; @@ -47,10 +47,6 @@ mod service_account; mod sync_account; mod system; -pub const KOPID: &str = "X-KANIDM-OPID"; -pub const KSESSIONID: &str = "X-KANIDM-AUTH-SESSION-ID"; - -const KVERSION: &str = "X-KANIDM-VERSION"; const EXPECT_VERSION: &str = env!("CARGO_PKG_VERSION"); #[derive(Debug)] diff --git a/proto/Cargo.toml b/proto/Cargo.toml index 01849231d..c3322e99b 100644 --- a/proto/Cargo.toml +++ b/proto/Cargo.toml @@ -14,7 +14,6 @@ repository = { workspace = true } [features] wasm = ["webauthn-rs-proto/wasm"] - [dependencies] base32 = { workspace = true } base64urlsafedata = { workspace = true } @@ -27,6 +26,6 @@ time = { workspace = true, features = ["serde", "std"] } tracing = { workspace = true } url = { workspace = true, features = ["serde"] } urlencoding = { workspace = true } +utoipa = { workspace = true } uuid = { workspace = true, features = ["serde"] } webauthn-rs-proto = { workspace = true } - diff --git a/proto/src/constants.rs b/proto/src/constants.rs index e2e05623f..2e397c1b3 100644 --- a/proto/src/constants.rs +++ b/proto/src/constants.rs @@ -191,3 +191,9 @@ pub const TEST_ATTR_TEST_ATTR: &str = "testattr"; pub const TEST_ATTR_EXTRA: &str = "extra"; pub const TEST_ATTR_NUMBER: &str = "testattrnumber"; pub const TEST_ATTR_NOTALLOWED: &str = "notallowed"; + +pub const KSESSIONID: &str = "X-KANIDM-AUTH-SESSION-ID"; +pub const KOPID: &str = "X-KANIDM-OPID"; +pub const KVERSION: &str = "X-KANIDM-VERSION"; + +pub const X_FORWARDED_FOR: &str = "x-forwarded-for"; diff --git a/proto/src/scim_v1.rs b/proto/src/scim_v1.rs index 8f25fdbc7..77d58963a 100644 --- a/proto/src/scim_v1.rs +++ b/proto/src/scim_v1.rs @@ -1,6 +1,7 @@ use base64urlsafedata::Base64UrlSafeData; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; +use utoipa::ToSchema; use uuid::Uuid; pub use scim_proto::prelude::{ScimAttr, ScimComplexAttr, ScimEntry, ScimError, ScimSimpleAttr}; @@ -12,7 +13,7 @@ use crate::constants::{ ATTR_NAME, ATTR_SSH_PUBLICKEY, }; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, ToSchema)] pub enum ScimSyncState { Refresh, Active { cookie: Base64UrlSafeData }, diff --git a/proto/src/v1.rs b/proto/src/v1.rs index 7d7e626d6..df31c73f4 100644 --- a/proto/src/v1.rs +++ b/proto/src/v1.rs @@ -9,6 +9,7 @@ use std::fmt; use std::str::FromStr; use time::OffsetDateTime; use url::Url; +use utoipa::ToSchema; use uuid::Uuid; use webauthn_rs_proto::{ CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, @@ -19,7 +20,7 @@ use crate::constants::{ATTR_GROUP, ATTR_LDAP_SSHPUBLICKEY}; // These proto implementations are here because they have public definitions -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, ToSchema)] pub enum AccountType { Person, ServiceAccount, @@ -35,7 +36,7 @@ impl ToString for AccountType { } /* ===== errors ===== */ -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum SchemaError { NotImplemented, @@ -52,7 +53,7 @@ pub enum SchemaError { PhantomAttribute(String), } -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum PluginError { AttrUnique(String), @@ -62,7 +63,7 @@ pub enum PluginError { Oauth2Secrets, } -#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)] +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, ToSchema)] #[serde(rename_all = "lowercase")] pub enum ConsistencyError { Unknown, @@ -88,7 +89,7 @@ pub enum ConsistencyError { DeniedName(Uuid), } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, ToSchema)] #[serde(rename_all = "lowercase")] pub enum PasswordFeedback { // https://docs.rs/zxcvbn/latest/zxcvbn/feedback/enum.Suggestion.html @@ -217,7 +218,7 @@ impl fmt::Display for PasswordFeedback { } } -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Debug, ToSchema)] #[serde(rename_all = "lowercase")] pub enum OperationError { SessionExpired, @@ -295,7 +296,7 @@ impl PartialEq for OperationError { // domain specific fields for the purposes of IDM, over the normal // entry/ava/filter types. These related deeply to schema. -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct Group { pub spn: String, pub uuid: String, @@ -308,7 +309,7 @@ impl fmt::Display for Group { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct Claim { pub name: String, pub uuid: String, @@ -380,7 +381,7 @@ impl fmt::Display for UatStatusState { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(rename_all = "lowercase")] pub struct UatStatus { pub account_id: Uuid, @@ -426,7 +427,7 @@ pub enum UatPurpose { /// This structure and how it works will *very much* change over time from this /// point onward! This means on updates, that sessions will invalidate in many /// cases. -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(rename_all = "lowercase")] pub struct UserAuthToken { pub session_id: Uuid, @@ -499,7 +500,7 @@ pub enum ApiTokenPurpose { Synchronise, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(rename_all = "lowercase")] pub struct ApiToken { // The account this is associated with. @@ -546,7 +547,7 @@ impl PartialEq for ApiToken { impl Eq for ApiToken {} -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(rename_all = "lowercase")] pub struct ApiTokenGenerate { pub label: String, @@ -560,7 +561,7 @@ pub struct ApiTokenGenerate { // This is similar to uat, but omits claims (they have no role in radius), and adds // the radius secret field. -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct RadiusAuthToken { pub name: String, pub displayname: String, @@ -581,7 +582,7 @@ impl fmt::Display for RadiusAuthToken { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct UnixGroupToken { pub name: String, pub spn: String, @@ -598,13 +599,13 @@ impl fmt::Display for UnixGroupToken { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct GroupUnixExtend { pub gidnumber: Option, } #[skip_serializing_none] -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct UnixUserToken { pub name: String, pub spn: String, @@ -639,7 +640,7 @@ impl fmt::Display for UnixUserToken { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(deny_unknown_fields)] pub struct AccountUnixExtend { pub gidnumber: Option, @@ -665,7 +666,7 @@ pub enum CredentialDetailType { PasswordMfa(Vec, Vec, usize), } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct CredentialDetail { pub uuid: Uuid, pub type_: CredentialDetailType, @@ -729,13 +730,13 @@ impl fmt::Display for CredentialDetail { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct PasskeyDetail { pub uuid: Uuid, pub tag: String, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct CredentialStatus { pub creds: Vec, } @@ -750,7 +751,7 @@ impl fmt::Display for CredentialStatus { } } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct BackupCodesView { pub backup_codes: Vec, } @@ -776,7 +777,7 @@ impl fmt::Display for Entry { } } -#[derive(Debug, Serialize, Deserialize, Clone, Ord, PartialOrd, Eq, PartialEq, Hash)] +#[derive(Debug, Serialize, Deserialize, Clone, Ord, PartialOrd, Eq, PartialEq, Hash, ToSchema)] #[serde(rename_all = "lowercase")] pub enum Filter { // This is attr - value @@ -804,7 +805,7 @@ pub enum Modify { Purged(String), } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct ModifyList { pub mods: Vec, } @@ -815,7 +816,7 @@ impl ModifyList { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchRequest { pub filter: Filter, } @@ -826,7 +827,7 @@ impl SearchRequest { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SearchResponse { pub entries: Vec, } @@ -837,7 +838,7 @@ impl SearchResponse { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CreateRequest { pub entries: Vec, } @@ -848,7 +849,7 @@ impl CreateRequest { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct DeleteRequest { pub filter: Filter, } @@ -859,7 +860,7 @@ impl DeleteRequest { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct ModifyRequest { // Probably needs a modlist? pub filter: Filter, @@ -884,7 +885,7 @@ impl ModifyRequest { // // On loginSuccess, we send a cookie, and that allows the token to be // generated. The cookie can be shared between servers. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum AuthCredential { Anonymous, @@ -909,7 +910,7 @@ impl fmt::Debug for AuthCredential { } } -#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialOrd, Ord)] +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialOrd, Ord, ToSchema)] #[serde(rename_all = "lowercase")] pub enum AuthMech { Anonymous, @@ -935,13 +936,13 @@ impl fmt::Display for AuthMech { } } -#[derive(Debug, Serialize, Deserialize, Copy, Clone)] +#[derive(Debug, Serialize, Deserialize, Copy, Clone, ToSchema)] #[serde(rename_all = "lowercase")] pub enum AuthIssueSession { Token, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum AuthStep { // name @@ -962,14 +963,14 @@ pub enum AuthStep { } // Request auth for identity X with roles Y? -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AuthRequest { pub step: AuthStep, } // Respond with the list of auth types and nonce, etc. // It can also contain a denied, or success. -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] #[serde(rename_all = "lowercase")] pub enum AuthAllowed { Anonymous, @@ -1032,7 +1033,7 @@ impl fmt::Display for AuthAllowed { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum AuthState { // You need to select how you want to talk to me. @@ -1050,7 +1051,7 @@ pub enum AuthState { // SuccessCookie, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct AuthResponse { pub sessionid: Uuid, pub state: AuthState, @@ -1072,7 +1073,7 @@ pub enum SetCredentialRequest { } */ -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "lowercase")] pub enum TotpAlgo { Sha1, @@ -1090,7 +1091,7 @@ impl fmt::Display for TotpAlgo { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TotpSecret { pub accountname: String, /// User-facing name of the system, issuer of the TOTP @@ -1123,12 +1124,12 @@ impl TotpSecret { } } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct CUIntentToken { pub token: String, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)] pub struct CUSessionToken { pub token: String, } @@ -1170,7 +1171,7 @@ impl fmt::Debug for CURequest { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum CURegState { // Nothing in progress. None, @@ -1181,14 +1182,14 @@ pub enum CURegState { Passkey(CreationChallengeResponse), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub enum CUExtPortal { None, Hidden, Some(Url), } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct CUStatus { // Display values pub spn: String, @@ -1204,7 +1205,7 @@ pub struct CUStatus { pub passkeys_can_edit: bool, } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] pub struct WhoamiResponse { // Should we just embed the entry? Or destructure it? pub youare: Entry, @@ -1217,7 +1218,7 @@ impl WhoamiResponse { } // Simple string value provision. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub struct SingleStringRequest { pub value: String, } diff --git a/server/core/Cargo.toml b/server/core/Cargo.toml index 5d70037da..8d55107dd 100644 --- a/server/core/Cargo.toml +++ b/server/core/Cargo.toml @@ -26,7 +26,6 @@ filetime = { workspace = true } futures = { workspace = true } futures-util = { workspace = true } http = { workspace = true } - hyper = { workspace = true } kanidm_proto = { workspace = true } kanidm_utils_users = { workspace = true } @@ -63,6 +62,15 @@ urlencoding = { workspace = true } tempfile = { workspace = true } url = { workspace = true, features = ["serde"] } uuid = { workspace = true, features = ["serde", "v4"] } +utoipa = { workspace = true, features = [ + "axum_extras", + "openapi_extensions", + "preserve_order", # Preserve order of properties when serializing the schema for a component. +] } +utoipa-swagger-ui = { version = "3.1.5", features = ["axum"] } + +[dev-dependencies] +walkdir = { workspace = true } [build-dependencies] kanidm_build_profiles = { workspace = true } diff --git a/server/core/src/config.rs b/server/core/src/config.rs index 642b6e69a..87e344041 100644 --- a/server/core/src/config.rs +++ b/server/core/src/config.rs @@ -308,6 +308,12 @@ impl fmt::Display for Configuration { impl Default for Configuration { fn default() -> Self { + Self::new() + } +} + +impl Configuration { + pub fn new() -> Self { Configuration { address: DEFAULT_SERVER_ADDRESS.to_string(), ldapaddress: None, @@ -335,12 +341,6 @@ impl Default for Configuration { integration_repl_config: None, } } -} - -impl Configuration { - pub fn new() -> Self { - Self::default() - } pub fn new_for_test() -> Self { Configuration { diff --git a/server/core/src/https/apidocs/mod.rs b/server/core/src/https/apidocs/mod.rs new file mode 100644 index 000000000..5b6c76527 --- /dev/null +++ b/server/core/src/https/apidocs/mod.rs @@ -0,0 +1,249 @@ +use axum::{response::Redirect, routing::get, Router}; +use kanidm_proto::{scim_v1::ScimSyncState, v1}; +use utoipa::{ + openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, + Modify, OpenApi, +}; +use utoipa_swagger_ui::SwaggerUi; + +use super::{errors::WebError, ServerState}; + +pub(crate) mod path_schema; +pub(crate) mod response_schema; +#[cfg(test)] +pub(crate) mod tests; + +struct SecurityAddon; + +impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "token_jwt", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ) + } + } +} + +// docs for the derive macro are here: +#[derive(OpenApi)] +#[openapi( + paths( + super::generic::status, + super::generic::robots_txt, + + super::oauth2::oauth2_image_get, + + super::v1::raw_create, + super::v1::raw_delete, + super::v1::raw_modify, + super::v1::raw_search, + + super::v1_oauth2::oauth2_id_image_delete, + super::v1_oauth2::oauth2_id_image_post, + super::v1_oauth2::oauth2_get, + super::v1_oauth2::oauth2_basic_post, + super::v1_oauth2::oauth2_public_post, + super::v1_oauth2::oauth2_id_get, + super::v1_oauth2::oauth2_id_patch, + super::v1_oauth2::oauth2_id_delete, + super::v1_oauth2::oauth2_id_image_post, + super::v1_oauth2::oauth2_id_image_delete, + super::v1_oauth2::oauth2_id_get_basic_secret, + super::v1_oauth2::oauth2_id_scopemap_post, + super::v1_oauth2::oauth2_id_scopemap_delete, + super::v1_oauth2::oauth2_id_sup_scopemap_post, + super::v1_oauth2::oauth2_id_sup_scopemap_delete, + super::v1_scim::scim_sync_post, + super::v1_scim::scim_sync_get, + + super::v1::schema_get, + super::v1::whoami, + super::v1::whoami_uat, + super::v1::applinks_get, + super::v1::schema_attributetype_get, + super::v1::schema_attributetype_get_id, + super::v1::schema_classtype_get, + super::v1::schema_classtype_get_id, + super::v1::person_get, + super::v1::person_post, + super::v1::service_account_credential_generate, + super::v1::service_account_api_token_delete, + super::v1::service_account_api_token_get, + super::v1::service_account_api_token_post, + super::v1::person_id_get, + super::v1::person_id_patch, + super::v1::person_id_delete, + super::v1::person_id_get_attr, + super::v1::person_id_put_attr, + super::v1::person_id_post_attr, + super::v1::person_id_delete_attr, + super::v1::person_get_id_credential_status, + super::v1::person_id_credential_update_get, + super::v1::person_id_credential_update_intent_get, + super::v1::person_id_credential_update_intent_ttl_get, + + super::v1::service_account_id_ssh_pubkeys_get, + super::v1::service_account_id_ssh_pubkeys_post, + + super::v1::person_id_ssh_pubkeys_get, + super::v1::person_id_ssh_pubkeys_post, + super::v1::person_id_ssh_pubkeys_tag_get, + super::v1::person_id_ssh_pubkeys_tag_delete, + + super::v1::person_id_radius_get, + super::v1::person_id_radius_post, + super::v1::person_id_radius_delete, + super::v1::person_id_radius_token_get, + + super::v1::account_id_ssh_pubkeys_get, + super::v1::account_id_radius_token_post, + super::v1::service_account_id_unix_post, + super::v1::person_id_unix_credential_put, + super::v1::person_id_unix_credential_delete, + super::v1::person_identify_user_post, + super::v1::service_account_get, + super::v1::service_account_post, + super::v1::service_account_get, + super::v1::service_account_post, + super::v1::service_account_id_get, + super::v1::service_account_id_delete, + super::v1::service_account_id_get_attr, + super::v1::service_account_id_put_attr, + super::v1::service_account_id_post_attr, + super::v1::service_account_id_delete_attr, + super::v1::service_account_into_person, + super::v1::service_account_api_token_post, + super::v1::service_account_api_token_get, + super::v1::service_account_api_token_delete, + super::v1::service_account_credential_generate, + super::v1::service_account_id_credential_status_get, + super::v1::service_account_id_ssh_pubkeys_tag_get, + super::v1::service_account_id_ssh_pubkeys_tag_delete, + super::v1::account_id_unix_post, + super::v1::account_id_unix_auth_post, + super::v1::account_id_unix_token, + super::v1::account_id_unix_token, + super::v1::account_id_radius_token_post, + super::v1::account_id_radius_token_get, + super::v1::account_id_ssh_pubkeys_get, + super::v1::account_id_ssh_pubkeys_tag_get, + super::v1::account_id_user_auth_token_get, + super::v1::account_user_auth_token_delete, + super::v1::credential_update_exchange_intent, + super::v1::credential_update_status, + super::v1::credential_update_update, + super::v1::credential_update_commit, + super::v1::credential_update_cancel, + super::v1::domain_get, + super::v1::domain_attr_get, + super::v1::domain_attr_put, + super::v1::domain_attr_delete, + super::v1::group_id_unix_token_get, + super::v1::group_id_unix_post, + super::v1::group_get, + super::v1::group_post, + super::v1::group_id_get, + super::v1::group_id_delete, + super::v1::group_id_attr_delete, + super::v1::group_id_attr_get, + super::v1::group_id_attr_put, + super::v1::group_id_attr_post, + super::v1::system_get, + super::v1::system_attr_get, + super::v1::system_attr_post, + super::v1::system_attr_put, + super::v1::system_attr_delete, + super::v1::recycle_bin_get, + super::v1::recycle_bin_id_get, + super::v1::recycle_bin_revive_id_post, + super::v1::auth, + super::v1::auth_valid, + super::v1::logout, + super::v1::reauth, + super::v1_scim::sync_account_get, + super::v1_scim::sync_account_post, + super::v1_scim::sync_account_id_get, + super::v1_scim::sync_account_id_patch, + super::v1_scim::sync_account_id_attr_get, + super::v1_scim::sync_account_id_attr_put, + super::v1_scim::sync_account_id_finalise_get, + super::v1_scim::sync_account_id_terminate_get, + super::v1_scim::sync_account_token_post, + super::v1_scim::sync_account_token_delete, + super::v1::debug_ipinfo, + + ), + components( + schemas( + // TODO: can't add Entry/ProtoEntry to schema as this was only recently supported utoipa v3.5.0 doesn't support it - ref + // v1::Entry, + v1::AccountUnixExtend, + v1::ApiToken, + v1::ApiTokenGenerate, + v1::AuthRequest, + v1::AuthResponse, + v1::AuthState, + v1::BackupCodesView, + v1::Claim, + v1::CreateRequest, + v1::CredentialDetail, + v1::CredentialStatus, + v1::CUIntentToken, + v1::CUSessionToken, + v1::CUStatus, + v1::DeleteRequest, + v1::Group, + v1::GroupUnixExtend, + v1::ModifyList, + v1::ModifyRequest, + v1::PasskeyDetail, + v1::RadiusAuthToken, + v1::SearchRequest, + v1::SearchResponse, + v1::SingleStringRequest, + v1::TotpSecret, + v1::TotpAlgo, + v1::UatStatus, + v1::UnixGroupToken, + v1::UnixUserToken, + v1::UserAuthToken, + v1::WhoamiResponse, + ScimSyncState, + + WebError, + + ) + ), + modifiers(&SecurityAddon), + tags( + (name = "kanidm", description = "Kanidm API") + ), + info( + title = "Kanidm", + description = "API for interacting with the Kanidm system. This is a work in progress", + contact( // + name="Kanidm", + url="https://github.com/kanidm/kanidm", + ) + ) +)] +pub(crate) struct ApiDoc; + +pub(crate) fn router() -> Router { + Router::new() + .route("/docs", get(Redirect::temporary("/docs/swagger-ui"))) + .route("/docs/", get(Redirect::temporary("/docs/swagger-ui"))) + .merge( + SwaggerUi::new("/docs/swagger-ui").url( + "/docs/v1/openapi.json", + ::openapi(), + ) + ) +} diff --git a/server/core/src/https/apidocs/path_schema.rs b/server/core/src/https/apidocs/path_schema.rs new file mode 100644 index 000000000..5bcc4b5c9 --- /dev/null +++ b/server/core/src/https/apidocs/path_schema.rs @@ -0,0 +1,34 @@ +//! Path schema objects for the API documentation. + +use serde::{Deserialize, Serialize}; +use utoipa::IntoParams; + +#[derive(IntoParams, Serialize, Deserialize, Debug)] +pub(crate) struct UuidOrName { + id: String, +} + +#[derive(IntoParams, Serialize, Deserialize, Debug)] +pub(crate) struct TokenId { + token_id: String, +} +#[derive(IntoParams, Serialize, Deserialize, Debug)] +pub(crate) struct Id { + id: String, +} +#[derive(IntoParams, Serialize, Deserialize, Debug)] +pub(crate) struct Attr { + attr: String, +} + +#[derive(IntoParams, Serialize, Deserialize, Debug)] +pub(crate) struct RsName { + // The short name of the OAuth2 resource server to target + rs_name: String, +} + +#[derive(IntoParams, Serialize, Deserialize, Debug)] +pub(crate) struct GroupName { + // The short name of the group to target + group: String, +} diff --git a/server/core/src/https/apidocs/response_schema.rs b/server/core/src/https/apidocs/response_schema.rs new file mode 100644 index 000000000..8d0ba9357 --- /dev/null +++ b/server/core/src/https/apidocs/response_schema.rs @@ -0,0 +1,56 @@ +//! This file contains the default response schemas for the API. +//! +//! These are used to generate the OpenAPI schema definitions. +//! +use kanidm_proto::constants::APPLICATION_JSON; +use std::collections::BTreeMap; +use utoipa::{ + openapi::{Content, RefOr, Response, ResponseBuilder, ResponsesBuilder}, + IntoResponses, +}; + +#[allow(dead_code)] // because this is used for the OpenAPI schema gen +/// An empty response with `application/json` content type - use [ApiResponseWithout200] if you want to do everything but a 200 +pub(crate) enum DefaultApiResponse { + Ok, + InvalidRequest, + NeedsAuthorization, + NotAuthorized, +} + +impl IntoResponses for DefaultApiResponse { + fn responses() -> BTreeMap> { + ResponsesBuilder::new() + .response( + "200", + ResponseBuilder::new() + .content(APPLICATION_JSON, Content::default()) + .description("Ok"), + ) + .response("400", ResponseBuilder::new().description("Invalid Request")) + .response("401", ResponseBuilder::new().description("Authorization required")) + .response("403", ResponseBuilder::new().description("Not Authorized")) + .build() + .into() + } +} + +#[allow(dead_code)] // because this is used for the OpenAPI schema gen +/// A response set without the 200 status so the "defaults" can be handled. +pub(crate) enum ApiResponseWithout200 { + InvalidRequest, + NeedsAuthorization, + NotAuthorized, +} + +impl IntoResponses for ApiResponseWithout200 { + fn responses() -> BTreeMap> { + ResponsesBuilder::new() + .response("400", ResponseBuilder::new().description("Invalid Request")) + .response("401", ResponseBuilder::new().description("Authorization required")) + .response("403", ResponseBuilder::new().description("Not Authorized")) + .build() + .into() + } +} + diff --git a/server/core/src/https/apidocs/tests.rs b/server/core/src/https/apidocs/tests.rs new file mode 100644 index 000000000..f80f65891 --- /dev/null +++ b/server/core/src/https/apidocs/tests.rs @@ -0,0 +1,113 @@ +#[test] +/// This parses the source code trying to make sure we have API docs for every endpoint we publish. +/// +/// It's not perfect, but it's a start! +fn figure_out_if_we_have_all_the_routes() { + use std::collections::HashMap; + + // load this file + let module_filename = format!("{}/src/https/apidocs/mod.rs", env!("CARGO_MANIFEST_DIR")); + println!("trying to load apidocs source file: {}", module_filename); + let file = std::fs::read_to_string(&module_filename).unwrap(); + + // find all the lines that start with super::v1:: and end with a comma + let apidocs_function_finder = regex::Regex::new(r#"super::([a-zA-Z0-9_:]+),"#).unwrap(); + let mut apidocs_routes: HashMap> = HashMap::new(); + for line in file.lines() { + if let Some(caps) = apidocs_function_finder.captures(line) { + let route = caps.get(1).unwrap().as_str(); + println!("route: {}", route); + let mut splitter = route.split("::"); + + let module = splitter.next().unwrap(); + let handler = splitter.next().unwrap(); + if !apidocs_routes.contains_key(module) { + apidocs_routes.insert(module.to_string(), Vec::new()); + } + apidocs_routes + .get_mut(module) + .unwrap() + .push((handler.to_string(), "unset".to_string())); + } + } + for (module, routes) in apidocs_routes.iter() { + println!("API Module: {}", module); + for route in routes { + println!(" - {} (method: {})", route.0, route.1); + } + } + + // this looks for method(handler) axum things + let routedef_finder = + regex::Regex::new(r#"(any|delete|get|head|options|patch|post|put|trace)\(([a-z:_]+)\)"#) + .unwrap(); + // work our way through the source files in this package looking for routedefs + let mut found_routes: HashMap> = HashMap::new(); + let walker = walkdir::WalkDir::new(format!("{}/src", env!("CARGO_MANIFEST_DIR"))) + .follow_links(false) + .into_iter(); + + for entry in walker { + let entry = entry.unwrap(); + if entry.path().is_dir() { + continue; + } + println!("checking {}", entry.path().display()); + // because nobody wants to see their project dir all over the place + let relative_filename = entry + .path() + .display() + .to_string() + .replace(&format!("{}/", env!("CARGO_MANIFEST_DIR")), ""); + + let source_module = relative_filename.split("/").last().unwrap(); + let source_module = source_module.split(".").next().unwrap(); + + let file = std::fs::read_to_string(&entry.path()).unwrap(); + for line in file.lines() { + if line.contains("skip_route_check") { + println!("Skipping this line because it contains skip_route_check"); + continue; + } + if let Some(caps) = routedef_finder.captures(line) { + let method = caps.get(1).unwrap().as_str(); + let route = caps.get(2).unwrap().as_str(); + + if !found_routes.contains_key(source_module) { + found_routes.insert(source_module.to_string(), Vec::new()); + } + let new_route = (route.to_string(), method.to_string()); + println!("Found new route: {} {:?}", source_module, new_route); + found_routes.get_mut(source_module).unwrap().push(new_route); + } + } + } + // now we check the things + for (module, routes) in found_routes { + if ["ui"].contains(&module.as_str()) { + println!( + "We can skip checking {} because it's allow-listed for docs", + module + ); + continue; + } + if !apidocs_routes.contains_key(&module) { + panic!("Module {} is missing from the API docs", module); + } + // we can't handle the method yet because that's in the derive + for (route, _method) in routes { + let mut found_route = false; + for (apiroute_handler, _method) in apidocs_routes[&module].iter() { + if &route == apiroute_handler { + found_route = true; + break; + } + } + if !found_route { + panic!("couldn't find apidocs route for {}::{}", module, route); + } else { + println!("Docs OK: {}::{}", module, route); + } + } + } +} diff --git a/server/core/src/https/errors.rs b/server/core/src/https/errors.rs new file mode 100644 index 000000000..a99f93bb7 --- /dev/null +++ b/server/core/src/https/errors.rs @@ -0,0 +1,69 @@ +//! Where we hide the error handling widgets +//! + +use axum::response::{IntoResponse, Response}; +use http::header::ACCESS_CONTROL_ALLOW_ORIGIN; +use http::{HeaderValue, StatusCode}; +use kanidm_proto::v1::OperationError; +use utoipa::ToSchema; + +/// The web app's top level error type, this takes an `OperationError` and converts it into a HTTP response. +#[derive(Debug, ToSchema)] +pub enum WebError { + /// Something went wrong when doing things. + OperationError(OperationError), + InternalServerError(String), +} + +impl From for WebError { + fn from(inner: OperationError) -> Self { + WebError::OperationError(inner) + } +} + +impl WebError { + pub(crate) fn response_with_access_control_origin_header(self) -> Response { + let mut res = self.into_response(); + res.headers_mut().insert( + ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_str("*").expect("Header generation failed, this is weird."), + ); + res + } +} + +impl IntoResponse for WebError { + fn into_response(self) -> Response { + match self { + WebError::InternalServerError(inner) => { + (StatusCode::INTERNAL_SERVER_ERROR, inner).into_response() + } + WebError::OperationError(inner) => { + let (response_code, headers) = match &inner { + OperationError::NotAuthenticated | OperationError::SessionExpired => { + // https://datatracker.ietf.org/doc/html/rfc7235#section-4.1 + ( + StatusCode::UNAUTHORIZED, + // Some([("WWW-Authenticate", "Bearer")]), + Some([("WWW-Authenticate", "Bearer"); 1]), + ) + } + OperationError::SystemProtectedObject | OperationError::AccessDenied => { + (StatusCode::FORBIDDEN, None) + } + OperationError::NoMatchingEntries => (StatusCode::NOT_FOUND, None), + OperationError::PasswordQuality(_) + | OperationError::EmptyRequest + | OperationError::SchemaViolation(_) => (StatusCode::BAD_REQUEST, None), + _ => (StatusCode::INTERNAL_SERVER_ERROR, None), + }; + let body = + serde_json::to_string(&inner).unwrap_or_else(|_err| format!("{:?}", inner)); + match headers { + Some(headers) => (response_code, headers, body).into_response(), + None => (response_code, body).into_response(), + } + } + } + } +} diff --git a/server/core/src/https/extractors/mod.rs b/server/core/src/https/extractors/mod.rs index 0770b1517..04d3b20a6 100644 --- a/server/core/src/https/extractors/mod.rs +++ b/server/core/src/https/extractors/mod.rs @@ -4,13 +4,14 @@ use axum::{ http::{header::HeaderName, request::Parts, StatusCode}, RequestPartsExt, }; +use kanidm_proto::constants::X_FORWARDED_FOR; use std::net::{IpAddr, SocketAddr}; use crate::https::ServerState; #[allow(clippy::declare_interior_mutable_const)] -const X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for"); +const X_FORWARDED_FOR_HEADER: HeaderName = HeaderName::from_static(X_FORWARDED_FOR); pub struct TrustedClientIp(pub IpAddr); @@ -24,7 +25,7 @@ impl FromRequestParts for TrustedClientIp { state: &ServerState, ) -> Result { if state.trust_x_forward_for { - if let Some(x_forward_for) = parts.headers.get(X_FORWARDED_FOR) { + if let Some(x_forward_for) = parts.headers.get(X_FORWARDED_FOR_HEADER) { // X forward for may be comma separate. let first = x_forward_for .to_str() diff --git a/server/core/src/https/generic.rs b/server/core/src/https/generic.rs index 4a5ec6f67..e9fcbd2e7 100644 --- a/server/core/src/https/generic.rs +++ b/server/core/src/https/generic.rs @@ -1,26 +1,45 @@ use axum::extract::State; -use axum::response::{IntoResponse, Response}; -use axum::Extension; +use axum::response::IntoResponse; +use axum::routing::get; +use axum::{Extension, Router}; use http::header::CONTENT_TYPE; use kanidmd_lib::status::StatusRequestEvent; use super::middleware::KOpId; use super::ServerState; -/// Status endpoint used for healthchecks +#[utoipa::path( + get, + path = "/status", + responses( + (status = 200, description = "Ok"), + ), + tag = "system", + +)] +/// Status endpoint used for health checks, returns true when the server is up. pub async fn status( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> String { let r = state .status_ref .handle_request(StatusRequestEvent { eventid: kopid.eventid, }) .await; - Response::new(format!("{}", r)) + format!("{}", r) } +#[utoipa::path( + get, + path = "/robots.txt", + responses( + (status = 200, description = "Ok"), + ), + tag = "ui", + +)] pub async fn robots_txt() -> impl IntoResponse { ( [(CONTENT_TYPE, "text/plain;charset=utf-8")], @@ -31,3 +50,9 @@ pub async fn robots_txt() -> impl IntoResponse { ), ) } + +pub(crate) fn route_setup() -> Router { + Router::new() + .route("/robots.txt", get(robots_txt)) + .route("/status", get(status)) +} diff --git a/server/core/src/https/middleware/caching.rs b/server/core/src/https/middleware/caching.rs index 61df78ad1..0303ff33d 100644 --- a/server/core/src/https/middleware/caching.rs +++ b/server/core/src/https/middleware/caching.rs @@ -1,4 +1,5 @@ use axum::{ + headers::{CacheControl, HeaderMapExt}, http::{self, Request}, middleware::Next, response::Response, @@ -18,3 +19,19 @@ pub async fn dont_cache_me(request: Request, next: Next) -> Response { response } + +/// Adds `no-cache max-age=0` to the response headers. +pub async fn cache_me(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + let cache_header = CacheControl::new() + .with_max_age(std::time::Duration::from_secs(300)) + .with_private(); + + response.headers_mut().typed_insert(cache_header); + response.headers_mut().insert( + http::header::PRAGMA, + http::HeaderValue::from_static("no-cache"), + ); + + response +} diff --git a/server/core/src/https/middleware/mod.rs b/server/core/src/https/middleware/mod.rs index 9ad40082c..37b589e04 100644 --- a/server/core/src/https/middleware/mod.rs +++ b/server/core/src/https/middleware/mod.rs @@ -6,8 +6,8 @@ use axum::{ TypedHeader, }; use http::HeaderValue; +use kanidm_proto::constants::{KOPID, KVERSION}; use uuid::Uuid; - pub(crate) mod caching; pub(crate) mod compression; pub(crate) mod hsts_header; @@ -21,14 +21,16 @@ pub async fn version_middleware(request: Request, next: Next) -> Respon let mut response = next.run(request).await; response .headers_mut() - .insert("X-KANIDM-VERSION", HeaderValue::from_static(KANIDM_VERSION)); + .insert(KVERSION, HeaderValue::from_static(KANIDM_VERSION)); response } #[derive(Clone, Debug)] /// For holding onto the event ID and other handy request-based things pub struct KOpId { + /// The event correlation ID pub eventid: Uuid, + /// The User Access Token, if present pub uat: Option, } @@ -45,7 +47,9 @@ pub async fn are_we_json_yet(request: Request, next: Next) -> Response assert!(headers.contains_key(http::header::CONTENT_TYPE)); assert!( headers.get(http::header::CONTENT_TYPE) - == Some(&HeaderValue::from_static(crate::https::APPLICATION_JSON)) + == Some(&HeaderValue::from_static( + kanidm_proto::constants::APPLICATION_JSON + )) ); } @@ -72,7 +76,7 @@ pub async fn kopid_middleware( // This conversion *should never* fail. If it does, rather than panic, we warn and // just don't put the id in the response. let _ = HeaderValue::from_str(&eventid.as_hyphenated().to_string()) - .map(|hv| response.headers_mut().insert("X-KANIDM-OPID", hv)) + .map(|hv| response.headers_mut().insert(KOPID, hv)) .map_err(|err| { warn!(?err, "An invalid operation id was encountered"); }); diff --git a/server/core/src/https/mod.rs b/server/core/src/https/mod.rs index d07ec2100..0d962bbc6 100644 --- a/server/core/src/https/mod.rs +++ b/server/core/src/https/mod.rs @@ -1,3 +1,6 @@ +mod apidocs; +pub(crate) mod errors; + mod extractors; mod generic; mod javascript; @@ -8,35 +11,31 @@ mod tests; pub(crate) mod trace; mod ui; mod v1; +mod v1_oauth2; mod v1_scim; -use self::generic::*; use self::javascript::*; use crate::actors::v1_read::QueryServerReadV1; use crate::actors::v1_write::QueryServerWriteV1; use crate::config::{Configuration, ServerRole, TlsConfiguration}; use axum::extract::connect_info::{IntoMakeServiceWithConnectInfo, ResponseFuture}; use axum::middleware::{from_fn, from_fn_with_state}; -use axum::response::{IntoResponse, Redirect, Response}; +use axum::response::Redirect; use axum::routing::*; use axum::Router; use axum_csp::{CspDirectiveType, CspValue}; use axum_macros::FromRef; use compact_jwt::{Jws, JwsSigner, JwsUnverified}; -use http::header::{ACCESS_CONTROL_ALLOW_ORIGIN, CONTENT_TYPE}; -use http::{HeaderMap, HeaderValue, StatusCode}; +use http::{HeaderMap, HeaderValue}; use hyper::server::accept::Accept; use hyper::server::conn::{AddrStream, Http}; -use hyper::Body; -use kanidm_proto::constants::APPLICATION_JSON; -use kanidm_proto::v1::OperationError; +use kanidm_proto::constants::KSESSIONID; use kanidmd_lib::status::StatusActor; use openssl::ssl::{Ssl, SslAcceptor, SslFiletype, SslMethod}; use sketching::*; use tokio_openssl::SslStream; use futures_util::future::poll_fn; -use serde::Serialize; use tokio::net::TcpListener; use tracing::Level; @@ -82,7 +81,7 @@ impl ServerState { fn get_current_auth_session_id(&self, headers: &HeaderMap) -> Option { // We see if there is a signed header copy first. headers - .get("X-KANIDM-AUTH-SESSION-ID") + .get(KSESSIONID) .and_then(|hv| { // Get the first header value. hv.to_str().ok() @@ -98,32 +97,39 @@ pub fn get_js_files(role: ServerRole) -> Vec { // let's set up the list of js module hashes { let filepath = "wasmloader.js"; - #[allow(clippy::unwrap_used)] - js_files.push(JavaScriptFile { + match generate_integrity_hash(format!( + "{}/{}", + env!("KANIDM_WEB_UI_PKG_PATH").to_owned(), filepath, - hash: generate_integrity_hash(format!( - "{}/{}", - env!("KANIDM_WEB_UI_PKG_PATH").to_owned(), + )) { + Ok(hash) => js_files.push(JavaScriptFile { filepath, - )) - .unwrap(), - filetype: Some("module".to_string()), - }); + hash, + filetype: Some("module".to_string()), + }), + Err(err) => { + admin_error!(?err, "Failed to generate integrity hash for wasmloader.js") + } + }; } // let's set up the list of non-module hashes { let filepath = "external/bootstrap.bundle.min.js"; - #[allow(clippy::unwrap_used)] - js_files.push(JavaScriptFile { + match generate_integrity_hash(format!( + "{}/{}", + env!("KANIDM_WEB_UI_PKG_PATH").to_owned(), filepath, - hash: generate_integrity_hash(format!( - "{}/{}", - env!("KANIDM_WEB_UI_PKG_PATH").to_owned(), + )) { + Ok(hash) => + js_files.push(JavaScriptFile { filepath, - )) - .unwrap(), - filetype: None, - }); + hash, + filetype: None, + }), + Err(err) => { + admin_error!(?err, "Failed to generate integrity hash for bootstrap.bundle.min.js") + } + } } }; js_files @@ -197,28 +203,25 @@ pub async fn create_https_server( let static_routes = match config.role { ServerRole::WriteReplica | ServerRole::ReadOnlyReplica => { // Create a spa router that captures everything at ui without key extraction. - let spa_router = Router::new() - .route("/", get(crate::https::ui::ui_handler)) - .fallback(crate::https::ui::ui_handler); Router::new() // direct users to the base app page. If a login is required, // then views will take care of redirection. We shouldn't redir // to login because that force clears previous sessions! .route("/", get(|| async { Redirect::temporary("/ui") })) - .route("/manifest.webmanifest", get(manifest::manifest)) - .nest("/ui", spa_router) + .route("/manifest.webmanifest", get(manifest::manifest)) // skip_route_check + .nest("/ui", ui::spa_router()) .layer(middleware::compression::new()) .route("/ui/images/oauth2/:rs_name", get(oauth2::oauth2_image_get)) + // skip_route_check } ServerRole::WriteReplicaNoUI => Router::new(), }; let app = Router::new() - .route("/robots.txt", get(robots_txt)) - .route("/status", get(status)) - .merge(oauth2::oauth2_route_setup(state.clone())) - .merge(v1_scim::scim_route_setup()) - .merge(v1::router(state.clone())); + .merge(generic::route_setup()) + .merge(oauth2::route_setup(state.clone())) + .merge(v1_scim::route_setup()) + .merge(v1::route_setup(state.clone())); let app = match config.role { ServerRole::WriteReplicaNoUI => app, @@ -265,6 +268,7 @@ pub async fn create_https_server( // to be exited, and this middleware sets up ids' and other bits for for logging // coherence to be maintained. .layer(from_fn(middleware::kopid_middleware)) + .merge(apidocs::router()) // this MUST be the last layer before with_state else the span never starts and everything breaks. .layer(trace_layer) .with_state(state) @@ -402,85 +406,3 @@ pub(crate) async fn handle_conn( } } } - -/// Convert any kind of Result into an axum response with a stable type -/// by JSON-encoding the body. -#[instrument(name = "to_axum_response", level = "debug")] -pub fn to_axum_response( - v: Result, -) -> Response { - match v { - Ok(iv) => { - let body = match serde_json::to_string(&iv) { - Ok(val) => val, - Err(err) => { - error!("Failed to serialise response: {:?}", err); - format!("{:?}", iv) - } - }; - trace!("Response Body: {:?}", body); - #[allow(clippy::unwrap_used)] - Response::builder() - .header(CONTENT_TYPE, APPLICATION_JSON) - .body(Body::from(body)) - .unwrap() - } - Err(e) => { - debug!("OperationError: {:?}", e); - let res = match &e { - OperationError::NotAuthenticated | OperationError::SessionExpired => { - // https://datatracker.ietf.org/doc/html/rfc7235#section-4.1 - Response::builder() - .status(http::StatusCode::UNAUTHORIZED) - .header("WWW-Authenticate", "Bearer") - } - OperationError::SystemProtectedObject | OperationError::AccessDenied => { - Response::builder().status(http::StatusCode::FORBIDDEN) - } - OperationError::NoMatchingEntries => { - Response::builder().status(http::StatusCode::NOT_FOUND) - } - OperationError::PasswordQuality(_) - | OperationError::EmptyRequest - | OperationError::SchemaViolation(_) => { - Response::builder().status(http::StatusCode::BAD_REQUEST) - } - _ => Response::builder().status(http::StatusCode::INTERNAL_SERVER_ERROR), - }; - match serde_json::to_string(&e) { - #[allow(clippy::expect_used)] - Ok(val) => res - .body(Body::from(val)) - .expect("Failed to build response!"), - #[allow(clippy::expect_used)] - Err(_) => res - .body(Body::from(format!("{:?}", e))) - .expect("Failed to build response!"), - } - } - } -} - -/// Wrapper for the externally-defined error type from the protocol -pub struct HttpOperationError(OperationError); - -impl IntoResponse for HttpOperationError { - fn into_response(self) -> Response { - let HttpOperationError(error) = self; - - let body = match serde_json::to_string(&error) { - Ok(val) => val, - Err(e) => { - admin_warn!("Failed to serialize error response: original_error=\"{:?}\" serialization_error=\"{:?}\"", error , e); - format!("{:?}", error) - } - }; - #[allow(clippy::unwrap_used)] - Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(Body::from(body)) - .unwrap() - .into_response() - } -} diff --git a/server/core/src/https/oauth2.rs b/server/core/src/https/oauth2.rs index abe72a1a0..3d63f3673 100644 --- a/server/core/src/https/oauth2.rs +++ b/server/core/src/https/oauth2.rs @@ -1,12 +1,13 @@ +use super::errors::WebError; use super::middleware::KOpId; -use super::v1::{json_rest_event_get, json_rest_event_post}; -use super::{to_axum_response, HttpOperationError, ServerState}; +use super::ServerState; use axum::extract::{Path, Query, State}; use axum::middleware::from_fn; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Extension, Form, Json, Router}; use axum_macros::debug_handler; +use compact_jwt::{JwkKeySet, OidcToken}; use http::header::{ ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_ORIGIN, AUTHORIZATION, CONTENT_TYPE, LOCATION, WWW_AUTHENTICATE, @@ -14,9 +15,7 @@ use http::header::{ use http::{HeaderMap, HeaderValue, StatusCode}; use hyper::Body; use kanidm_proto::constants::APPLICATION_JSON; -use kanidm_proto::internal::{ImageType, ImageValue}; -use kanidm_proto::oauth2::{AuthorisationResponse, OidcDiscoveryResponse}; -use kanidm_proto::v1::Entry as ProtoEntry; +use kanidm_proto::oauth2::{AuthorisationResponse, OidcDiscoveryResponse, AccessTokenResponse}; use kanidmd_lib::idm::oauth2::{ AccessTokenIntrospectRequest, AccessTokenRequest, AuthorisationRequest, AuthorisePermitSuccess, AuthoriseResponse, ErrorResponse, Oauth2Error, TokenRevokeRequest, @@ -24,9 +23,9 @@ use kanidmd_lib::idm::oauth2::{ use kanidmd_lib::prelude::f_eq; use kanidmd_lib::prelude::*; use kanidmd_lib::value::PartialValue; -use kanidmd_lib::valueset::image::ImageValueThings; use serde::{Deserialize, Serialize}; +// TODO: merge this into a value in WebError later pub struct HTTPOauth2Error(Oauth2Error); impl IntoResponse for HTTPOauth2Error { @@ -34,17 +33,18 @@ impl IntoResponse for HTTPOauth2Error { let HTTPOauth2Error(error) = self; if let Oauth2Error::AuthenticationRequired = error { - #[allow(clippy::unwrap_used)] - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header(WWW_AUTHENTICATE, "Bearer") - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(Body::empty()) - .unwrap() + ( + StatusCode::UNAUTHORIZED, + [ + (WWW_AUTHENTICATE, "Bearer"), + (ACCESS_CONTROL_ALLOW_ORIGIN, "*"), + ], + ) + .into_response() } else { let err = ErrorResponse { error: error.to_string(), - ..Default::default() + ..Default::default() }; let body = match serde_json::to_string(&err) { @@ -54,299 +54,71 @@ impl IntoResponse for HTTPOauth2Error { format!("{:?}", err) } }; - #[allow(clippy::unwrap_used)] - Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(Body::from(body)) - .unwrap() + ( + StatusCode::BAD_REQUEST, + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], + body, + ) + .into_response() } - .into_response() } } // == Oauth2 Configuration Endpoints == -/// List all the OAuth2 Resource Servers -pub async fn oauth2_get( - State(state): State, - Extension(kopid): Extension, -) -> impl IntoResponse { - let filter = filter_all!(f_eq( - Attribute::Class, - EntryClass::OAuth2ResourceServer.into() - )); - json_rest_event_get(state, None, filter, kopid).await -} - -pub async fn oauth2_basic_post( - State(state): State, - Extension(kopid): Extension, - Json(obj): Json, -) -> impl IntoResponse { - let classes = vec![ - EntryClass::OAuth2ResourceServer.to_string(), - EntryClass::OAuth2ResourceServerBasic.to_string(), - EntryClass::Object.to_string(), - ]; - json_rest_event_post(state, classes, obj, kopid).await -} - -pub async fn oauth2_public_post( - State(state): State, - Extension(kopid): Extension, - Json(obj): Json, -) -> impl IntoResponse { - let classes = vec![ - EntryClass::OAuth2ResourceServer.to_string(), - EntryClass::OAuth2ResourceServerPublic.to_string(), - EntryClass::Object.to_string(), - ]; - json_rest_event_post(state, classes, obj, kopid).await -} - /// Get a filter matching a given OAuth2 Resource Server -fn oauth2_id(rs_name: &str) -> Filter { +pub(crate) fn oauth2_id(rs_name: &str) -> Filter { filter_all!(f_and!([ f_eq(Attribute::Class, EntryClass::OAuth2ResourceServer.into()), f_eq(Attribute::OAuth2RsName, PartialValue::new_iname(rs_name)) ])) } -pub async fn oauth2_id_get( - State(state): State, - Path(rs_name): Path, - Extension(kopid): Extension, -) -> Response { - let filter = oauth2_id(&rs_name); - - let res = state - .qe_r_ref - .handle_internalsearch(kopid.uat, filter, None, kopid.eventid) - .await - .map(|mut r| r.pop()); - to_axum_response(res) -} - -#[instrument(level = "info", skip(state))] -pub async fn oauth2_id_get_basic_secret( +#[utoipa::path( + get, + path = "/ui/images/oauth2/{rs_name}", + params( + super::apidocs::path_schema::RsName + ), + responses( + (status = 200, description = "Ok", body=&[u8]), + (status = 403, description = "Authorization refused"), + (status = 403, description = "Authorization refused"), + ), + security(("token_jwt" = [])), + tag = "ui", +)] +/// This returns the image for the OAuth2 Resource Server if the user has permissions +/// +pub(crate) async fn oauth2_image_get( State(state): State, Extension(kopid): Extension, Path(rs_name): Path, -) -> Response { - let filter = oauth2_id(&rs_name); - let res = state - .qe_r_ref - .handle_oauth2_basic_secret_read(kopid.uat, filter, kopid.eventid) - .await; - to_axum_response(res) -} - -pub async fn oauth2_id_patch( - State(state): State, - Path(rs_name): Path, - Extension(kopid): Extension, - Json(obj): Json, -) -> Response { - let filter = oauth2_id(&rs_name); - - let res = state - .qe_w_ref - .handle_internalpatch(kopid.uat, filter, obj, kopid.eventid) - .await; - to_axum_response(res) -} - -pub async fn oauth2_id_scopemap_post( - State(state): State, - Extension(kopid): Extension, - Path((rs_name, group)): Path<(String, String)>, - Json(scopes): Json>, -) -> Response { - let filter = oauth2_id(&rs_name); - let res = state - .qe_w_ref - .handle_oauth2_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid) - .await; - to_axum_response(res) -} - -pub async fn oauth2_id_scopemap_delete( - State(state): State, - Extension(kopid): Extension, - Path((rs_name, group)): Path<(String, String)>, -) -> Response { - let filter = oauth2_id(&rs_name); - let res = state - .qe_w_ref - .handle_oauth2_scopemap_delete(kopid.uat, group, filter, kopid.eventid) - .await; - to_axum_response(res) -} - -pub async fn oauth2_id_sup_scopemap_post( - State(state): State, - Extension(kopid): Extension, - Path((rs_name, group)): Path<(String, String)>, - Json(scopes): Json>, -) -> Response { - let filter = oauth2_id(&rs_name); - let res = state - .qe_w_ref - .handle_oauth2_sup_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid) - .await; - to_axum_response(res) -} - -pub async fn oauth2_id_sup_scopemap_delete( - State(state): State, - Extension(kopid): Extension, - Path((rs_name, group)): Path<(String, String)>, -) -> Response { - let filter = oauth2_id(&rs_name); - let res = state - .qe_w_ref - .handle_oauth2_sup_scopemap_delete(kopid.uat, group, filter, kopid.eventid) - .await; - to_axum_response(res) -} - -pub async fn oauth2_id_delete( - State(state): State, - Extension(kopid): Extension, - Path(rs_name): Path, -) -> Response { - let filter = oauth2_id(&rs_name); - let res = state - .qe_w_ref - .handle_internaldelete(kopid.uat, filter, kopid.eventid) - .await; - to_axum_response(res) -} - -/// this returns the image for the user if the user has permissions -pub async fn oauth2_image_get( - State(state): State, - Extension(kopid): Extension, - Path(rs_name): Path, -) -> Response { +) -> Response { let rs_filter = oauth2_id(&rs_name); let res = state .qe_r_ref .handle_oauth2_rs_image_get_image(kopid.uat, rs_filter) .await; - let image = match res { - Ok(image) => image, - Err(_err) => { - admin_error!( - "Unable to get image for oauth2 resource server: {}", - rs_name + match res { + Ok(image) => ( + StatusCode::OK, + [(CONTENT_TYPE, image.filetype.as_content_type_str())], + image.contents, + ) + .into_response(), + Err(err) => { + admin_debug!( + "Unable to get image for oauth2 resource server {}: {:?}", + rs_name, + err ); - #[allow(clippy::unwrap_used)] - return Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::empty()) - .unwrap(); + // TODO: a 404 probably isn't perfect but it's not the worst + (StatusCode::NOT_FOUND, "").into_response() } - }; - - #[allow(clippy::expect_used)] - Response::builder() - .header(CONTENT_TYPE, image.filetype.as_content_type_str()) - .body(Body::from(image.contents)) - .expect("Somehow failed to turn an image into a response!") -} - -pub async fn oauth2_id_image_delete( - State(state): State, - Extension(kopid): Extension, - Path(rs_name): Path, -) -> Response { - let rs_filter = oauth2_id(&rs_name); - let res = state - .qe_w_ref - .handle_oauth2_rs_image_delete(kopid.uat, rs_filter) - .await; - - to_axum_response(res) -} - -pub async fn oauth2_id_image_post( - State(state): State, - Extension(kopid): Extension, - Path(rs_name): Path, - mut multipart: axum::extract::Multipart, -) -> Response { - // because we might not get an image - let mut image: Option = None; - - while let Some(field) = multipart.next_field().await.unwrap_or(None) { - let filename = field.file_name().map(|f| f.to_string()).clone(); - if let Some(filename) = filename { - let content_type = field.content_type().map(|f| f.to_string()).clone(); - - let content_type = match content_type { - Some(val) => { - if VALID_IMAGE_UPLOAD_CONTENT_TYPES.contains(&val.as_str()) { - val - } else { - debug!("Invalid content type: {}", val); - let res = - to_axum_response::(Err(OperationError::InvalidRequestState)); - return res; - } - } - None => { - debug!("No content type header provided"); - let res = to_axum_response::(Err(OperationError::InvalidRequestState)); - return res; - } - }; - let data = match field.bytes().await { - Ok(val) => val, - Err(_e) => { - let res = to_axum_response::(Err(OperationError::InvalidRequestState)); - return res; - } - }; - - let filetype = match ImageType::try_from_content_type(&content_type) { - Ok(val) => val, - Err(_err) => { - let res = to_axum_response::(Err(OperationError::InvalidRequestState)); - return res; - } - }; - - image = Some(ImageValue { - filetype, - filename: filename.to_string(), - contents: data.to_vec(), - }); - }; } - - let res = match image { - Some(image) => { - let image_validation_result = image.validate_image(); - if let Err(err) = image_validation_result { - admin_error!("Invalid image uploaded: {:?}", err); - return to_axum_response::(Err(OperationError::InvalidRequestState)); - } - - let rs_name = oauth2_id(&rs_name); - state - .qe_w_ref - .handle_oauth2_rs_image_update(kopid.uat, rs_name, image) - .await - } - None => Err(OperationError::InvalidAttribute( - "No image included, did you mean to use the DELETE method?".to_string(), - )), - }; - to_axum_response(res) } // == OAUTH2 PROTOCOL FLOW HANDLERS == @@ -701,7 +473,7 @@ pub async fn oauth2_token_post( Extension(kopid): Extension, headers: HeaderMap, Form(tok_req): Form, -) -> Result, HTTPOauth2Error> { +) -> Result, HTTPOauth2Error> { // This is called directly by the resource server, where we then issue // the token to the caller. @@ -731,7 +503,7 @@ pub async fn oauth2_openid_discovery_get( State(state): State, Path(client_id): Path, Extension(kopid): Extension, -) -> Result, HttpOperationError> { +) -> Result, Response> { // let client_id = req.get_url_param("client_id")?; let res = state @@ -743,7 +515,7 @@ pub async fn oauth2_openid_discovery_get( Ok(dsc) => Ok(Json(dsc)), Err(e) => { error!(err = ?e, "Unable to access discovery info"); - Err(HttpOperationError(e)) + Err(WebError::from(e).response_with_access_control_origin_header()) } } } @@ -753,7 +525,7 @@ pub async fn oauth2_openid_userinfo_get( State(state): State, Path(client_id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result, HTTPOauth2Error> { // The token we want to inspect is in the authorisation header. let client_token = match kopid.uat { Some(val) => val, @@ -778,13 +550,13 @@ pub async fn oauth2_openid_publickey_get( State(state): State, Path(client_id): Path, Extension(kopid): Extension, -) -> Response { - to_axum_response( - state - .qe_r_ref - .handle_oauth2_openid_publickey(client_id, kopid.eventid) - .await, - ) +) -> Result, WebError> { + state + .qe_r_ref + .handle_oauth2_openid_publickey(client_id, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) } /// This is called directly by the resource server, where we then issue @@ -887,12 +659,12 @@ pub async fn oauth2_token_revoke_post( Some(val) => val, None => { - #[allow(clippy::unwrap_used)] - return Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(Body::empty()) - .unwrap() + return ( + StatusCode::UNAUTHORIZED, + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], + "" + ) + .into_response(); } }; @@ -906,21 +678,17 @@ pub async fn oauth2_token_revoke_post( match res { Ok(()) => { - #[allow(clippy::unwrap_used)] - Response::builder() - .status(StatusCode::OK) - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(Body::empty()) - .unwrap() + (StatusCode::OK, + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*")], + "" + ).into_response() } Err(Oauth2Error::AuthenticationRequired) => { // This will trigger our ui to auth and retry. - #[allow(clippy::unwrap_used)] - Response::builder() - .status(StatusCode::UNAUTHORIZED) - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(Body::empty()) - .unwrap() + (StatusCode::UNAUTHORIZED, + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),], + "" + ).into_response() } Err(e) => { // https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 @@ -928,30 +696,27 @@ pub async fn oauth2_token_revoke_post( error: e.to_string(), ..Default::default() }; - #[allow(clippy::unwrap_used)] - Response::builder() - .status(StatusCode::BAD_REQUEST) - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .body(Body::from( - serde_json::to_string(&err).unwrap_or("".to_string()), - )) - .unwrap() + (StatusCode::BAD_REQUEST, + [(ACCESS_CONTROL_ALLOW_ORIGIN, "*"),], + serde_json::to_string(&err).unwrap_or("".to_string()), + ).into_response() } } } // Some requests from browsers require preflight so that CORS works. -pub async fn oauth2_preflight_options() -> Response { - #[allow(clippy::unwrap_used)] - Response::builder() - .status(StatusCode::OK) - .header(ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .header(ACCESS_CONTROL_ALLOW_HEADERS, "Authorization") - .body(Body::empty()) - .unwrap() +pub async fn oauth2_preflight_options() -> Response { + ( + StatusCode::OK, + [ + (ACCESS_CONTROL_ALLOW_ORIGIN, "*"), + (ACCESS_CONTROL_ALLOW_HEADERS, "Authorization"), + ], + String::new(), + ).into_response() } -pub fn oauth2_route_setup(state: ServerState) -> Router { +pub fn route_setup(state: ServerState) -> Router { // this has all the openid-related routes let openid_router = Router::new() // // ⚠️ ⚠️ WARNING ⚠️ ⚠️ @@ -975,7 +740,7 @@ pub fn oauth2_route_setup(state: ServerState) -> Router { .with_state(state.clone()); Router::new() - .route("/oauth2", get(oauth2_get)) + .route("/oauth2", get(super::v1_oauth2::oauth2_get)) // ⚠️ ⚠️ WARNING ⚠️ ⚠️ // IF YOU CHANGE THESE VALUES YOU MUST UPDATE OIDC DISCOVERY URLS .route( diff --git a/server/core/src/https/scim/sink.html b/server/core/src/https/scim/sink.html new file mode 100644 index 000000000..7b18755c5 --- /dev/null +++ b/server/core/src/https/scim/sink.html @@ -0,0 +1,29 @@ + + + + + 🚰 Sink! + + + + + + +
+                        ___
+                      .' _ '.
+                     / /` `\ \
+                     | |   [__]
+                     | |    {{
+                     | |    }}
+                  _  | |  _ {{
+      ___________<_>_| |_<_>}}________d
+          .=======^=(___)=^={{====.
+         / .----------------}}---. \
+        / /                 {{    \ \
+       / /                  }}     \ \
+      (  '========================='  )
+       '-----------------------------'
+            
+ + \ No newline at end of file diff --git a/server/core/src/https/ui.rs b/server/core/src/https/ui.rs index b30b49f19..0ec244354 100644 --- a/server/core/src/https/ui.rs +++ b/server/core/src/https/ui.rs @@ -1,13 +1,20 @@ use axum::extract::State; use axum::http::HeaderValue; use axum::response::Response; -use axum::Extension; +use axum::routing::get; +use axum::{Extension, Router}; use http::header::CONTENT_TYPE; use super::middleware::KOpId; use super::ServerState; -pub async fn ui_handler( +pub(crate) fn spa_router() -> Router { + Router::new() + .route("/", get(ui_handler)) + .fallback(ui_handler) +} + +pub(crate) async fn ui_handler( State(state): State, Extension(kopid): Extension, ) -> Response { diff --git a/server/core/src/https/v1.rs b/server/core/src/https/v1.rs index 6f5e60926..8b6e91d21 100644 --- a/server/core/src/https/v1.rs +++ b/server/core/src/https/v1.rs @@ -1,123 +1,206 @@ //! The V1 API things! +use std::net::IpAddr; + use axum::extract::{Path, Query, State}; -use axum::headers::{CacheControl, HeaderMapExt}; use axum::middleware::from_fn; use axum::response::{IntoResponse, Response}; use axum::routing::{delete, get, post, put}; use axum::{Extension, Json, Router}; -use axum_macros::debug_handler; use compact_jwt::Jws; -use http::{HeaderMap, HeaderValue, StatusCode}; -use hyper::Body; +use http::{HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use kanidm_proto::internal::IdentifyUserRequest; +use kanidm_proto::internal::{AppLink, IdentifyUserRequest, IdentifyUserResponse}; use kanidm_proto::v1::{ - AccountUnixExtend, ApiTokenGenerate, AuthIssueSession, AuthRequest, AuthResponse, - AuthState as ProtoAuthState, CUIntentToken, CURequest, CUSessionToken, CreateRequest, - DeleteRequest, Entry as ProtoEntry, GroupUnixExtend, ModifyRequest, SearchRequest, - SingleStringRequest, + AccountUnixExtend, ApiToken, ApiTokenGenerate, AuthIssueSession, AuthRequest, AuthResponse, + AuthState as ProtoAuthState, CUIntentToken, CURequest, CUSessionToken, CUStatus, CreateRequest, + CredentialStatus, DeleteRequest, Entry as ProtoEntry, GroupUnixExtend, ModifyRequest, + RadiusAuthToken, SearchRequest, SearchResponse, SingleStringRequest, UatStatus, UnixGroupToken, + UnixUserToken, UserAuthToken, WhoamiResponse, }; use kanidmd_lib::idm::event::AuthResult; use kanidmd_lib::idm::AuthState; use kanidmd_lib::prelude::*; use kanidmd_lib::value::PartialValue; -use crate::https::extractors::TrustedClientIp; -use crate::https::to_axum_response; +use super::apidocs::path_schema; -use super::middleware::caching::dont_cache_me; +use super::errors::WebError; +use super::middleware::caching::{cache_me, dont_cache_me}; use super::middleware::KOpId; -use super::v1_scim::*; use super::ServerState; +use crate::https::apidocs::response_schema::{ApiResponseWithout200, DefaultApiResponse}; +use crate::https::extractors::TrustedClientIp; #[derive(Serialize, Deserialize, Debug, Clone)] pub(crate) struct SessionId { pub sessionid: Uuid, } -#[debug_handler] -pub async fn create( +#[utoipa::path( + post, + path = "/v1/raw/create", + responses( + DefaultApiResponse, + ), + request_body=CreateRequest, + security(("token_jwt" = [])), + tag = "v1/raw", +)] +/// Raw request to the system, be warned this can be dangerous! +pub async fn raw_create( State(state): State, Extension(kopid): Extension, Json(msg): Json, -) -> Response { - // parse the req to a CreateRequest - // let msg: CreateRequest = req.body_json().await?; - - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_create(kopid.uat, msg, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn v1_modify( +#[utoipa::path( + post, + path = "/v1/raw/modify", + responses( + DefaultApiResponse, + ), + request_body=ModifyRequest, + security(("token_jwt" = [])), + tag = "v1/raw", +)] +/// Raw request to the system, be warned this can be dangerous! +pub async fn raw_modify( State(state): State, Extension(kopid): Extension, Json(msg): Json, -) -> Response { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_modify(kopid.uat, msg, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn v1_delete( +#[utoipa::path( + post, + path = "/v1/raw/delete", + responses( + DefaultApiResponse, + ), + request_body=DeleteRequest, + security(("token_jwt" = [])), + tag = "v1/raw", +)] +/// Raw request to the system, be warned this can be dangerous! +pub async fn raw_delete( State(state): State, Extension(kopid): Extension, Json(msg): Json, -) -> Response { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_delete(kopid.uat, msg, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn search( +#[utoipa::path( + post, + path = "/v1/raw/search", + responses( + (status = 200), // TODO: response content + ApiResponseWithout200, + ), + request_body=SearchRequest, + security(("token_jwt" = [])), + tag = "v1/raw", +)] +/// Raw request to the system, be warned this can be dangerous! +pub async fn raw_search( State(state): State, Extension(kopid): Extension, Json(msg): Json, -) -> Response { - let res = state +) -> Result, WebError> { + state .qe_r_ref .handle_search(kopid.uat, msg, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -#[debug_handler] +#[utoipa::path( + get, + path = "/v1/self", + responses( + (status = 200), // TODO: response content + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/self", +)] +// Whoami? pub async fn whoami( State(state): State, Extension(kopid): Extension, -) -> Response { +) -> Result, WebError> { // New event, feed current auth data from the token to it. - let res = state.qe_r_ref.handle_whoami(kopid.uat, kopid.eventid).await; - to_axum_response(res) + state + .qe_r_ref + .handle_whoami(kopid.uat, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/self/_uat", + responses( + (status = 200, description = "Ok"), + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/self", +)] pub async fn whoami_uat( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_r_ref .handle_whoami_uat(kopid.uat, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + post, + path = "/v1/logout", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/auth", +)] pub async fn logout( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state.qe_w_ref.handle_logout(kopid.uat, kopid.eventid).await; - - to_axum_response(res) +) -> Result, WebError> { + state + .qe_w_ref + .handle_logout(kopid.uat, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) } // // =============== REST generics ======================== @@ -128,13 +211,13 @@ pub async fn json_rest_event_get( attrs: Option>, filter: Filter, kopid: KOpId, -) -> impl IntoResponse { - let res = state +) -> Result>, WebError> { + state .qe_r_ref .handle_internalsearch(kopid.uat, filter, attrs, kopid.eventid) - .await; - - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } pub async fn json_rest_event_get_id( @@ -143,15 +226,16 @@ pub async fn json_rest_event_get_id( filter: Filter, attrs: Option>, kopid: KOpId, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str()))); - let res = state + state .qe_r_ref .handle_internalsearch(kopid.uat, filter, attrs, kopid.eventid) .await - .map(|mut r| r.pop()); - to_axum_response(res) + .map(|mut r| r.pop()) + .map(Json::from) + .map_err(WebError::from) } pub async fn json_rest_event_delete_id( @@ -159,13 +243,14 @@ pub async fn json_rest_event_delete_id( id: String, filter: Filter, kopid: KOpId, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str()))); - let res = state + state .qe_w_ref .handle_internaldelete(kopid.uat, filter, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } pub async fn json_rest_event_get_attr( @@ -174,15 +259,16 @@ pub async fn json_rest_event_get_attr( attr: String, filter: Filter, kopid: KOpId, -) -> impl IntoResponse { +) -> Result>>, WebError> { let filter = Filter::join_parts_and(filter, filter_all!(f_id(id))); let attrs = Some(vec![attr.clone()]); - let res: Result, _> = state + state .qe_r_ref .handle_internalsearch(kopid.uat, filter, attrs, kopid.eventid) .await - .map(|mut event_result| event_result.pop().and_then(|mut e| e.attrs.remove(&attr))); - to_axum_response(res) + .map(|mut event_result| event_result.pop().and_then(|mut e| e.attrs.remove(&attr))) + .map(Json::from) + .map_err(WebError::from) } pub async fn json_rest_event_get_id_attr( @@ -191,7 +277,7 @@ pub async fn json_rest_event_get_id_attr( attr: String, filter: Filter, kopid: KOpId, -) -> impl IntoResponse { +) -> Result>>, WebError> { json_rest_event_get_attr(state, id.as_str(), attr, filter, kopid).await } @@ -200,7 +286,7 @@ pub async fn json_rest_event_post( classes: Vec, obj: ProtoEntry, kopid: KOpId, -) -> impl IntoResponse { +) -> Result, WebError> { debug_assert!(!classes.is_empty()); let mut obj = obj; @@ -209,11 +295,12 @@ pub async fn json_rest_event_post( entries: vec![obj.to_owned()], }; - let res = state + state .qe_w_ref .handle_create(kopid.uat, msg, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } pub async fn json_rest_event_post_id_attr( @@ -223,12 +310,13 @@ pub async fn json_rest_event_post_id_attr( filter: Filter, values: Vec, kopid: KOpId, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_appendattribute(kopid.uat, id, attr, values, filter, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } pub async fn json_rest_event_put_attr( @@ -238,12 +326,13 @@ pub async fn json_rest_event_put_attr( filter: Filter, values: Vec, kopid: KOpId, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_setattribute(kopid.uat, id, attr, values, filter, kopid.eventid) - .await; - to_axum_response(res) + .await + .map_err(WebError::from) + .map(Json::from) } pub async fn json_rest_event_post_attr( @@ -253,15 +342,24 @@ pub async fn json_rest_event_post_attr( filter: Filter, values: Vec, kopid: KOpId, -) -> impl IntoResponse { - let uuid_or_name = id; - let res = state +) -> Result, WebError> { + state .qe_w_ref - .handle_appendattribute(kopid.uat, uuid_or_name, attr, values, filter, kopid.eventid) - .await; - to_axum_response(res) + .handle_appendattribute(kopid.uat, id, attr, values, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) } +// Okay, so a put normally needs +/// * filter of what we are working on (id + class) +/// * a `Map>` that we turn into a modlist. +/// +/// OR +/// * filter of what we are working on (id + class) +/// * a `Vec` that we are changing +/// * the attr name (as a param to this in path) +/// pub async fn json_rest_event_put_id_attr( state: ServerState, id: String, @@ -269,7 +367,7 @@ pub async fn json_rest_event_put_id_attr( filter: Filter, values: Vec, kopid: KOpId, -) -> impl IntoResponse { +) -> Result, WebError> { json_rest_event_put_attr(state, id, attr, filter, values, kopid).await } @@ -280,7 +378,7 @@ pub async fn json_rest_event_delete_id_attr( filter: Filter, values: Option>, kopid: KOpId, -) -> impl IntoResponse { +) -> Result, WebError> { json_rest_event_delete_attr(state, id, attr, filter, values, kopid).await } @@ -291,20 +389,19 @@ pub async fn json_rest_event_delete_attr( filter: Filter, values: Option>, kopid: KOpId, -) -> impl IntoResponse { +) -> Result, WebError> { let values = match values { Some(val) => val, None => vec![], }; if values.is_empty() { - let res = state + state .qe_w_ref .handle_purgeattribute(kopid.uat, uuid_or_name, attr, filter, kopid.eventid) - .await; - to_axum_response(res) + .await } else { - let res = state + state .qe_w_ref .handle_removeattributevalues( kopid.uat, @@ -314,26 +411,27 @@ pub async fn json_rest_event_delete_attr( filter, kopid.eventid, ) - .await; - to_axum_response(res) + .await } + .map(Json::from) + .map_err(WebError::from) } -// // Okay, so a put normally needs -// // * filter of what we are working on (id + class) -// // * a Map> that we turn into a modlist. -// // -// // OR -// // * filter of what we are working on (id + class) -// // * a Vec that we are changing -// // * the attr name (as a param to this in path) -// // -// // json_rest_event_put_id(path, req, state - +#[utoipa::path( + get, + path = "/v1/schema", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/schema", +)] +// Whoami? pub async fn schema_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { // NOTE: This is filter_all, because from_internal_message will still do the alterations // needed to make it safe. This is needed because there may be aci's that block access // to the recycle/ts types in the filter, and we need the aci to only eval on this @@ -345,19 +443,39 @@ pub async fn schema_get( json_rest_event_get(state, None, filter, kopid).await } +#[utoipa::path( + get, + path = "/v1/schema/attributetype", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/schema", +)] pub async fn schema_attributetype_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::AttributeType.into())); json_rest_event_get(state, None, filter, kopid).await } +#[utoipa::path( + get, + path = "/v1/schema/attributetype/{id}", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/schema", +)] pub async fn schema_attributetype_get_id( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { // These can't use get_id because the attribute name and class name aren't ... well name. let filter = filter_all!(f_and!([ f_eq(Attribute::Class, EntryClass::AttributeType.into()), @@ -367,56 +485,96 @@ pub async fn schema_attributetype_get_id( ) ])); - let res = state + state .qe_r_ref .handle_internalsearch(kopid.uat, filter, None, kopid.eventid) .await - .map(|mut r| r.pop()); - to_axum_response(res) + .map(|mut r| r.pop()) + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/schema/classtype", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/schema", +)] pub async fn schema_classtype_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::ClassType.into())); json_rest_event_get(state, None, filter, kopid).await } +#[utoipa::path( + get, + path = "/v1/schema/classtype/{id}", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/schema", +)] pub async fn schema_classtype_get_id( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { +) -> Result>, WebError> { // These can't use get_id because they attribute name and class name aren't ... well name. let filter = filter_all!(f_and!([ f_eq(Attribute::Class, EntryClass::ClassType.into()), f_eq(Attribute::ClassName, PartialValue::new_iutf8(id.as_str())) ])); - let res = state + state .qe_r_ref .handle_internalsearch(kopid.uat, filter, None, kopid.eventid) .await - .map(|mut r| r.pop()); - to_axum_response(res) + .map(|mut r| r.pop()) + .map(Json::from) + .map_err(WebError::from) } -// // == person == +#[utoipa::path( + get, + path = "/v1/person", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] pub async fn person_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Person.into())); json_rest_event_get(state, None, filter, kopid).await } -// expects the following fields in the attrs field of the req: [name, displayname] -#[debug_handler] +#[utoipa::path( + post, + path = "/v1/person", + responses( + DefaultApiResponse, + ), + request_body=Json, // TODO: ProtoEntry can't be serialized, so we need to do this manually + security(("token_jwt" = [])), + tag = "v1/person", +)] +/// Expects the following fields in the attrs field of the req: [name, displayname] pub async fn person_post( State(state): State, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { +) -> Result, WebError> { let classes: Vec = vec![ EntryClass::Person.into(), EntryClass::Account.into(), @@ -425,39 +583,78 @@ pub async fn person_post( json_rest_event_post(state, classes, obj, kopid).await } +#[utoipa::path( + get, + path = "/v1/person/{id}", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] pub async fn person_id_get( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Person.into())); json_rest_event_get_id(state, id, filter, None, kopid).await } -pub async fn person_account_id_delete( +#[utoipa::path( + delete, + path = "/v1/person/{id}", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] +pub async fn person_id_delete( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Person.into())); json_rest_event_delete_id(state, id, filter, kopid).await } // // == account == +#[utoipa::path( + get, + path = "/v1/service_account", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::ServiceAccount.into())); json_rest_event_get(state, None, filter, kopid).await } +#[utoipa::path( + post, + path = "/v1/service_account", + request_body=Json, // TODO ProtoEntry can't be serialized, so we need to do this manually + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_post( State(state): State, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { +) -> Result, WebError> { let classes: Vec = vec![ EntryClass::ServiceAccount.into(), EntryClass::Account.into(), @@ -466,73 +663,138 @@ pub async fn service_account_post( json_rest_event_post(state, classes, obj, kopid).await } +#[utoipa::path( + get, + path = "/v1/service_account/{id}", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_id_get( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::ServiceAccount.into())); json_rest_event_get_id(state, id, filter, None, kopid).await } +#[utoipa::path( + delete, + path = "/v1/service_account/{id}", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_id_delete( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::ServiceAccount.into())); json_rest_event_delete_id(state, id, filter, kopid).await } +#[utoipa::path( + get, + path = "/v1/service_account/{id}/_credential/_generate", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_credential_generate( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_service_account_credential_generate(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -// // Due to how the migrations work in 6 -> 7, we can accidentally -// // mark "accounts" as service accounts when they are persons. This -// // allows migrating them to the person type due to it's similarities. -// // -// // In the future this will be REMOVED! +#[utoipa::path( + post, + path = "/v1/service_account/{id}/_into_person", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +/// Due to how the migrations work in 6 -> 7, we can accidentally +/// mark "accounts" as service accounts when they are persons. This +/// allows migrating them to the person type due to its similarities. +/// +/// In the future this will be REMOVED! +#[deprecated] pub async fn service_account_into_person( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_service_account_into_person(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -// // Api Token +#[utoipa::path( + get, + path = "/v1/service_account/{id}/_spi_token", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_api_token_get( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result>, WebError> { + state .qe_r_ref .handle_service_account_api_token_get(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + post, + path = "/v1/service_account/{id}/_spi_token", + params( + path_schema::Id, + ), + request_body = ApiTokenGenerate, + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_api_token_post( State(state): State, Extension(kopid): Extension, Path(id): Path, - Json(obj): Json, // TODO work out if this limits the fields? -) -> impl IntoResponse { - let res = state + Json(obj): Json, +) -> Result, WebError> { + state .qe_w_ref .handle_service_account_api_token_generate( kopid.uat, @@ -542,99 +804,267 @@ pub async fn service_account_api_token_post( obj.read_write, kopid.eventid, ) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + delete, + path = "/v1/service_account/{id}/_spi_token/{token_id}", + params( + path_schema::Id, + path_schema::TokenId, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] pub async fn service_account_api_token_delete( State(state): State, Path((id, token_id)): Path<(String, Uuid)>, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_service_account_api_token_destroy(kopid.uat, id, token_id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -// // Account stuff -// TODO: shouldn't this be service_account? -pub async fn account_id_get_attr( +#[utoipa::path( + get, + path = "/v1/person/{id}/_attr/{attr}", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + (status = 403, description = "Authorzation refused"), + ), + security(("token_jwt" = [])), + tag = "v1/person/attr", +)] +pub async fn person_id_get_attr( State(state): State, Path((id, attr)): Path<(String, String)>, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); json_rest_event_get_attr(state, id.as_str(), attr, filter, kopid).await } -pub async fn account_id_post_attr( +#[utoipa::path( + get, + path = "/v1/service_account/{id}/_attr/{attr}", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_get_attr( + State(state): State, + Path((id, attr)): Path<(String, String)>, + Extension(kopid): Extension, +) -> Result>>, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); + json_rest_event_get_attr(state, id.as_str(), attr, filter, kopid).await +} + +#[utoipa::path( + post, + path = "/v1/person/{id}/_attr/{attr}", + params( + path_schema::Id, + path_schema::Attr, + ), + request_body= Json>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/attr", +)] +pub async fn person_id_post_attr( State(state): State, Path((id, attr)): Path<(String, String)>, Extension(kopid): Extension, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); json_rest_event_post_id_attr(state, id, attr, filter, values, kopid).await } -pub async fn account_id_delete_attr( - State(state): State, - Path((id, attr)): Path<(String, String)>, - Extension(kopid): Extension, -) -> impl IntoResponse { - let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); - json_rest_event_delete_id_attr(state, id, attr, filter, None, kopid).await -} - -pub async fn account_id_put_attr( +#[utoipa::path( + post, + path = "/v1/service_account/{id}/_attr/{attr}", + request_body=Json>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_post_attr( State(state): State, Path((id, attr)): Path<(String, String)>, Extension(kopid): Extension, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); + json_rest_event_post_id_attr(state, id, attr, filter, values, kopid).await +} + +#[utoipa::path( + delete, + path = "/v1/person/{id}/_attr/{attr}", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/attr", +)] +pub async fn person_id_delete_attr( + State(state): State, + Path((id, attr)): Path<(String, String)>, + Extension(kopid): Extension, +) -> Result, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); + json_rest_event_delete_id_attr(state, id, attr, filter, None, kopid).await +} + +#[utoipa::path( + delete, + path = "/v1/service_account/{id}/_attr/{attr}", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_delete_attr( + State(state): State, + Path((id, attr)): Path<(String, String)>, + Extension(kopid): Extension, +) -> Result, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); + json_rest_event_delete_id_attr(state, id, attr, filter, None, kopid).await +} + +#[utoipa::path( + put, + path = "/v1/person/{id}/_attr/{attr}", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/attr", +)] +pub async fn person_id_put_attr( + State(state): State, + Path((id, attr)): Path<(String, String)>, + Extension(kopid): Extension, + Json(values): Json>, +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); json_rest_event_put_attr(state, id, attr, filter, values, kopid).await } -pub async fn account_id_patch( +#[utoipa::path( + put, + path = "/v1/service_account/{id}/_attr/{attr}", + request_body=Vec, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_put_attr( + State(state): State, + Path((id, attr)): Path<(String, String)>, + Extension(kopid): Extension, + Json(values): Json>, +) -> Result, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); + json_rest_event_put_attr(state, id, attr, filter, values, kopid).await +} + +#[utoipa::path( + patch, + path = "/v1/person/{id}", + responses( + DefaultApiResponse, + ), + // request_body=ProtoEntry, // TODO: can't deal with a HashMap in the attr + security(("token_jwt" = [])), + tag = "v1/person", +)] +pub async fn person_id_patch( State(state): State, Extension(kopid): Extension, Path(id): Path, Json(obj): Json, -) -> impl IntoResponse { +) -> Result, WebError> { // Update a value / attrs - let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str()))); - let res = state + state .qe_w_ref .handle_internalpatch(kopid.uat, filter, obj, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_get_id_credential_update( +#[utoipa::path( + get, + path = "/v1/person/{id}/_credential/_update", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/credential", +)] +pub async fn person_id_credential_update_get( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmcredentialupdate(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/person/{id}/_credential/_update_intent/?ttl={ttl}", + params( + ("ttl" = u32, Query, description="The new TTL for the credential?") // TODO: this is a query param? + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/credential", +)] +// TODO: this shouldn't be a get, we're making changes! #[instrument(level = "trace", skip(state, kopid))] -pub async fn account_get_id_credential_update_intent_ttl( +pub async fn person_id_credential_update_intent_ttl_get( State(state): State, Extension(kopid): Extension, Path(id): Path, Query(ttl): Query, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmcredentialupdateintent( kopid.uat, @@ -642,351 +1072,920 @@ pub async fn account_get_id_credential_update_intent_ttl( Some(Duration::from_secs(ttl)), kopid.eventid, ) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/person/{id}/_credential/_update_intent", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] #[instrument(level = "trace", skip(state, kopid))] -pub async fn account_get_id_credential_update_intent( +pub async fn person_id_credential_update_intent_get( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmcredentialupdateintent(kopid.uat, id, None, kopid.eventid) - .await; - // panic!("res: {:?}", res); - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_get_id_user_auth_token( +#[utoipa::path( + get, + path = "/v1/account/{id}/_user_auth_token", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] +pub async fn account_id_user_auth_token_get( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result>, WebError> { + state .qe_r_ref .handle_account_user_auth_token_get(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/account/{id}/_user_auth_token/{token_id}", + params( + path_schema::Id, + path_schema::TokenId, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] pub async fn account_user_auth_token_delete( State(state): State, Path((id, token_id)): Path<(String, Uuid)>, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_account_user_auth_token_destroy(kopid.uat, id, token_id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/credential/_exchange_intent", + params( + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/credential", +)] // TODO: post body pub async fn credential_update_exchange_intent( State(state): State, Extension(kopid): Extension, Json(intent_token): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmcredentialexchangeintent(intent_token, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/credential/_status", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/credential", +)] // TODO: post body pub async fn credential_update_status( State(state): State, Extension(kopid): Extension, Json(session_token): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_r_ref .handle_idmcredentialupdatestatus(session_token, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -// #[derive(Deserialize, Debug, Clone)] -// struct CUBody { -// pub session_token: CUSessionToken, -// pub scr: CURequest, -// } +#[utoipa::path( + post, + path = "/v1/credential/_update", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/credential", +)] // TODO: post body #[instrument(level = "debug", skip(state, kopid))] pub async fn credential_update_update( State(state): State, Extension(kopid): Extension, Json(cubody): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let scr: CURequest = match serde_json::from_value(cubody[0].clone()) { Ok(val) => val, Err(err) => { - error!("Failed to deserialize CURequest: {:?}", err); - #[allow(clippy::unwrap_used)] - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::empty()) - .unwrap(); + let errmsg = format!("Failed to deserialize CURequest: {:?}", err); + error!("{}", errmsg); + return Err(WebError::InternalServerError(errmsg)); } }; let session_token = match serde_json::from_value(cubody[1].clone()) { Ok(val) => val, Err(err) => { - error!("Failed to deserialize session token: {:?}", err); - #[allow(clippy::unwrap_used)] - return Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::empty()) - .unwrap(); + let errmsg = format!("Failed to deserialize session token: {:?}", err); + error!("{}", errmsg); + return Err(WebError::InternalServerError(errmsg)); } }; debug!("session_token: {:?}", session_token); debug!("scr: {:?}", scr); - let res = state + state .qe_r_ref .handle_idmcredentialupdate(session_token, scr, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + post, + path = "/v1/credential/_commit", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/credential", +)] // TODO: post body pub async fn credential_update_commit( State(state): State, Extension(kopid): Extension, Json(session_token): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmcredentialupdatecommit(session_token, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + post, + path = "/v1/credential/_cancel", + request_body=CUSessionToken, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/credential", +)] pub async fn credential_update_cancel( State(state): State, Extension(kopid): Extension, Json(session_token): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmcredentialupdatecancel(session_token, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_get_id_credential_status( +#[utoipa::path( + get, + path = "/v1/service_account/{id}/_credential/_status", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_credential_status_get( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_r_ref .handle_idmcredentialstatus(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -// // Return a vec of str -pub async fn account_get_id_ssh_pubkeys( +#[utoipa::path( + delete, + path = "/v1/person/{id}/_credential/_status", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/credential", +)] +pub async fn person_get_id_credential_status( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_r_ref - .handle_internalsshkeyread(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .handle_idmcredentialstatus(kopid.uat, id, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_post_id_ssh_pubkey( +#[utoipa::path( + get, + path = "/v1/person/{id}/_ssh_pubkeys", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/ssh_pubkeys", +)] +pub async fn person_id_ssh_pubkeys_get( + State(state): State, + Extension(kopid): Extension, + Path(id): Path, +) -> Result>, WebError> { + state + .qe_r_ref + .handle_internalsshkeyread(kopid.uat, id, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + get, + path = "/v1/account/{id}/_ssh_pubkeys", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] +#[deprecated] +pub async fn account_id_ssh_pubkeys_get( + State(state): State, + Extension(kopid): Extension, + Path(id): Path, +) -> Result>, WebError> { + state + .qe_r_ref + .handle_internalsshkeyread(kopid.uat, id, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + get, + path = "/v1/service_account/{id}/_ssh_pubkeys", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_ssh_pubkeys_get( + State(state): State, + Extension(kopid): Extension, + Path(id): Path, +) -> Result>, WebError> { + state + .qe_r_ref + .handle_internalsshkeyread(kopid.uat, id, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + post, + path = "/v1/person/{id}/_ssh_pubkeys", + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/ssh_pubkeys", +)] +pub async fn person_id_ssh_pubkeys_post( State(state): State, Extension(kopid): Extension, Path(id): Path, Json((tag, key)): Json<(String, String)>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); // Add a msg here - let res = state + state .qe_w_ref .handle_sshkeycreate(kopid.uat, id, tag, key, filter, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_get_id_ssh_pubkey_tag( +#[utoipa::path( + post, + path = "/v1/service_account/{id}/_ssh_pubkeys", + request_body = (String, String), + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_ssh_pubkeys_post( + State(state): State, + Extension(kopid): Extension, + Path(id): Path, + Json((tag, key)): Json<(String, String)>, +) -> Result, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); + // Add a msg here + state + .qe_w_ref + .handle_sshkeycreate(kopid.uat, id, tag, key, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + get, + path = "/v1/person/{id}/_ssh_pubkeys/{tag}", + params( + path_schema::Id, + ("tag" = String, description="The tag of the SSH key"), + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/ssh_pubkeys/tag", +)] +pub async fn person_id_ssh_pubkeys_tag_get( State(state): State, Extension(kopid): Extension, Path((id, tag)): Path<(String, String)>, -) -> impl IntoResponse { - let res = state +) -> Result>, WebError> { + state .qe_r_ref .handle_internalsshkeytagread(kopid.uat, id, tag, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } - -pub async fn account_delete_id_ssh_pubkey_tag( +#[utoipa::path( + get, + path = "/v1/account/{id}/_ssh_pubkeys/{tag}", + params( + path_schema::Id, + ("tag" = String, description="The tag of the SSH key"), + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] +pub async fn account_id_ssh_pubkeys_tag_get( State(state): State, Extension(kopid): Extension, Path((id, tag)): Path<(String, String)>, -) -> impl IntoResponse { - let attr = Attribute::SshPublicKey.to_string(); +) -> Result>, WebError> { + state + .qe_r_ref + .handle_internalsshkeytagread(kopid.uat, id, tag, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + get, + path = "/v1/service_account/{id}/_ssh_pubkeys/{tag}", + params( + path_schema::Id, + ("tag" = String, description="The tag of the SSH key"), + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +pub async fn service_account_id_ssh_pubkeys_tag_get( + State(state): State, + Extension(kopid): Extension, + Path((id, tag)): Path<(String, String)>, +) -> Result>, WebError> { + state + .qe_r_ref + .handle_internalsshkeytagread(kopid.uat, id, tag, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + delete, + path = "/v1/person/{id}/_ssh_pubkeys/{tag}", + params( + path_schema::Id, + ("tag" = String, description="The tag of the SSH key"), + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/ssh_pubkeys/tag", +)] +pub async fn person_id_ssh_pubkeys_tag_delete( + State(state): State, + Extension(kopid): Extension, + Path((id, tag)): Path<(String, String)>, +) -> Result, WebError> { let values = vec![tag]; let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); - let res = state + state .qe_w_ref - .handle_removeattributevalues(kopid.uat, id, attr, values, filter, kopid.eventid) - .await; - to_axum_response(res) + .handle_removeattributevalues( + kopid.uat, + id, + Attribute::SshPublicKey.to_string(), + values, + filter, + kopid.eventid, + ) + .await + .map(Json::from) + .map_err(WebError::from) } -// // Get and return a single str -pub async fn account_get_id_radius( +#[utoipa::path( + delete, + path = "/v1/service_account/{id}/_ssh_pubkeys/{tag}", + params( + path_schema::Id, + ("tag" = String, description="The tag of the SSH key"), + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] +pub async fn service_account_id_ssh_pubkeys_tag_delete( + State(state): State, + Extension(kopid): Extension, + Path((id, tag)): Path<(String, String)>, +) -> Result, WebError> { + let values = vec![tag]; + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); + state + .qe_w_ref + .handle_removeattributevalues( + kopid.uat, + id, + Attribute::SshPublicKey.to_string(), + values, + filter, + kopid.eventid, + ) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + get, + path = "/v1/person/{id}/_radius", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/radius", +)] +/// Get and return a single str +pub async fn person_id_radius_get( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result>, WebError> { + // TODO: string + state .qe_r_ref .handle_internalradiusread(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_post_id_radius_regenerate( +#[utoipa::path( + post, + path = "/v1/person/{id}/_radius", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/radius", +)] +// TODO: what body do we take here? +pub async fn person_id_radius_post( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { +) -> Result, WebError> { // Need to to send the regen msg - let res = state + state .qe_w_ref .handle_regenerateradius(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_delete_id_radius( +#[utoipa::path( + delete, + path = "/v1/person/{id}/_radius", + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] +pub async fn person_id_radius_delete( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result, WebError> { let attr = "radius_secret".to_string(); let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Account.into())); json_rest_event_delete_id_attr(state, id, attr, filter, None, kopid).await } -pub async fn account_id_radius_token( +// /v1/person/:id/_radius/_token +#[utoipa::path( + get, + path = "/v1/person/{id}/_radius/_token", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person/radius", +)] +pub async fn person_id_radius_token_get( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state - .qe_r_ref - .handle_internalradiustokenread(kopid.uat, id, kopid.eventid) - .await; - let mut res = to_axum_response(res); - debug!("Response: {:?}", res); - let cache_header = CacheControl::new() - .with_max_age(Duration::from_secs(300)) - .with_private(); - res.headers_mut().typed_insert(cache_header); - res +) -> Result, WebError> { + person_id_radius_handler(state, id, kopid).await } -/// Expects an `AccountUnixExtend` object -/// +// /v1/account/:id/_radius/_token +#[utoipa::path( + get, + path = "/v1/account/{id}/_radius/_token", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] +pub async fn account_id_radius_token_get( + State(state): State, + Path(id): Path, + Extension(kopid): Extension, +) -> Result, WebError> { + person_id_radius_handler(state, id, kopid).await +} + +#[utoipa::path( + post, + path = "/v1/account/{id}/_radius/_token", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] // TODO: what body do we expect here? +pub async fn account_id_radius_token_post( + State(state): State, + Path(id): Path, + Extension(kopid): Extension, +) -> Result, WebError> { + person_id_radius_handler(state, id, kopid).await +} + +async fn person_id_radius_handler( + state: ServerState, + id: String, + kopid: KOpId, +) -> Result, WebError> { + state + .qe_r_ref + .handle_internalradiustokenread(kopid.uat, id, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + post, + path = "/v1/person/{id}/_unix", + params( + path_schema::Id, + ), + request_body=Jaon, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/unix", +)] #[instrument(name = "account_post_id_unix", level = "INFO", skip(id, state, kopid))] -pub async fn account_post_id_unix( +pub async fn person_id_unix_post( State(state): State, Path(id): Path, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmaccountunixextend(kopid.uat, id, obj, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -#[instrument(name = "account_id_unix_token", level = "INFO", skip_all)] +#[utoipa::path( + post, + path = "/v1/service_account/{id}/_unix", + params( + path_schema::Id, + ), + request_body = AccountUnixExtend, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/service_account", +)] +/// +#[instrument(, level = "INFO", skip(id, state, kopid))] +pub async fn service_account_id_unix_post( + State(state): State, + Path(id): Path, + Extension(kopid): Extension, + Json(obj): Json, +) -> Result, WebError> { + state + .qe_w_ref + .handle_idmaccountunixextend(kopid.uat, id, obj, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + post, + path = "/v1/account/{id}/_unix", + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] +/// Expects an `AccountUnixExtend` object +#[instrument(, level = "INFO", skip(id, state, kopid))] +pub async fn account_id_unix_post( + State(state): State, + Path(id): Path, + Extension(kopid): Extension, + Json(obj): Json, +) -> Result, WebError> { + state + .qe_w_ref + .handle_idmaccountunixextend(kopid.uat, id, obj, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + get,post, + path = "/v1/account/{id}/_unix/_token", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] // TODO: what body do we expect here? +#[instrument(level = "INFO", skip_all)] pub async fn account_id_unix_token( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { +) -> Result, WebError> { // no point asking for an empty id if id.is_empty() { - #[allow(clippy::unwrap_used)] - return Response::builder() - .status(StatusCode::BAD_REQUEST) - .body(Body::empty()) - .unwrap(); + return Err(OperationError::EmptyRequest.into()); } let res = state .qe_r_ref .handle_internalunixusertokenread(kopid.uat, id, kopid.eventid) - .await; + .await + .map(Json::from); if let Err(OperationError::InvalidAccountState(val)) = &res { // if they're not a posix user we should just hide them if *val == format!("Missing class: {}", "posixaccount") { - #[allow(clippy::unwrap_used)] - return Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::empty()) - .unwrap(); + return Err(OperationError::NoMatchingEntries.into()); } }; - // the was returning a 500 error which wasn't right if let Err(OperationError::InvalidValueState) = &res { - #[allow(clippy::unwrap_used)] - return Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::empty()) - .unwrap(); + return Err(OperationError::NoMatchingEntries.into()); }; - - to_axum_response(res) + res.map_err(WebError::from) } -pub async fn account_post_id_unix_auth( +#[utoipa::path( + post, + path = "/v1/account/{id}/_unix/_auth", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/account", +)] // TODO: what body do we expect here? +pub async fn account_id_unix_auth_post( State(state): State, Extension(kopid): Extension, Path(id): Path, Json(obj): Json, -) -> impl IntoResponse { - let res = state +) -> Result>, WebError> { + state .qe_r_ref .handle_idmaccountunixauth(kopid.uat, id, obj.value, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_put_id_unix_credential( +#[utoipa::path( + post, + path = "/v1/person/{id}/_unix/_credential", + params( + path_schema::Id, + ), + request_body = SingleStringRequest, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/unix", +)] // TODO: what body do we expect here? +pub async fn person_id_unix_credential_put( State(state): State, Extension(kopid): Extension, Path(id): Path, Json(obj): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmaccountunixsetcred(kopid.uat, id, obj.value, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn account_delete_id_unix_credential( +#[utoipa::path( + delete, + path = "/v1/person/{id}/_unix/_credential", + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/person/unix", +)] +pub async fn person_id_unix_credential_delete( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::PosixAccount.into())); - let res = state + state .qe_w_ref .handle_purgeattribute( kopid.uat, @@ -995,146 +1994,329 @@ pub async fn account_delete_id_unix_credential( filter, kopid.eventid, ) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn person_post_identify_user( +#[utoipa::path( + post, + path = "/v1/person/{id}/_identify/_user", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/person", +)] // TODO: what body do we expect here? +pub async fn person_identify_user_post( State(state): State, Extension(kopid): Extension, Path(id): Path, Json(user_request): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_r_ref .handle_user_identity_verification(kopid.uat, kopid.eventid, user_request, id) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/group", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/group", +)] /// Returns all groups visible to the user pub async fn group_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); json_rest_event_get(state, None, filter, kopid).await } +#[utoipa::path( + post, + path = "/v1/group/{id}", + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/group", +)] // TODO: post body pub async fn group_post( State(state): State, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { +) -> Result, WebError> { let classes = vec!["group".to_string(), "object".to_string()]; json_rest_event_post(state, classes, obj, kopid).await } +#[utoipa::path( + get, + path = "/v1/group/{id}", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/group", +)] pub async fn group_id_get( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); json_rest_event_get_id(state, id, filter, None, kopid).await } -pub async fn group_id_get_attr( +#[utoipa::path( + delete, + path = "/v1/group/{id}", + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/group", +)] +pub async fn group_id_delete( + State(state): State, + Extension(kopid): Extension, + Path(id): Path, +) -> Result, WebError> { + let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); + json_rest_event_delete_id(state, id, filter, kopid).await +} + +#[utoipa::path( + get, + path = "/v1/group/{id}/_attr/{attr}", + params( + path_schema::Id, + path_schema::Attr, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/group/attr", +)] +pub async fn group_id_attr_get( State(state): State, Path((id, attr)): Path<(String, String)>, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); json_rest_event_get_id_attr(state, id, attr, filter, kopid).await } -pub async fn group_id_post_attr( +#[utoipa::path( + post, + path = "/v1/group/{id}/_attr/{attr}", + params( + path_schema::Id, + path_schema::Attr, + ), + request_body=Json>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/group/attr", +)] +pub async fn group_id_attr_post( Path((id, attr)): Path<(String, String)>, State(state): State, Extension(kopid): Extension, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); json_rest_event_post_id_attr(state, id, attr, filter, values, kopid).await } -pub async fn group_id_delete_attr( +#[utoipa::path( + delete, + path = "/v1/group/{id}/_attr/{attr}", + params( + path_schema::Id, + path_schema::Attr, + ), + request_body=Option>>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/group/attr", +)] +pub async fn group_id_attr_delete( Path((id, attr)): Path<(String, String)>, State(state): State, Extension(kopid): Extension, values: Option>>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); let values = values.map(|v| v.0); json_rest_event_delete_id_attr(state, id, attr, filter, values, kopid).await } -pub async fn group_id_put_attr( +#[utoipa::path( + put, + path = "/v1/group/{id}/_attr/{attr}", + params( + path_schema::Id, + path_schema::Attr, + ), + request_body=Json>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/group/attr", +)] +pub async fn group_id_attr_put( Path((id, attr)): Path<(String, String)>, State(state): State, Extension(kopid): Extension, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); json_rest_event_put_id_attr(state, id, attr, filter, values, kopid).await } -pub async fn group_id_delete( - State(state): State, - Extension(kopid): Extension, - Path(id): Path, -) -> impl IntoResponse { - let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into())); - json_rest_event_delete_id(state, id, filter, kopid).await -} -pub async fn group_post_id_unix( +#[utoipa::path( + put, + path = "/v1/group/{id}/_unix", + params( + path_schema::Id, + ), + request_body = GroupUnixExtend, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/group/unix", +)] +pub async fn group_id_unix_post( State(state): State, Path(id): Path, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_idmgroupunixextend(kopid.uat, id, obj, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn group_get_id_unix_token( +#[utoipa::path( + get, + path = "/v1/group/{id}/_unix/_token", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/group/unix", +)] +pub async fn group_id_unix_token_get( State(state): State, Extension(kopid): Extension, Path(id): Path, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_r_ref .handle_internalunixgrouptokenread(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/domain", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/domain", +)] pub async fn domain_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Uuid, PartialValue::Uuid(UUID_DOMAIN_INFO))); json_rest_event_get(state, None, filter, kopid).await } -pub async fn domain_get_attr( +#[utoipa::path( + get, + path = "/v1/domain/_attr/{attr}", + params( + path_schema::Attr, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/domain", +)] +pub async fn domain_attr_get( State(state): State, Extension(kopid): Extension, Path(attr): Path, -) -> impl IntoResponse { +) -> Result>>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::DomainInfo.into())); json_rest_event_get_attr(state, STR_UUID_DOMAIN_INFO, attr, filter, kopid).await } -pub async fn domain_put_attr( +#[utoipa::path( + put, + path = "/v1/domain/_attr/{attr}", + params( + path_schema::Attr, + ), + request_body=Json>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/domain", +)] +pub async fn domain_attr_put( State(state): State, Extension(kopid): Extension, Path(attr): Path, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::DomainInfo.into())); json_rest_event_put_attr( state, @@ -1147,12 +2329,25 @@ pub async fn domain_put_attr( .await } -pub async fn domain_delete_attr( +#[utoipa::path( + delete, + path = "/v1/domain/_attr/{attr}", + params( + path_schema::Attr, + ), + request_body=Json>>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/domain", +)] +pub async fn domain_attr_delete( State(state): State, Path(attr): Path, Extension(kopid): Extension, Json(values): Json>>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::DomainInfo.into())); json_rest_event_delete_attr( state, @@ -1165,10 +2360,20 @@ pub async fn domain_delete_attr( .await } +#[utoipa::path( + get, + path = "/v1/system", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/system", +)] pub async fn system_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq( Attribute::Uuid, PartialValue::Uuid(UUID_SYSTEM_CONFIG) @@ -1176,21 +2381,47 @@ pub async fn system_get( json_rest_event_get(state, None, filter, kopid).await } -pub async fn system_get_attr( +#[utoipa::path( + get, + path = "/v1/system/_attr/{attr}", + params( + path_schema::Attr, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/system", +)] +pub async fn system_attr_get( State(state): State, Path(attr): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SystemConfig.into())); json_rest_event_get_attr(state, STR_UUID_SYSTEM_CONFIG, attr, filter, kopid).await } -pub async fn system_post_attr( +#[utoipa::path( + post, + path = "/v1/system/_attr/{attr}", + params( + path_schema::Attr, + ), + request_body=Json>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/system", +)] +pub async fn system_attr_post( State(state): State, Path(attr): Path, Extension(kopid): Extension, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SystemConfig.into())); json_rest_event_post_attr( state, @@ -1203,12 +2434,25 @@ pub async fn system_post_attr( .await } -pub async fn system_delete_attr( +#[utoipa::path( + delete, + path = "/v1/system/_attr/{attr}", + params( + path_schema::Attr, + ), + request_body=Json>>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/system", +)] +pub async fn system_attr_delete( State(state): State, Path(attr): Path, Extension(kopid): Extension, Json(values): Json>>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SystemConfig.into())); json_rest_event_delete_attr( state, @@ -1221,12 +2465,25 @@ pub async fn system_delete_attr( .await } -pub async fn system_put_attr( +#[utoipa::path( + put, + path = "/v1/system/_attr/{attr}", + params( + path_schema::Attr, + ), + request_body=Json>, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/system", +)] +pub async fn system_attr_put( State(state): State, Path(attr): Path, Extension(kopid): Extension, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SystemConfig.into())); json_rest_event_put_attr( state, @@ -1239,87 +2496,153 @@ pub async fn system_put_attr( .await } +#[utoipa::path( + post, + path = "/v1/recycle_bin", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/recycle_bin", +)] pub async fn recycle_bin_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_pres(Attribute::Class)); let attrs = None; - let res = state - .qe_r_ref - .handle_internalsearchrecycled(kopid.uat, filter, attrs, kopid.eventid) - .await; - to_axum_response(res) -} - -pub async fn recycle_bin_id_get( - State(state): State, - - Path(id): Path, - Extension(kopid): Extension, -) -> impl IntoResponse { - let filter = filter_all!(f_id(id.as_str())); - let attrs = None; - - let res = state + state .qe_r_ref .handle_internalsearchrecycled(kopid.uat, filter, attrs, kopid.eventid) .await - .map(|mut r| r.pop()); - to_axum_response(res) + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/recycle_bin/{id}", + params( + path_schema::Id, + ), + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/recycle_bin", +)] +pub async fn recycle_bin_id_get( + State(state): State, + Path(id): Path, + Extension(kopid): Extension, +) -> Result>, WebError> { + let filter = filter_all!(f_id(id.as_str())); + let attrs = None; + + state + .qe_r_ref + .handle_internalsearchrecycled(kopid.uat, filter, attrs, kopid.eventid) + .await + .map(|mut r| r.pop()) + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + post, + path = "/v1/recycle_bin/{id}/_revive", + params( + path_schema::Id, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/recycle_bin", +)] pub async fn recycle_bin_revive_id_post( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_id(id.as_str())); - let res = state + state .qe_w_ref .handle_reviverecycled(kopid.uat, filter, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/self/_applinks", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/self", +)] +/// Returns your OAuth2 app links for the Web UI pub async fn applinks_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result>, WebError> { + state .qe_r_ref .handle_list_applinks(kopid.uat, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -// TODO: routemap things -// pub async fn do_routemap(State(state): State) -> impl IntoResponse { -// Json(state.do_map()) -// } - +#[utoipa::path( + post, + path = "/v1/reauth", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + request_body = AuthIssueSession, + security(("token_jwt" = [])), + tag = "v1/auth", +)] // TODO: post body stuff pub async fn reauth( State(state): State, TrustedClientIp(ip_addr): TrustedClientIp, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { +) -> Result { // This may change in the future ... let inter = state .qe_r_ref .handle_reauth(kopid.uat, obj, kopid.eventid, ip_addr) .await; - debug!("REAuth result: {:?}", inter); + debug!("ReAuth result: {:?}", inter); auth_session_state_management(state, inter) } +#[utoipa::path( + post, + path = "/v1/auth", + responses( + (status=200), // TODO: define response + ApiResponseWithout200, + ), + request_body = AuthRequest, + security(("token_jwt" = [])), + tag = "v1/auth", +)] pub async fn auth( State(state): State, TrustedClientIp(ip_addr): TrustedClientIp, headers: HeaderMap, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { +) -> Result { // First, deal with some state management. // Do anything here first that's needed like getting the session details // out of the req cookie. @@ -1341,7 +2664,7 @@ pub async fn auth( fn auth_session_state_management( state: ServerState, inter: Result, -) -> impl IntoResponse { +) -> Result { let mut auth_session_id_tok = None; let res: Result = match inter { @@ -1399,77 +2722,120 @@ fn auth_session_state_management( Err(e) => Err(e), }; - let mut res = to_axum_response(res); - // if the sessionid was injected into our cookie, set it in the header too. - match auth_session_id_tok { - Some(tok) => { - #[allow(clippy::unwrap_used)] - res.headers_mut().insert( - "X-KANIDM-AUTH-SESSION-ID", - HeaderValue::from_str(&tok).unwrap(), - ); - res + res.map(|response| { + let mut res = Json::from(response).into_response(); + match auth_session_id_tok { + Some(tok) => { + match HeaderValue::from_str(&tok) { + Ok(val) => { + res.headers_mut().insert(KSESSIONID, val); + } + Err(err) => { + admin_error!(?err, "Failed to add sessionid {} to header", tok); + } + } + res + } + None => res, } - None => res, - } + }) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/auth/valid", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/auth", +)] pub async fn auth_valid( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_r_ref .handle_auth_valid(kopid.uat, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/v1/debug/ipinfo", + responses( + (status = 200, description = "Ok"), + ), + security(("token_jwt" = [])), + tag = "v1/debug", +)] pub async fn debug_ipinfo( State(_state): State, TrustedClientIp(ip_addr): TrustedClientIp, -) -> impl IntoResponse { - to_axum_response(Ok(vec![ip_addr])) +) -> Result>, ()> { + Ok(Json::from(vec![ip_addr])) +} + +fn cacheable_routes(state: ServerState) -> Router { + Router::new() + .route( + "/v1/person/:id/_radius/_token", + get(person_id_radius_token_get), + ) + .route("/v1/account/:id/_unix/_token", get(account_id_unix_token)) + .route( + "/v1/account/:id/_radius/_token", + get(account_id_radius_token_get), + ) + .layer(from_fn(cache_me)) + .with_state(state) } #[instrument(skip(state))] -pub fn router(state: ServerState) -> Router { +pub(crate) fn route_setup(state: ServerState) -> Router { Router::new() - .route("/v1/oauth2", get(super::oauth2::oauth2_get)) - .route("/v1/oauth2/_basic", post(super::oauth2::oauth2_basic_post)) + .route("/v1/oauth2", get(super::v1_oauth2::oauth2_get)) + .route( + "/v1/oauth2/_basic", + post(super::v1_oauth2::oauth2_basic_post), + ) .route( "/v1/oauth2/_public", - post(super::oauth2::oauth2_public_post), + post(super::v1_oauth2::oauth2_public_post), ) .route( "/v1/oauth2/:rs_name", - get(super::oauth2::oauth2_id_get) - .patch(super::oauth2::oauth2_id_patch) - .delete(super::oauth2::oauth2_id_delete), + get(super::v1_oauth2::oauth2_id_get) + .patch(super::v1_oauth2::oauth2_id_patch) + .delete(super::v1_oauth2::oauth2_id_delete), ) .route( "/v1/oauth2/:rs_name/_image", - post(super::oauth2::oauth2_id_image_post).delete(super::oauth2::oauth2_id_image_delete), + post(super::v1_oauth2::oauth2_id_image_post) + .delete(super::v1_oauth2::oauth2_id_image_delete), ) .route( "/v1/oauth2/:rs_name/_basic_secret", - get(super::oauth2::oauth2_id_get_basic_secret), + get(super::v1_oauth2::oauth2_id_get_basic_secret), ) .route( "/v1/oauth2/:rs_name/_scopemap/:group", - post(super::oauth2::oauth2_id_scopemap_post) - .delete(super::oauth2::oauth2_id_scopemap_delete), + post(super::v1_oauth2::oauth2_id_scopemap_post) + .delete(super::v1_oauth2::oauth2_id_scopemap_delete), ) .route( "/v1/oauth2/:rs_name/_sup_scopemap/:group", - post(super::oauth2::oauth2_id_sup_scopemap_post) - .delete(super::oauth2::oauth2_id_sup_scopemap_delete), + post(super::v1_oauth2::oauth2_id_sup_scopemap_post) + .delete(super::v1_oauth2::oauth2_id_sup_scopemap_delete), ) - .route("/v1/raw/create", post(create)) - .route("/v1/raw/modify", post(v1_modify)) - .route("/v1/raw/delete", post(v1_delete)) - .route("/v1/raw/search", post(search)) + .route("/v1/raw/create", post(raw_create)) + .route("/v1/raw/modify", post(raw_modify)) + .route("/v1/raw/delete", post(raw_delete)) + .route("/v1/raw/search", post(raw_search)) .route("/v1/schema", get(schema_get)) .route( "/v1/schema/attributetype", @@ -1509,26 +2875,25 @@ pub fn router(state: ServerState) -> Router { // Applinks are the list of apps this account can access. .route("/v1/self/_applinks", get(applinks_get)) // Person routes - .route("/v1/person", get(person_get)) - .route("/v1/person", post(person_post)) + .route("/v1/person", get(person_get).post(person_post)) .route( "/v1/person/:id", get(person_id_get) - .patch(account_id_patch) - .delete(person_account_id_delete), + .patch(person_id_patch) + .delete(person_id_delete), ) .route( "/v1/person/:id/_attr/:attr", - get(account_id_get_attr) - .put(account_id_put_attr) - .post(account_id_post_attr) - .delete(account_id_delete_attr), + get(person_id_get_attr) + .put(person_id_put_attr) + .post(person_id_post_attr) + .delete(person_id_delete_attr), ) // .route("/v1/person/:id/_lock", get(|| async { "TODO" })) // .route("/v1/person/:id/_credential", get(|| async { "TODO" })) .route( "/v1/person/:id/_credential/_status", - get(account_get_id_credential_status), + get(person_get_id_credential_status), ) // .route( // "/v1/person/:id/_credential/:cid/_lock", @@ -1536,42 +2901,38 @@ pub fn router(state: ServerState) -> Router { // ) .route( "/v1/person/:id/_credential/_update", - get(account_get_id_credential_update), + get(person_id_credential_update_get), ) .route( - "/v1/person/:id/_credential/_update_intent/:ttl", - get(account_get_id_credential_update_intent_ttl), + "/v1/person/:id/_credential/_update_intent/:ttl", // TODO: I'm pretty sure this route is wrong, because we match the query not the path + get(person_id_credential_update_intent_ttl_get), ) .route( "/v1/person/:id/_credential/_update_intent", - get(account_get_id_credential_update_intent), + get(person_id_credential_update_intent_get), ) .route( "/v1/person/:id/_ssh_pubkeys", - get(account_get_id_ssh_pubkeys).post(account_post_id_ssh_pubkey), + get(person_id_ssh_pubkeys_get).post(person_id_ssh_pubkeys_post), ) .route( "/v1/person/:id/_ssh_pubkeys/:tag", - get(account_get_id_ssh_pubkey_tag).delete(account_delete_id_ssh_pubkey_tag), + get(person_id_ssh_pubkeys_tag_get).delete(person_id_ssh_pubkeys_tag_delete), ) .route( "/v1/person/:id/_radius", - get(account_get_id_radius) - .post(account_post_id_radius_regenerate) - .delete(account_delete_id_radius), + get(person_id_radius_get) + .post(person_id_radius_post) + .delete(person_id_radius_delete), ) - .route( - "/v1/person/:id/_radius/_token", - get(account_id_radius_token), - ) // TODO: make this cacheable - .route("/v1/person/:id/_unix", post(account_post_id_unix)) + .route("/v1/person/:id/_unix", post(service_account_id_unix_post)) .route( "/v1/person/:id/_unix/_credential", - put(account_put_id_unix_credential).delete(account_delete_id_unix_credential), + put(person_id_unix_credential_put).delete(person_id_unix_credential_delete), ) .route( "/v1/person/:id/_identify_user", - post(person_post_identify_user), + post(person_identify_user_post), ) // Service accounts .route( @@ -1588,14 +2949,15 @@ pub fn router(state: ServerState) -> Router { ) .route( "/v1/service_account/:id/_attr/:attr", - get(account_id_get_attr) - .put(account_id_put_attr) - .post(account_id_post_attr) - .delete(account_id_delete_attr), + get(service_account_id_get_attr) + .put(service_account_id_put_attr) + .post(service_account_id_post_attr) + .delete(service_account_id_delete_attr), ) // .route("/v1/service_account/:id/_lock", get(|| async { "TODO" })) .route( "/v1/service_account/:id/_into_person", + #[allow(deprecated)] post(service_account_into_person), ) .route( @@ -1616,7 +2978,7 @@ pub fn router(state: ServerState) -> Router { ) .route( "/v1/service_account/:id/_credential/_status", - get(account_get_id_credential_status), + get(service_account_id_credential_status_get), ) // .route( // "/v1/service_account/:id/_credential/:cid/_lock", @@ -1624,36 +2986,38 @@ pub fn router(state: ServerState) -> Router { // ) .route( "/v1/service_account/:id/_ssh_pubkeys", - get(account_get_id_ssh_pubkeys).post(account_post_id_ssh_pubkey), + get(service_account_id_ssh_pubkeys_get).post(service_account_id_ssh_pubkeys_post), ) .route( "/v1/service_account/:id/_ssh_pubkeys/:tag", - get(account_get_id_ssh_pubkey_tag).delete(account_delete_id_ssh_pubkey_tag), + get(service_account_id_ssh_pubkeys_tag_get) + .delete(service_account_id_ssh_pubkeys_tag_delete), + ) + .route( + "/v1/service_account/:id/_unix", + post(service_account_id_unix_post), ) - .route("/v1/service_account/:id/_unix", post(account_post_id_unix)) .route( "/v1/account/:id/_unix/_auth", - post(account_post_id_unix_auth), - ) - .route( - "/v1/account/:id/_unix/_token", - post(account_id_unix_token).get(account_id_unix_token), // TODO: make this cacheable + post(account_id_unix_auth_post), ) + .route("/v1/account/:id/_unix/_token", post(account_id_unix_token)) .route( "/v1/account/:id/_radius/_token", - post(account_id_radius_token).get(account_id_radius_token), // TODO: make this cacheable + post(account_id_radius_token_post), ) .route( "/v1/account/:id/_ssh_pubkeys", - get(account_get_id_ssh_pubkeys), + #[allow(deprecated)] + get(account_id_ssh_pubkeys_get), ) .route( "/v1/account/:id/_ssh_pubkeys/:tag", - get(account_get_id_ssh_pubkey_tag), + get(account_id_ssh_pubkeys_tag_get), ) .route( "/v1/account/:id/_user_auth_token", - get(account_get_id_user_auth_token), + get(account_id_user_auth_token_get), ) .route( "/v1/account/:id/_user_auth_token/:token_id", @@ -1671,29 +3035,29 @@ pub fn router(state: ServerState) -> Router { .route("/v1/domain", get(domain_get)) .route( "/v1/domain/_attr/:attr", - get(domain_get_attr) - .put(domain_put_attr) - .delete(domain_delete_attr), + get(domain_attr_get) + .put(domain_attr_put) + .delete(domain_attr_delete), ) - .route("/v1/group/:id/_unix/_token", get(group_get_id_unix_token)) - .route("/v1/group/:id/_unix", post(group_post_id_unix)) + .route("/v1/group/:id/_unix/_token", get(group_id_unix_token_get)) + .route("/v1/group/:id/_unix", post(group_id_unix_post)) .route("/v1/group", get(group_get).post(group_post)) .route("/v1/group/:id", get(group_id_get).delete(group_id_delete)) .route( "/v1/group/:id/_attr/:attr", - delete(group_id_delete_attr) - .get(group_id_get_attr) - .put(group_id_put_attr) - .post(group_id_post_attr), + delete(group_id_attr_delete) + .get(group_id_attr_get) + .put(group_id_attr_put) + .post(group_id_attr_post), ) .with_state(state.clone()) .route("/v1/system", get(system_get)) .route( "/v1/system/_attr/:attr", - get(system_get_attr) - .post(system_post_attr) - .put(system_put_attr) - .delete(system_delete_attr), + get(system_attr_get) + .post(system_attr_post) + .put(system_attr_put) + .delete(system_attr_delete), ) .route("/v1/recycle_bin", get(recycle_bin_get)) .route("/v1/recycle_bin/:id", get(recycle_bin_id_get)) @@ -1711,35 +3075,8 @@ pub fn router(state: ServerState) -> Router { .route("/v1/auth/valid", get(auth_valid)) .route("/v1/logout", get(logout)) .route("/v1/reauth", post(reauth)) - .route( - "/v1/sync_account", - get(sync_account_get).post(sync_account_post), - ) - .route( - "/v1/sync_account/", - get(sync_account_get).post(sync_account_post), - ) - .route( - "/v1/sync_account/:id", - get(sync_account_id_get).patch(sync_account_id_patch), - ) - .route( - "/v1/sync_account/:id/_attr/:attr", - get(sync_account_id_get_attr).put(sync_account_id_put_attr), - ) - .route( - "/v1/sync_account/:id/_finalise", - get(sync_account_id_get_finalise), - ) - .route( - "/v1/sync_account/:id/_terminate", - get(sync_account_id_get_terminate), - ) - .route( - "/v1/sync_account/:id/_sync_token", - post(sync_account_token_post).delete(sync_account_token_delete), - ) - .with_state(state) + .with_state(state.clone()) .layer(from_fn(dont_cache_me)) + .merge(cacheable_routes(state)) .route("/v1/debug/ipinfo", get(debug_ipinfo)) } diff --git a/server/core/src/https/v1_oauth2.rs b/server/core/src/https/v1_oauth2.rs new file mode 100644 index 000000000..1f43eb963 --- /dev/null +++ b/server/core/src/https/v1_oauth2.rs @@ -0,0 +1,429 @@ +use super::apidocs::path_schema; +use super::apidocs::response_schema::{DefaultApiResponse, ApiResponseWithout200}; +use super::errors::WebError; +use super::middleware::KOpId; +use super::oauth2::oauth2_id; +use super::v1::{json_rest_event_get, json_rest_event_post}; +use super::ServerState; + +use axum::extract::{Path, State}; +use axum::{Extension, Json}; +use kanidm_proto::internal::{ImageType, ImageValue}; +use kanidm_proto::v1::Entry as ProtoEntry; +use kanidmd_lib::prelude::*; +use kanidmd_lib::valueset::image::ImageValueThings; +use sketching::admin_error; + +#[utoipa::path( + get, + path = "/v1/oauth2", + responses( + (status = 200,content_type="application/json", body=Vec), + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Lists all the OAuth2 Resource Servers +pub(crate) async fn oauth2_get( + State(state): State, + Extension(kopid): Extension, +) -> Result>, WebError> { + let filter = filter_all!(f_eq( + Attribute::Class, + EntryClass::OAuth2ResourceServer.into() + )); + json_rest_event_get(state, None, filter, kopid).await +} + +#[utoipa::path( + post, + path = "/v1/oauth2/basic", + request_body=ProtoEntry, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +// TODO: what does this actually do? :D +pub(crate) async fn oauth2_basic_post( + State(state): State, + Extension(kopid): Extension, + Json(obj): Json, +) -> Result, WebError> { + let classes = vec![ + EntryClass::OAuth2ResourceServer.to_string(), + EntryClass::OAuth2ResourceServerBasic.to_string(), + EntryClass::Object.to_string(), + ]; + json_rest_event_post(state, classes, obj, kopid).await +} + +#[utoipa::path( + post, + path = "/v1/oauth2/_public", + request_body=ProtoEntry, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +// TODO: what does this actually do? :D +pub(crate) async fn oauth2_public_post( + State(state): State, + Extension(kopid): Extension, + Json(obj): Json, +) -> Result, WebError> { + let classes = vec![ + EntryClass::OAuth2ResourceServer.to_string(), + EntryClass::OAuth2ResourceServerPublic.to_string(), + EntryClass::Object.to_string(), + ]; + json_rest_event_post(state, classes, obj, kopid).await +} + +#[utoipa::path( + get, + path = "/v1/oauth2/{rs_name}", + params( + path_schema::RsName + ), + responses( + (status = 200, /* TODO response=Option*/), + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Get the details of a given OAuth2 Resource Server. +pub(crate) async fn oauth2_id_get( + State(state): State, + Path(rs_name): Path, + Extension(kopid): Extension, +) -> Result>, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_r_ref + .handle_internalsearch(kopid.uat, filter, None, kopid.eventid) + .await + .map(|mut r| r.pop()) + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + get, + path = "/v1/oauth2/{rs_name}/_basic_secret", + params( + path_schema::RsName, + ), + responses( + (status = 200,content_type="application/json", body=Option), + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Get the basic secret for a given OAuth2 Resource Server. This is used for authentication. +#[instrument(level = "info", skip(state))] +pub(crate) async fn oauth2_id_get_basic_secret( + State(state): State, + Extension(kopid): Extension, + Path(rs_name): Path, +) -> Result>, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_r_ref + .handle_oauth2_basic_secret_read(kopid.uat, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + patch, + path = "/v1/oauth2/{rs_name}", + params( + path_schema::RsName, + ), + request_body=ProtoEntry, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Modify an OAuth2 Resource Server +pub(crate) async fn oauth2_id_patch( + State(state): State, + Path(rs_name): Path, + Extension(kopid): Extension, + Json(obj): Json, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + + state + .qe_w_ref + .handle_internalpatch(kopid.uat, filter, obj, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + patch, + path = "/v1/oauth2/{rs_name}/_scopemap/{group}", + params( + path_schema::RsName, + path_schema::GroupName, + ), + request_body=Vec, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Modify the scope map for a given OAuth2 Resource Server +pub(crate) async fn oauth2_id_scopemap_post( + State(state): State, + Extension(kopid): Extension, + Path((rs_name, group)): Path<(String, String)>, + Json(scopes): Json>, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + delete, + path = "/v1/oauth2/{rs_name}/_scopemap/{group}", + params( + path_schema::RsName, + path_schema::GroupName, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +// Delete a scope map for a given OAuth2 Resource Server +pub(crate) async fn oauth2_id_scopemap_delete( + State(state): State, + Extension(kopid): Extension, + Path((rs_name, group)): Path<(String, String)>, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_scopemap_delete(kopid.uat, group, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + post, + path = "/v1/oauth2/{rs_name}/_sup_scopemap/{group}", + params( + path_schema::RsName, + path_schema::GroupName, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Create a supplemental scope map for a given OAuth2 Resource Server +pub(crate) async fn oauth2_id_sup_scopemap_post( + State(state): State, + Extension(kopid): Extension, + Path((rs_name, group)): Path<(String, String)>, + Json(scopes): Json>, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_sup_scopemap_update(kopid.uat, group, scopes, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + delete, + path = "/v1/oauth2/{rs_name}/_sup_scopemap/{group}", + params( + path_schema::RsName, + path_schema::GroupName, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +// Delete a supplemental scope map configuration. +pub(crate) async fn oauth2_id_sup_scopemap_delete( + State(state): State, + Extension(kopid): Extension, + Path((rs_name, group)): Path<(String, String)>, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_sup_scopemap_delete(kopid.uat, group, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + delete, + path = "/v1/oauth2/{rs_name}", + params( + path_schema::RsName, + ), + responses( + DefaultApiResponse, + (status = 404), + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// Delete an OAuth2 Resource Server +pub(crate) async fn oauth2_id_delete( + State(state): State, + Extension(kopid): Extension, + Path(rs_name): Path, +) -> Result, WebError> { + let filter = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_internaldelete(kopid.uat, filter, kopid.eventid) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + delete, + path = "/v1/oauth2/{rs_name}/_image", + params( + path_schema::RsName, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +// API endpoint for deleting the image associated with an OAuth2 Resource Server. +pub(crate) async fn oauth2_id_image_delete( + State(state): State, + Extension(kopid): Extension, + Path(rs_name): Path, +) -> Result, WebError> { + state + .qe_w_ref + .handle_oauth2_rs_image_delete(kopid.uat, oauth2_id(&rs_name)) + .await + .map(Json::from) + .map_err(WebError::from) +} + +#[utoipa::path( + post, + path = "/v1/oauth2/{rs_name}/_image", + params( + path_schema::RsName, + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/oauth2", +)] +/// API endpoint for creating/replacing the image associated with an OAuth2 Resource Server. +/// +/// It requires a multipart form with the image file, and the content type must be one of the +/// [VALID_IMAGE_UPLOAD_CONTENT_TYPES]. +pub(crate) async fn oauth2_id_image_post( + State(state): State, + Extension(kopid): Extension, + Path(rs_name): Path, + mut multipart: axum::extract::Multipart, +) -> Result, WebError> { + // because we might not get an image + let mut image: Option = None; + + while let Some(field) = multipart.next_field().await.unwrap_or(None) { + let filename = field.file_name().map(|f| f.to_string()).clone(); + if let Some(filename) = filename { + let content_type = field.content_type().map(|f| f.to_string()).clone(); + + let content_type = match content_type { + Some(val) => { + if VALID_IMAGE_UPLOAD_CONTENT_TYPES.contains(&val.as_str()) { + val + } else { + debug!("Invalid content type: {}", val); + return Err(OperationError::InvalidRequestState.into()); + } + } + None => { + debug!("No content type header provided"); + return Err(OperationError::InvalidRequestState.into()); + } + }; + let data = match field.bytes().await { + Ok(val) => val, + Err(_e) => return Err(OperationError::InvalidRequestState.into()), + }; + + let filetype = match ImageType::try_from_content_type(&content_type) { + Ok(val) => val, + Err(_err) => return Err(OperationError::InvalidRequestState.into()), + }; + + image = Some(ImageValue { + filetype, + filename: filename.to_string(), + contents: data.to_vec(), + }); + }; + } + + match image { + Some(image) => { + let image_validation_result = image.validate_image(); + match image_validation_result { + Err(err) => { + admin_error!("Invalid image uploaded: {:?}", err); + Err(WebError::from(OperationError::InvalidRequestState)) + } + Ok(_) => { + let rs_name = oauth2_id(&rs_name); + state + .qe_w_ref + .handle_oauth2_rs_image_update(kopid.uat, rs_name, image) + .await + .map(Json::from) + .map_err(WebError::from) + } + } + } + None => Err(WebError::from(OperationError::InvalidAttribute( + "No image included, did you mean to use the DELETE method?".to_string(), + ))), + } +} diff --git a/server/core/src/https/v1_scim.rs b/server/core/src/https/v1_scim.rs index 451b63770..cec84d653 100644 --- a/server/core/src/https/v1_scim.rs +++ b/server/core/src/https/v1_scim.rs @@ -1,190 +1,315 @@ +use super::apidocs::path_schema; +use super::apidocs::response_schema::{ApiResponseWithout200,DefaultApiResponse}; +use super::errors::WebError; use super::middleware::KOpId; -use super::{to_axum_response, ServerState}; -use axum::extract::{Path, State}; -use axum::response::IntoResponse; -use axum::routing::{get, post}; -use axum::{Extension, Json, Router}; -use axum_auth::AuthBearer; -use kanidm_proto::scim_v1::ScimSyncRequest; -use kanidm_proto::v1::Entry as ProtoEntry; -use kanidmd_lib::prelude::*; - use super::v1::{ json_rest_event_get, json_rest_event_get_id, json_rest_event_get_id_attr, json_rest_event_post, json_rest_event_put_id_attr, }; +use super::ServerState; +use axum::extract::{Path, State}; +use axum::response::Html; +use axum::routing::{get, post}; +use axum::{Extension, Json, Router}; +use axum_auth::AuthBearer; +use kanidm_proto::scim_v1::{ScimSyncRequest, ScimSyncState}; +use kanidm_proto::v1::Entry as ProtoEntry; +use kanidmd_lib::prelude::*; +#[utoipa::path( + get, + path = "/v1/sync_account", + responses( + (status = 200,content_type="application/json", body=Vec), + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] +/// Get all? the sync accounts. pub async fn sync_account_get( State(state): State, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); json_rest_event_get(state, None, filter, kopid).await } +#[utoipa::path( + post, + path = "/v1/sync_account", + // request_body=ProtoEntry, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] pub async fn sync_account_post( State(state): State, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { +) -> Result, WebError> { let classes: Vec = vec![EntryClass::SyncAccount.into(), EntryClass::Object.into()]; json_rest_event_post(state, classes, obj, kopid).await } +#[utoipa::path( + get, + path = "/v1/sync_account/{id}", + params( + path_schema::Id + ), + responses( + (status = 200,content_type="application/json", body=Option), + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] +/// Get the details of a sync account pub async fn sync_account_id_get( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { +) -> Result>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); json_rest_event_get_id(state, id, filter, None, kopid).await } +#[utoipa::path( + patch, + path = "/v1/sync_account/{id}", + params( + path_schema::UuidOrName + ), + request_body=ProtoEntry, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] +/// Modify a sync account in-place pub async fn sync_account_id_patch( State(state): State, Path(id): Path, Extension(kopid): Extension, Json(obj): Json, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); let filter = Filter::join_parts_and(filter, filter_all!(f_id(id.as_str()))); - let res = state + state .qe_w_ref .handle_internalpatch(kopid.uat, filter, obj, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn sync_account_id_get_finalise( +#[utoipa::path( + get, + path = "/v1/sync_account/{id}/_finalise", + params( + path_schema::UuidOrName + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] +// TODO: why is this a get and not a post? +pub async fn sync_account_id_finalise_get( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_sync_account_finalise(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } -pub async fn sync_account_id_get_terminate( +#[utoipa::path( + get, + path = "/v1/sync_account/{id}/_terminate", + params( + path_schema::UuidOrName + ), + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] +// TODO: why is this a get if it's a terminate? +pub async fn sync_account_id_terminate_get( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_sync_account_terminate(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + post, + path = "/v1/sync_account/{id}/_sync_token", + params( + path_schema::UuidOrName + ), + responses( + (status = 200), // TODO: response content + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] pub async fn sync_account_token_post( State(state): State, Path(id): Path, Extension(kopid): Extension, Json(label): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_sync_account_token_generate(kopid.uat, id, label, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + delete, + path = "/v1/sync_account/{id}/_sync_token", + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] pub async fn sync_account_token_delete( State(state): State, Path(id): Path, Extension(kopid): Extension, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_sync_account_token_destroy(kopid.uat, id, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + post, + path = "/scim/v1/Sync", + request_body = ScimSyncRequest, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "scim", +)] async fn scim_sync_post( State(state): State, Extension(kopid): Extension, AuthBearer(bearer): AuthBearer, Json(changes): Json, -) -> impl IntoResponse { - let res = state +) -> Result, WebError> { + state .qe_w_ref .handle_scim_sync_apply(Some(bearer), changes, kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } +#[utoipa::path( + get, + path = "/scim/v1/Sync", + responses( + (status = 200), // TODO: response content + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "scim", +)] async fn scim_sync_get( State(state): State, Extension(kopid): Extension, AuthBearer(bearer): AuthBearer, -) -> impl IntoResponse { +) -> Result, WebError> { // Given the token, what is it's connected sync state? trace!(?bearer); - let res = state + state .qe_r_ref .handle_scim_sync_status(Some(bearer), kopid.eventid) - .await; - to_axum_response(res) + .await + .map(Json::from) + .map_err(WebError::from) } - -pub async fn sync_account_id_get_attr( +#[utoipa::path( + get, + path = "/v1/sync_account/{id}/_attr/{attr}", + params( + path_schema::UuidOrName, + path_schema::Attr, + ), + responses( + (status = 200), // TODO: response content + ApiResponseWithout200, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] +pub async fn sync_account_id_attr_get( State(state): State, Extension(kopid): Extension, Path((id, attr)): Path<(String, String)>, -) -> impl IntoResponse { +) -> Result>>, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); json_rest_event_get_id_attr(state, id, attr, filter, kopid).await } -pub async fn sync_account_id_put_attr( +#[utoipa::path( + post, + path = "/v1/sync_account/{id}/_attr/{attr}", + params( + path_schema::UuidOrName, + path_schema::Attr, + ), + request_body=Vec, + responses( + DefaultApiResponse, + ), + security(("token_jwt" = [])), + tag = "v1/sync_account", +)] +pub async fn sync_account_id_attr_put( State(state): State, Extension(kopid): Extension, Path((id, attr)): Path<(String, String)>, Json(values): Json>, -) -> impl IntoResponse { +) -> Result, WebError> { let filter = filter_all!(f_eq(Attribute::Class, EntryClass::SyncAccount.into())); json_rest_event_put_id_attr(state, id, attr, filter, values, kopid).await } -async fn scim_sink_get() -> impl IntoResponse { - r#" - - - - - - - Sink! - - - - -
-                        ___
-                      .' _ '.
-                     / /` `\ \
-                     | |   [__]
-                     | |    {{
-                     | |    }}
-                  _  | |  _ {{
-      ___________<_>_| |_<_>}}________
-          .=======^=(___)=^={{====.
-         / .----------------}}---. \
-        / /                 {{    \ \
-       / /                  }}     \ \
-      (  '========================='  )
-       '-----------------------------'
-            
- - "# +/// When you want the kitchen Sink +async fn scim_sink_get() -> Html<&'static str> { + Html::from(include_str!("scim/sink.html")) } -pub fn scim_route_setup() -> Router { +pub fn route_setup() -> Router { Router::new() // https://datatracker.ietf.org/doc/html/rfc7644#section-3.2 // @@ -254,6 +379,30 @@ pub fn scim_route_setup() -> Router { // // POST Send a sync update // + .route( + "/v1/sync_account", + get(sync_account_get).post(sync_account_post), + ) + .route( + "/v1/sync_account/:id", + get(sync_account_id_get).patch(sync_account_id_patch), + ) + .route( + "/v1/sync_account/:id/_attr/:attr", + get(sync_account_id_attr_get).put(sync_account_id_attr_put), + ) + .route( + "/v1/sync_account/:id/_finalise", + get(sync_account_id_finalise_get), + ) + .route( + "/v1/sync_account/:id/_terminate", + get(sync_account_id_terminate_get), + ) + .route( + "/v1/sync_account/:id/_sync_token", + post(sync_account_token_post).delete(sync_account_token_delete), + ) .route("/scim/v1/Sync", post(scim_sync_post).get(scim_sync_get)) - .route("/scim/v1/Sink", get(scim_sink_get)) + .route("/scim/v1/Sink", get(scim_sink_get)) // skip_route_check } diff --git a/server/core/src/repl/mod.rs b/server/core/src/repl/mod.rs index 0bf0f2203..da2498030 100644 --- a/server/core/src/repl/mod.rs +++ b/server/core/src/repl/mod.rs @@ -373,6 +373,7 @@ struct ConsumerConnSettings { replica_connect_timeout: Duration, } +#[allow(clippy::too_many_arguments)] async fn repl_task( origin: Url, client_key: PKey, @@ -827,7 +828,7 @@ async fn repl_acceptor( } if respond.send(success).is_err() { - warn!("Server certificate renewal was requested, but requsetor disconnected"); + warn!("Server certificate renewal was requested, but requester disconnected!"); } else { trace!("Sent server certificate renewal status via control channel"); } diff --git a/server/lib/src/filter.rs b/server/lib/src/filter.rs index a7d7bb3d5..198010b67 100644 --- a/server/lib/src/filter.rs +++ b/server/lib/src/filter.rs @@ -278,11 +278,13 @@ impl fmt::Debug for FilterResolved { } #[derive(Debug, Clone, PartialEq, Eq)] +/// A filter before it's gone through schema validation pub struct FilterInvalid { inner: FilterComp, } #[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] +/// A filter after it's gone through schema validation pub struct FilterValid { inner: FilterComp, } diff --git a/server/testkit/Cargo.toml b/server/testkit/Cargo.toml index 3308749c4..c9926b417 100644 --- a/server/testkit/Cargo.toml +++ b/server/testkit/Cargo.toml @@ -27,6 +27,7 @@ kanidmd_core = { workspace = true } kanidmd_lib = { workspace = true } # used for webdriver testing hyper-tls = { workspace = true } +http = { workspace = true } # used for webdriver testing fantoccini = { version = "0.19.3", optional = true } serde = { workspace = true } diff --git a/server/testkit/tests/default_entries.rs b/server/testkit/tests/default_entries.rs index 829f976bd..439ac2b7a 100644 --- a/server/testkit/tests/default_entries.rs +++ b/server/testkit/tests/default_entries.rs @@ -649,31 +649,19 @@ async fn test_https_robots_txt(rsclient: KanidmClient) { eprintln!( "csp headers: {:#?}", - response.headers().get("content-security-policy") + response + .headers() + .get(http::header::CONTENT_SECURITY_POLICY) + ); + assert_ne!( + response + .headers() + .get(http::header::CONTENT_SECURITY_POLICY), + None ); - assert_ne!(response.headers().get("content-security-policy"), None); eprintln!("{}", response.text().await.unwrap()); } -// TODO: #1787 when the routemap comes back -// #[kanidmd_testkit::test] -// async fn test_https_routemap(rsclient: KanidmClient) { -// // We need to do manual reqwests here. -// let response = match reqwest::get(rsclient.make_url("/v1/routemap")).await { -// Ok(value) => value, -// Err(error) => { -// panic!("Failed to query {:?} : {:#?}", addr, error); -// } -// }; -// eprintln!("response: {:#?}", response); -// assert_eq!(response.status(), 200); - -// let body = response.text().await.unwrap(); -// eprintln!("{}", body); -// assert!(body.contains("/scim/v1/Sync")); -// assert!(body.contains(r#""path": "/v1/routemap""#)); -// } - /// This literally tests that the thing exists and responds in a way we expect, probably worth testing it better... #[kanidmd_testkit::test] async fn test_v1_raw_delete(rsclient: KanidmClient) { diff --git a/server/testkit/tests/http_manifest.rs b/server/testkit/tests/http_manifest.rs index a3a40432c..7e78c3e22 100644 --- a/server/testkit/tests/http_manifest.rs +++ b/server/testkit/tests/http_manifest.rs @@ -20,6 +20,8 @@ async fn test_https_manifest(rsclient: KanidmClient) { eprintln!( "csp headers: {:#?}", - response.headers().get("content-security-policy") + response + .headers() + .get(http::header::CONTENT_SECURITY_POLICY) ); } diff --git a/server/testkit/tests/https_extractors.rs b/server/testkit/tests/https_extractors.rs index 49d0f2e38..335d617d2 100644 --- a/server/testkit/tests/https_extractors.rs +++ b/server/testkit/tests/https_extractors.rs @@ -4,6 +4,7 @@ use std::{ }; use kanidm_client::KanidmClient; +use kanidm_proto::constants::X_FORWARDED_FOR; const DEFAULT_IP_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)); @@ -18,7 +19,7 @@ async fn dont_trust_xff_send_header(rsclient: KanidmClient) { let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) .header( - "X-Forwarded-For", + X_FORWARDED_FOR, "An invalid header that will get through!!!", ) .send() @@ -41,7 +42,7 @@ async fn dont_trust_xff_dont_send_header(rsclient: KanidmClient) { let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) .header( - "X-Forwarded-For", + X_FORWARDED_FOR, "An invalid header that will get through!!!", ) .send() @@ -69,7 +70,7 @@ async fn trust_xff_send_invalid_header_single_value(rsclient: KanidmClient) { let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) .header( - "X-Forwarded-For", + X_FORWARDED_FOR, "An invalid header that will get through!!!", ) .send() @@ -91,7 +92,7 @@ async fn trust_xff_send_invalid_header_multiple_values(rsclient: KanidmClient) { let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) .header( - "X-Forwarded-For", + X_FORWARDED_FOR, "203.0.113.195_noooo_my_ip_address, 2001:db8:85a3:8d3:1319:8a2e:370:7348", ) .send() @@ -111,7 +112,7 @@ async fn trust_xff_send_valid_header_single_ipv4_address(rsclient: KanidmClient) .unwrap(); let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) - .header("X-Forwarded-For", ip_addr) + .header(X_FORWARDED_FOR, ip_addr) .send() .await .unwrap(); @@ -133,7 +134,7 @@ async fn trust_xff_send_valid_header_single_ipv6_address(rsclient: KanidmClient) .unwrap(); let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) - .header("X-Forwarded-For", ip_addr) + .header(X_FORWARDED_FOR, ip_addr) .send() .await .unwrap(); @@ -155,7 +156,7 @@ async fn trust_xff_send_valid_header_multiple_address(rsclient: KanidmClient) { .unwrap(); let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) - .header("X-Forwarded-For", first_ip_addr) + .header(X_FORWARDED_FOR, first_ip_addr) .send() .await .unwrap(); @@ -177,7 +178,7 @@ async fn trust_xff_send_valid_header_multiple_address(rsclient: KanidmClient) { .unwrap(); let res = client .get(rsclient.make_url("/v1/debug/ipinfo")) - .header("X-Forwarded-For", second_ip_addr) + .header(X_FORWARDED_FOR, second_ip_addr) .send() .await .unwrap(); diff --git a/server/testkit/tests/https_middleware.rs b/server/testkit/tests/https_middleware.rs index 2179aa6d2..4e02a9118 100644 --- a/server/testkit/tests/https_middleware.rs +++ b/server/testkit/tests/https_middleware.rs @@ -19,9 +19,16 @@ async fn test_https_middleware_headers(rsclient: KanidmClient) { assert_eq!(response.status(), 200); eprintln!( "csp headers: {:#?}", - response.headers().get("content-security-policy") + response + .headers() + .get(http::header::CONTENT_SECURITY_POLICY) + ); + assert_ne!( + response + .headers() + .get(http::header::CONTENT_SECURITY_POLICY), + None ); - assert_ne!(response.headers().get("content-security-policy"), None); // here we test the /ui/login endpoint which should have the headers let response = match reqwest::get(rsclient.make_url("/ui/login")).await { @@ -39,7 +46,14 @@ async fn test_https_middleware_headers(rsclient: KanidmClient) { eprintln!( "csp headers: {:#?}", - response.headers().get("content-security-policy") + response + .headers() + .get(http::header::CONTENT_SECURITY_POLICY) + ); + assert_ne!( + response + .headers() + .get(http::header::CONTENT_SECURITY_POLICY), + None ); - assert_ne!(response.headers().get("content-security-policy"), None); } diff --git a/server/testkit/tests/oauth2_test.rs b/server/testkit/tests/oauth2_test.rs index 0d2325375..a67410529 100644 --- a/server/testkit/tests/oauth2_test.rs +++ b/server/testkit/tests/oauth2_test.rs @@ -23,7 +23,7 @@ macro_rules! assert_no_cache { // Check we have correct nocache headers. let cache_header: &str = $response .headers() - .get("cache-control") + .get(http::header::CACHE_CONTROL) .expect("missing cache-control header") .to_str() .expect("invalid cache-control header"); @@ -151,9 +151,10 @@ async fn test_oauth2_openid_basic_flow(rsclient: KanidmClient) { .expect("Failed to send discovery preflight request."); assert!(response.status() == reqwest::StatusCode::OK); + let cors_header: &str = response .headers() - .get("access-control-allow-origin") + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) .expect("missing access-control-allow-origin header") .to_str() .expect("invalid access-control-allow-origin header"); @@ -646,7 +647,7 @@ async fn test_oauth2_openid_public_flow(rsclient: KanidmClient) { assert!(response.status() == reqwest::StatusCode::OK); let cors_header: &str = response .headers() - .get("access-control-allow-origin") + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) .expect("missing access-control-allow-origin header") .to_str() .expect("invalid access-control-allow-origin header"); diff --git a/server/testkit/tests/proto_v1_test.rs b/server/testkit/tests/proto_v1_test.rs index 71841225c..740371086 100644 --- a/server/testkit/tests/proto_v1_test.rs +++ b/server/testkit/tests/proto_v1_test.rs @@ -2,6 +2,7 @@ use std::path::Path; use std::time::SystemTime; +use kanidm_proto::constants::KSESSIONID; use kanidm_proto::internal::ImageValue; use kanidm_proto::v1::{ ApiToken, AuthCredential, AuthIssueSession, AuthMech, AuthRequest, AuthResponse, AuthState, @@ -1826,7 +1827,7 @@ async fn start_password_session( }; assert_eq!(res.status(), 200); - let session_id = res.headers().get("x-kanidm-auth-session-id").unwrap(); + let session_id = res.headers().get(KSESSIONID).unwrap(); let authreq = AuthRequest { step: AuthStep::Begin(AuthMech::Password), @@ -1836,7 +1837,7 @@ async fn start_password_session( let res = match client .post(rsclient.make_url("/v1/auth")) .header("Content-Type", "application/json") - .header("x-kanidm-auth-session-id", session_id) + .header(KSESSIONID, session_id) .body(authreq) .send() .await @@ -1854,7 +1855,7 @@ async fn start_password_session( let res = match client .post(rsclient.make_url("/v1/auth")) .header("Content-Type", "application/json") - .header("x-kanidm-auth-session-id", session_id) + .header(KSESSIONID, session_id) .body(authreq) .send() .await diff --git a/server/testkit/tests/scim_test.rs b/server/testkit/tests/scim_test.rs index f5d8f8678..b4ff9ecee 100644 --- a/server/testkit/tests/scim_test.rs +++ b/server/testkit/tests/scim_test.rs @@ -105,6 +105,7 @@ async fn test_scim_sync_get(rsclient: KanidmClient) { .default_headers(headers) .build() .unwrap(); + // here we test the /ui/ endpoint which should have the headers let response = match client.get(rsclient.make_url("/scim/v1/Sync")).send().await { Ok(value) => value, @@ -117,12 +118,30 @@ async fn test_scim_sync_get(rsclient: KanidmClient) { } }; eprintln!("response: {:#?}", response); - // assert_eq!(response.status(), 200); + assert!(response.status().is_client_error()); + + // check that the CSP headers are coming back + eprintln!( + "csp headers: {:#?}", + response.headers().get(http::header::CONTENT_SECURITY_POLICY) + ); + assert_ne!(response.headers().get(http::header::CONTENT_SECURITY_POLICY), None); + + // test that the proper content type comes back + let url = rsclient.make_url("/scim/v1/Sink"); + let response = match client.get(url.clone()).send().await { + Ok(value) => value, + Err(error) => { + panic!( + "Failed to query {:?} : {:#?}", + url, + error + ); + } + }; + assert!( response.status().is_success()); + let content_type = response.headers().get(http::header::CONTENT_TYPE).unwrap(); + assert!(content_type.to_str().unwrap().contains("text/html")); + assert!(response.text().await.unwrap().contains("Sink")); - // eprintln!( - // "csp headers: {:#?}", - // response.headers().get("content-security-policy") - // ); - // assert_ne!(response.headers().get("content-security-policy"), None); - // eprintln!("{}", response.text().await.unwrap()); } diff --git a/server/web_ui/src/lib.rs b/server/web_ui/src/lib.rs index 81d953472..ebcac7eb1 100644 --- a/server/web_ui/src/lib.rs +++ b/server/web_ui/src/lib.rs @@ -15,7 +15,7 @@ use error::FetchError; use gloo::console; -use kanidm_proto::constants::APPLICATION_JSON; +use kanidm_proto::constants::{APPLICATION_JSON, KSESSIONID}; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; @@ -91,7 +91,7 @@ pub async fn do_request( if let Some(sessionid) = models::pop_auth_session_id() { request .headers() - .set("x-kanidm-auth-session-id", &sessionid) + .set(KSESSIONID, &sessionid) .expect_throw("failed to set auth session id header"); } @@ -108,7 +108,7 @@ pub async fn do_request( let status = resp.status(); let headers: Headers = resp.headers(); - if let Some(sessionid) = headers.get("x-kanidm-auth-session-id").ok().flatten() { + if let Some(sessionid) = headers.get(KSESSIONID).ok().flatten() { models::push_auth_session_id(sessionid); }