From 55b4d7cef6af1f285c120ab4f51e307d72b44f95 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Tue, 20 Jan 2026 10:29:16 +0100 Subject: [PATCH 1/5] refactor: add domain and webhook workspace crates - New libs/domain crate: client, user, realm, role, identity modules, CoreError and UUID/timestamp helpers - New libs/webhook crate: webhook entities, triggers, inputs and ports - Register both crates in workspace and add ferriskey-domain as a core dependency - Bump thiserror to 2.0.18 in Cargo.lock --- Cargo.lock | 121 +++++++--- Cargo.toml | 2 +- core/Cargo.toml | 1 + libs/domain/Cargo.toml | 15 ++ libs/domain/src/client/entities/mod.rs | 1 + .../src/client/entities/redirect_uri.rs | 32 +++ libs/domain/src/client/inputs.rs | 72 ++++++ libs/domain/src/client/mod.rs | 91 +++++++ libs/domain/src/client/ports.rs | 183 ++++++++++++++ libs/domain/src/client/value_objects.rs | 51 ++++ libs/domain/src/identity/mod.rs | 84 +++++++ libs/domain/src/lib.rs | 224 ++++++++++++++++++ libs/domain/src/realm/mod.rs | 118 +++++++++ libs/domain/src/realm/ports.rs | 5 + libs/domain/src/role/mod.rs | 19 ++ libs/domain/src/user/inputs.rs | 50 ++++ libs/domain/src/user/mod.rs | 69 ++++++ libs/domain/src/user/ports.rs | 205 ++++++++++++++++ libs/domain/src/user/required_action.rs | 52 ++++ libs/domain/src/user/value_objects.rs | 25 ++ libs/webhook/Cargo.toml | 13 + libs/webhook/src/entities.rs | 92 +++++++ libs/webhook/src/entities/inputs.rs | 43 ++++ libs/webhook/src/entities/trigger.rs | 134 +++++++++++ libs/webhook/src/lib.rs | 5 + libs/webhook/src/ports.rs | 130 ++++++++++ 26 files changed, 1802 insertions(+), 35 deletions(-) create mode 100644 libs/domain/Cargo.toml create mode 100644 libs/domain/src/client/entities/mod.rs create mode 100644 libs/domain/src/client/entities/redirect_uri.rs create mode 100644 libs/domain/src/client/inputs.rs create mode 100644 libs/domain/src/client/mod.rs create mode 100644 libs/domain/src/client/ports.rs create mode 100644 libs/domain/src/client/value_objects.rs create mode 100644 libs/domain/src/identity/mod.rs create mode 100644 libs/domain/src/lib.rs create mode 100644 libs/domain/src/realm/mod.rs create mode 100644 libs/domain/src/realm/ports.rs create mode 100644 libs/domain/src/role/mod.rs create mode 100644 libs/domain/src/user/inputs.rs create mode 100644 libs/domain/src/user/mod.rs create mode 100644 libs/domain/src/user/ports.rs create mode 100644 libs/domain/src/user/required_action.rs create mode 100644 libs/domain/src/user/value_objects.rs create mode 100644 libs/webhook/Cargo.toml create mode 100644 libs/webhook/src/entities.rs create mode 100644 libs/webhook/src/entities/inputs.rs create mode 100644 libs/webhook/src/entities/trigger.rs create mode 100644 libs/webhook/src/lib.rs create mode 100644 libs/webhook/src/ports.rs diff --git a/Cargo.lock b/Cargo.lock index e6d73136..3e671a85 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -185,7 +185,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -696,9 +696,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -1323,7 +1323,7 @@ dependencies = [ "num", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "typetag", "uuid", ] @@ -1372,7 +1372,7 @@ dependencies = [ "serde", "serde_json", "test-context", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tower-http", "tracing", @@ -1399,13 +1399,14 @@ dependencies = [ "base64 0.22.1", "chrono", "enum-display", + "ferriskey-domain", "futures", "hex", "hmac", "jsonwebtoken", "ldap3", "maskass", - "mockall", + "mockall 0.13.1", "p256", "rand 0.8.5", "regex", @@ -1419,7 +1420,7 @@ dependencies = [ "sha2", "signature", "sqlx", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "urlencoding", @@ -1429,6 +1430,20 @@ dependencies = [ "x509-parser 0.18.0", ] +[[package]] +name = "ferriskey-domain" +version = "0.1.0" +dependencies = [ + "chrono", + "enum-display", + "mockall 0.14.0", + "rand 0.8.5", + "serde", + "thiserror 2.0.18", + "utoipa", + "uuid", +] + [[package]] name = "ferriskey-operator" version = "0.1.0" @@ -1438,14 +1453,14 @@ dependencies = [ "k8s-openapi", "kube", "kube-derive 1.1.0", - "mockall", + "mockall 0.13.1", "rand 0.9.2", "reqwest", "schemars", "serde", "serde_json", "serde_yaml", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tower", "tower-http", @@ -2243,7 +2258,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2324,7 +2339,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-util", "tower", @@ -2348,7 +2363,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2400,7 +2415,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", @@ -2440,7 +2455,7 @@ dependencies = [ "native-tls", "nom", "percent-encoding", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-native-tls", "tokio-stream", @@ -2579,7 +2594,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2650,7 +2665,21 @@ dependencies = [ "cfg-if", "downcast", "fragile", - "mockall_derive", + "mockall_derive 0.13.1", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive 0.14.0", "predicates", "predicates-tree", ] @@ -2667,6 +2696,18 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "mockall_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -2883,7 +2924,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] @@ -2913,7 +2954,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tonic", "tracing", @@ -2944,7 +2985,7 @@ dependencies = [ "opentelemetry", "percent-encoding", "rand 0.9.2", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3582,7 +3623,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21918d6644020c6f6ef1993242989bf6d4952d2e025617744f184c02df51c356" dependencies = [ - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3704,7 +3745,7 @@ dependencies = [ "http", "mime", "rand 0.9.2", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3891,7 +3932,7 @@ dependencies = [ "serde_json", "sqlx", "strum", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tracing", "url", @@ -4204,7 +4245,7 @@ checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" dependencies = [ "num-bigint", "num-traits", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -4302,7 +4343,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -4390,7 +4431,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -4433,7 +4474,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tracing", "uuid", @@ -4460,7 +4501,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tracing", "url", @@ -4628,11 +4669,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4648,9 +4689,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -4969,7 +5010,7 @@ dependencies = [ "opentelemetry_sdk", "rustversion", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "tracing-core", "tracing-log", @@ -5467,6 +5508,18 @@ dependencies = [ "url", ] +[[package]] +name = "webhook" +version = "0.1.0" +dependencies = [ + "chrono", + "ferriskey-domain", + "mockall 0.14.0", + "serde", + "utoipa", + "uuid", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5884,7 +5937,7 @@ dependencies = [ "nom", "oid-registry 0.8.1", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 76336f27..c158c989 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["core", "api", "operator", "libs/maskass"] +members = ["core", "api", "operator", "libs/maskass", "libs/webhook", "libs/domain"] resolver = "2" [workspace.package] diff --git a/core/Cargo.toml b/core/Cargo.toml index 9fea692f..a6eccf62 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true [dependencies] maskass = { path = "../libs/maskass" } +ferriskey-domain = { path = "../libs/domain" } anyhow = "1.0.98" argon2 = "0.5.3" base32 = "0.5.1" diff --git a/libs/domain/Cargo.toml b/libs/domain/Cargo.toml new file mode 100644 index 00000000..3a61f1d4 --- /dev/null +++ b/libs/domain/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ferriskey-domain" +version.workspace = true +authors.workspace = true +edition.workspace = true + +[dependencies] +chrono = { version = "0.4.43", features = ["serde"] } +enum-display = "0.2.1" +mockall = "0.14.0" +rand = "0.8.0" +serde = { version = "1.0.228", features = ["derive"] } +thiserror = "2.0.18" +utoipa = { version = "5.4.0", features = ["chrono", "uuid"] } +uuid = { version = "1.19.0", features = ["serde", "v7"] } diff --git a/libs/domain/src/client/entities/mod.rs b/libs/domain/src/client/entities/mod.rs new file mode 100644 index 00000000..c873f28d --- /dev/null +++ b/libs/domain/src/client/entities/mod.rs @@ -0,0 +1 @@ +pub mod redirect_uri; diff --git a/libs/domain/src/client/entities/redirect_uri.rs b/libs/domain/src/client/entities/redirect_uri.rs new file mode 100644 index 00000000..b9438f4b --- /dev/null +++ b/libs/domain/src/client/entities/redirect_uri.rs @@ -0,0 +1,32 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::generate_uuid_v7; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, ToSchema)] +pub struct RedirectUri { + pub id: Uuid, + pub client_id: Uuid, + pub value: String, + pub enabled: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl RedirectUri { + pub fn new(client_id: Uuid, value: String, enabled: bool) -> Self { + let now = Utc::now(); + let id = generate_uuid_v7(); + + Self { + id, + client_id, + value, + enabled, + created_at: now, + updated_at: now, + } + } +} diff --git a/libs/domain/src/client/inputs.rs b/libs/domain/src/client/inputs.rs new file mode 100644 index 00000000..f02a72ec --- /dev/null +++ b/libs/domain/src/client/inputs.rs @@ -0,0 +1,72 @@ +use uuid::Uuid; + +use crate::client::value_objects::{CreateRedirectUriRequest, UpdateClientRequest}; + +pub struct CreateClientInput { + pub realm_name: String, + pub name: String, + pub client_id: String, + pub client_type: String, + pub service_account_enabled: bool, + pub public_client: bool, + pub protocol: String, + pub enabled: bool, + pub direct_access_grants_enabled: bool, +} + +pub struct CreateRedirectUriInput { + pub client_id: Uuid, + pub realm_name: String, + pub payload: CreateRedirectUriRequest, +} + +pub struct CreateRoleInput { + pub realm_name: String, + pub client_id: Uuid, + pub description: Option, + pub name: String, + pub permissions: Vec, +} + +pub struct DeleteClientInput { + pub realm_name: String, + pub client_id: Uuid, +} + +pub struct DeleteRedirectUriInput { + pub realm_name: String, + pub client_id: Uuid, + pub uri_id: Uuid, +} + +pub struct GetClientInput { + pub client_id: Uuid, + pub realm_name: String, +} + +pub struct GetClientRolesInput { + pub client_id: Uuid, + pub realm_name: String, +} + +pub struct GetRedirectUrisInput { + pub realm_name: String, + pub client_id: Uuid, +} + +pub struct GetClientsInput { + pub realm_name: String, +} + +pub struct UpdateClientInput { + pub realm_name: String, + pub client_id: Uuid, + pub payload: UpdateClientRequest, +} + +pub struct UpdateRedirectUriInput { + pub realm_name: String, + pub client_id: Uuid, + pub redirect_uri_id: Uuid, + pub enabled: bool, +} diff --git a/libs/domain/src/client/mod.rs b/libs/domain/src/client/mod.rs new file mode 100644 index 00000000..0f166adf --- /dev/null +++ b/libs/domain/src/client/mod.rs @@ -0,0 +1,91 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + client::entities::redirect_uri::RedirectUri, generate_random_string, generate_uuid_v7, + realm::RealmId, +}; + +pub mod entities; +pub mod inputs; +pub mod ports; +pub mod value_objects; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, ToSchema)] +pub struct Client { + pub id: Uuid, + pub enabled: bool, + pub client_id: String, + pub secret: Option, + pub realm_id: RealmId, + pub protocol: String, + pub public_client: bool, + pub service_account_enabled: bool, + pub direct_access_grants_enabled: bool, + pub client_type: String, + pub name: String, + pub redirect_uris: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct ClientConfig { + pub realm_id: RealmId, + pub name: String, + pub client_id: String, + pub secret: Option, + pub enabled: bool, + pub protocol: String, + pub public_client: bool, + pub service_account_enabled: bool, + pub client_type: String, + pub direct_access_grants_enabled: Option, +} + +impl Client { + pub fn new(config: ClientConfig) -> Self { + let id = generate_uuid_v7(); + let now = Utc::now(); + + Self { + id, + enabled: config.enabled, + client_id: config.client_id, + secret: config.secret, + realm_id: config.realm_id, + protocol: config.protocol, + public_client: config.public_client, + service_account_enabled: config.service_account_enabled, + direct_access_grants_enabled: config.direct_access_grants_enabled.unwrap_or_default(), + client_type: config.client_type, + name: config.name, + redirect_uris: None, + created_at: now, + updated_at: now, + } + } + + pub fn from_realm_and_client_id(realm_id: RealmId, client_id: String) -> Self { + let id = generate_uuid_v7(); + let now = Utc::now(); + + Self { + id, + enabled: true, + client_id: client_id.clone(), + secret: Some(generate_random_string()), + realm_id, + protocol: "openid-connect".to_string(), + public_client: false, + service_account_enabled: false, + direct_access_grants_enabled: false, + client_type: "confidential".to_string(), + name: format!("{client_id} Client"), + redirect_uris: None, + created_at: now, + updated_at: now, + } + } +} diff --git a/libs/domain/src/client/ports.rs b/libs/domain/src/client/ports.rs new file mode 100644 index 00000000..ded981df --- /dev/null +++ b/libs/domain/src/client/ports.rs @@ -0,0 +1,183 @@ +use uuid::Uuid; + +use crate::{ + CoreError, + client::{ + Client, + entities::redirect_uri::RedirectUri, + inputs::{ + CreateClientInput, CreateRedirectUriInput, CreateRoleInput, DeleteClientInput, + DeleteRedirectUriInput, GetClientInput, GetClientRolesInput, GetClientsInput, + GetRedirectUrisInput, UpdateClientInput, UpdateRedirectUriInput, + }, + value_objects::{CreateClientRequest, CreateRedirectUriRequest, UpdateClientRequest}, + }, + identity::Identity, + realm::{Realm, RealmId}, + role::Role, +}; + +pub trait ClientService: Send + Sync { + fn create_client( + &self, + identity: Identity, + input: CreateClientInput, + ) -> impl Future> + Send; + fn create_redirect_uri( + &self, + identity: Identity, + input: CreateRedirectUriInput, + ) -> impl Future> + Send; + fn create_role( + &self, + identity: Identity, + input: CreateRoleInput, + ) -> impl Future> + Send; + fn delete_client( + &self, + identity: Identity, + input: DeleteClientInput, + ) -> impl Future> + Send; + fn delete_redirect_uri( + &self, + identity: Identity, + input: DeleteRedirectUriInput, + ) -> impl Future> + Send; + fn get_client_roles( + &self, + identity: Identity, + input: GetClientRolesInput, + ) -> impl Future, CoreError>> + Send; + fn get_client_by_id( + &self, + identity: Identity, + input: GetClientInput, + ) -> impl Future> + Send; + fn get_clients( + &self, + identity: Identity, + input: GetClientsInput, + ) -> impl Future, CoreError>> + Send; + + fn get_redirect_uris( + &self, + identity: Identity, + input: GetRedirectUrisInput, + ) -> impl Future, CoreError>> + Send; + fn update_client( + &self, + identity: Identity, + input: UpdateClientInput, + ) -> impl Future> + Send; + fn update_redirect_uri( + &self, + identity: Identity, + input: UpdateRedirectUriInput, + ) -> impl Future> + Send; +} + +pub trait ClientPolicy: Send + Sync { + fn can_create_client( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_update_client( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_delete_client( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_view_client( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; +} + +#[cfg_attr(test, mockall::automock)] +pub trait ClientRepository: Send + Sync { + fn create_client( + &self, + data: CreateClientRequest, + ) -> impl Future> + Send; + + fn get_by_client_id( + &self, + client_id: String, + realm_id: RealmId, + ) -> impl Future> + Send; + + fn get_by_id(&self, id: Uuid) -> impl Future> + Send; + fn get_by_realm_id( + &self, + realm_id: RealmId, + ) -> impl Future, CoreError>> + Send; + + fn update_client( + &self, + client_id: Uuid, + data: UpdateClientRequest, + ) -> impl Future> + Send; + + fn delete_by_id(&self, id: Uuid) -> impl Future> + Send; +} + +pub trait RedirectUriService: Send + Sync { + fn add_redirect_uri( + &self, + payload: CreateRedirectUriRequest, + realm_name: String, + client_id: Uuid, + ) -> impl Future> + Send; + + fn get_by_client_id( + &self, + client_id: Uuid, + ) -> impl Future, CoreError>> + Send; + + fn get_enabled_by_client_id( + &self, + client_id: Uuid, + ) -> impl Future, CoreError>> + Send; + + fn update_enabled( + &self, + id: Uuid, + enabled: bool, + ) -> impl Future> + Send; + + fn delete(&self, id: Uuid) -> impl Future> + Send; +} + +#[cfg_attr(test, mockall::automock)] +pub trait RedirectUriRepository: Send + Sync { + fn create_redirect_uri( + &self, + client_id: Uuid, + value: String, + enabled: bool, + ) -> impl Future> + Send; + + fn get_by_client_id( + &self, + client_id: Uuid, + ) -> impl Future, CoreError>> + Send; + + fn get_enabled_by_client_id( + &self, + client_id: Uuid, + ) -> impl Future, CoreError>> + Send; + + fn update_enabled( + &self, + id: Uuid, + enabled: bool, + ) -> impl Future> + Send; + + fn delete(&self, id: Uuid) -> impl Future> + Send; +} diff --git a/libs/domain/src/client/value_objects.rs b/libs/domain/src/client/value_objects.rs new file mode 100644 index 00000000..2d28c221 --- /dev/null +++ b/libs/domain/src/client/value_objects.rs @@ -0,0 +1,51 @@ +use serde::{Deserialize, Serialize}; + +use crate::realm::RealmId; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateClientRequest { + pub realm_id: RealmId, + pub name: String, + pub client_id: String, + pub secret: Option, + pub enabled: bool, + pub protocol: String, + pub public_client: bool, + pub service_account_enabled: bool, + pub direct_access_grants_enabled: bool, + pub client_type: String, +} + +impl CreateClientRequest { + pub fn create_realm_system_client( + realm_id: RealmId, + client_name: String, + ) -> CreateClientRequest { + CreateClientRequest { + realm_id, + client_id: client_name.clone(), + client_type: "system".to_string(), + direct_access_grants_enabled: false, + enabled: true, + name: client_name, + protocol: "openid-connect".to_string(), + public_client: true, + secret: None, + service_account_enabled: false, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateClientRequest { + pub name: Option, + pub client_id: Option, + pub enabled: Option, + pub direct_access_grants_enabled: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRedirectUriRequest { + pub value: String, + pub enabled: bool, +} diff --git a/libs/domain/src/identity/mod.rs b/libs/domain/src/identity/mod.rs new file mode 100644 index 00000000..775775e4 --- /dev/null +++ b/libs/domain/src/identity/mod.rs @@ -0,0 +1,84 @@ +use enum_display::EnumDisplay; +use uuid::Uuid; + +use crate::{client::Client, realm::RealmId, user::User}; + +#[derive(Debug, Clone)] +pub enum Identity { + User(User), + Client(Client), +} + +impl Identity { + /// Get the unique identifier of the identity + pub fn id(&self) -> Uuid { + match self { + Self::User(user) => user.id, + Self::Client(client) => client.id, + } + } + + /// Check if this identity is a service account + pub fn is_service_account(&self) -> bool { + matches!(self, Self::Client(_)) + } + + /// Check if this identity is a regular user (not associated with a client) + pub fn is_regular_user(&self) -> bool { + matches!(self, Self::User(user) if user.client_id.is_none()) + } + + /// Get the user if this identity represents a user + pub fn as_user(&self) -> Option<&User> { + match self { + Self::User(user) => Some(user), + _ => None, + } + } + + /// Get the client if this identity represents a client + pub fn as_client(&self) -> Option<&Client> { + match self { + Self::Client(client) => Some(client), + _ => None, + } + } + + /// Get the realm ID this identity belongs to + pub fn realm_id(&self) -> RealmId { + match self { + Self::User(user) => user.realm_id, + Self::Client(client) => client.realm_id, + } + } + + /// Check if this identity has access to the specified realm + /// + /// Business rule: An identity can only access resources in its own realm + pub fn has_access_to_realm(&self, realm_id: Uuid) -> bool { + self.realm_id() == realm_id + } + + /// Get a display name for this identity + pub fn display_name(&self) -> String { + match self { + Self::User(user) => user.username.clone(), + Self::Client(client) => format!("client:{}", client.client_id), + } + } + + /// Get the kind of this identity + pub fn kind(&self) -> IdentityKind { + match self { + Self::User(_) => IdentityKind::User, + Self::Client(_) => IdentityKind::Client, + } + } +} + +#[derive(Clone, Copy, Debug, EnumDisplay, Eq, PartialEq)] +#[display(case = "Kebab")] +pub enum IdentityKind { + User, + Client, +} diff --git a/libs/domain/src/lib.rs b/libs/domain/src/lib.rs new file mode 100644 index 00000000..9ee65122 --- /dev/null +++ b/libs/domain/src/lib.rs @@ -0,0 +1,224 @@ +use chrono::{DateTime, Utc}; +use rand::{Rng, distributions::Alphanumeric}; +use thiserror::Error; +use uuid::{NoContext, Timestamp, Uuid}; + +pub mod client; +pub mod identity; +pub mod realm; +pub mod role; +pub mod user; + +pub fn generate_timestamp() -> (DateTime, Timestamp) { + let now = Utc::now(); + let ts = Timestamp::from_unix( + NoContext, + now.timestamp().try_into().unwrap_or(0), + now.timestamp_subsec_nanos(), + ); + (now, ts) +} + +pub fn generate_uuid_v7() -> Uuid { + let (_, ts) = generate_timestamp(); + Uuid::new_v7(ts) +} + +pub fn generate_random_string() -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect() +} + +#[derive(Error, Debug, Clone)] +pub enum CoreError { + #[error("Not found")] + NotFound, + + #[error("Already exists")] + AlreadyExists, + + #[error("Invalid resource")] + Invalid, + + #[error("Forbidden: {0}")] + Forbidden(String), + + #[error("Internal server error")] + InternalServerError, + + #[error("Redirect URI not found")] + RedirectUriNotFound, + + #[error("Invalid redirect URI")] + InvalidRedirectUri, + + #[error("Invalid client")] + InvalidClient, + + #[error("Invalid realm")] + InvalidRealm, + + #[error("Invalid user")] + InvalidUser, + + #[error("Invalid password")] + InvalidPassword, + + #[error("Invalid state")] + InvalidState, + + #[error("Invalid refresh token")] + InvalidRefreshToken, + + #[error("Invalid client secret")] + InvalidClientSecret, + + #[error("Invalid authorization request")] + InvalidRequest, + + #[error("Service account not found")] + ServiceAccountNotFound, + + #[error("Hash password error: {0}")] + HashPasswordError(String), + + #[error("Verify password error: {0}")] + VerifyPasswordError(String), + + #[error("Failed to delete password credential")] + DeletePasswordCredentialError, + + #[error("Failed to create credential")] + CreateCredentialError, + + #[error("Failed to get password credential")] + GetPasswordCredentialError, + + #[error("Failed to get user credentials")] + GetUserCredentialsError, + + #[error("Failed to delete credential")] + DeleteCredentialError, + + #[error("Token generation error: {0}")] + TokenGenerationError(String), + + #[error("Token validation error: {0}")] + TokenValidationError(String), + + #[error("Token parsing error: {0}")] + TokenParsingError(String), + + #[error("Token expiration error: {0}")] + TokenExpirationError(String), + + #[error("Realm key not found")] + RealmKeyNotFound, + + #[error("Invalid token")] + InvalidToken, + + #[error("Expired token")] + ExpiredToken, + + #[error("Invalid key: {0}")] + InvalidKey(String), + + #[error("Session not found")] + SessionNotFound, + + #[error("Session expired")] + SessionExpired, + + #[error("Invalid session")] + InvalidSession, + + #[error("Failed to create session")] + SessionCreateError, + + #[error("Failed to delete session")] + SessionDeleteError, + + #[error("Invalid TOTP secret format")] + InvalidTotpSecretFormat, + + #[error("TOTP generation failed: {0}")] + TotpGenerationFailed(String), + + #[error("TOTP verification failed: {0}")] + TotpVerificationFailed(String), + + #[error("Recovery code generation failed: {0}")] + RecoveryCodeGenError(String), + + #[error("Recovery code burning failed: {0}")] + RecoveryCodeBurnError(String), + + #[error("Cannot delete master realm")] + CannotDeleteMasterRealm, + + #[error("Webhook not found")] + WebhookNotFound, + + #[error("Webhook forbidden")] + WebhookForbidden, + + #[error("Failed to notify webhook: {0}")] + FailedWebhookNotification(String), + + #[error("Realm not found for webhook")] + WebhookRealmNotFound, + + #[error("Failed to create client")] + CreateClientError, + + #[error("Service unavailable: {0}")] + ServiceUnavailable(String), + + #[error("Authorization code storage failed")] + AuthorizationCodeStorageFailed, + + #[error("Expected an auth session state")] + AuthSessionExpectedState, + + #[error("Missing webauthn challenge")] + WebAuthnMissingChallenge, + + #[error("Webauthn credential not found")] + WebAuthnCredentialNotFound, + + #[error("Webauthn challenge failed")] + WebAuthnChallengeFailed, + + // Provider (Abyss) errors + #[error("Provider not found")] + ProviderNotFound, + + #[error("Provider name already exists")] + ProviderNameAlreadyExists, + + #[error("Invalid provider configuration")] + InvalidProviderConfiguration, + + #[error("Provider is disabled")] + ProviderDisabled, + + #[error("Invalid provider URL")] + InvalidProviderUrl, + + // Infrastructure errors + #[error("External error: {0}")] + External(String), + + #[error("Database error: {0}")] + Database(String), + + #[error("Configuration error: {0}")] + Configuration(String), + + #[error("Federation authentication error: {0}")] + FederationAuthenticationFailed(String), +} diff --git a/libs/domain/src/realm/mod.rs b/libs/domain/src/realm/mod.rs new file mode 100644 index 00000000..bdee7627 --- /dev/null +++ b/libs/domain/src/realm/mod.rs @@ -0,0 +1,118 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{generate_timestamp, generate_uuid_v7}; + +pub mod ports; + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, PartialOrd, Ord, +)] +pub struct RealmId(Uuid); + +impl RealmId { + pub fn new(id: Uuid) -> Self { + Self(id) + } + + pub fn id(&self) -> Uuid { + self.0 + } +} + +impl Default for RealmId { + fn default() -> Self { + Self::new(generate_uuid_v7()) + } +} + +impl From for RealmId { + fn from(value: Uuid) -> Self { + Self(value) + } +} + +impl From for Uuid { + fn from(value: RealmId) -> Self { + value.0 + } +} + +impl PartialEq for RealmId { + fn eq(&self, other: &Uuid) -> bool { + self.0.eq(other) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] +pub struct Realm { + pub id: RealmId, + pub name: String, + pub settings: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] +pub struct RealmSetting { + pub id: Uuid, + pub realm_id: RealmId, + pub default_signing_algorithm: Option, + pub user_registration_enabled: bool, + pub forgot_password_enabled: bool, + pub remember_me_enabled: bool, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] +pub struct RealmLoginSetting { + pub user_registration_enabled: bool, + pub forgot_password_enabled: bool, + pub remember_me_enabled: bool, +} + +impl From for RealmLoginSetting { + fn from(value: RealmSetting) -> Self { + Self { + forgot_password_enabled: value.forgot_password_enabled, + remember_me_enabled: value.remember_me_enabled, + user_registration_enabled: value.user_registration_enabled, + } + } +} + +impl RealmSetting { + pub fn new(realm_id: RealmId, default_signing_algorithm: Option) -> Self { + let (now, timestamp) = generate_timestamp(); + + Self { + id: Uuid::new_v7(timestamp), + realm_id, + default_signing_algorithm, + forgot_password_enabled: false, + remember_me_enabled: false, + user_registration_enabled: false, + updated_at: now, + } + } +} + +impl Realm { + pub fn new(name: String) -> Self { + let now = Utc::now(); + + Self { + id: RealmId::default(), + name, + settings: None, + created_at: now, + updated_at: now, + } + } + + pub fn can_delete(&self) -> bool { + self.name != "master" + } +} diff --git a/libs/domain/src/realm/ports.rs b/libs/domain/src/realm/ports.rs new file mode 100644 index 00000000..48e05615 --- /dev/null +++ b/libs/domain/src/realm/ports.rs @@ -0,0 +1,5 @@ +// pub trait RealmService: Send + Sync { +// fn get_realms_by_user( +// &self, +// ) +// } diff --git a/libs/domain/src/role/mod.rs b/libs/domain/src/role/mod.rs new file mode 100644 index 00000000..62fce9a4 --- /dev/null +++ b/libs/domain/src/role/mod.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{client::Client, realm::RealmId}; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, ToSchema)] +pub struct Role { + pub id: Uuid, + pub name: String, + pub description: Option, + pub permissions: Vec, + pub realm_id: RealmId, + pub client_id: Option, + pub client: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} diff --git a/libs/domain/src/user/inputs.rs b/libs/domain/src/user/inputs.rs new file mode 100644 index 00000000..16eeced1 --- /dev/null +++ b/libs/domain/src/user/inputs.rs @@ -0,0 +1,50 @@ +use uuid::Uuid; + +pub struct ResetPasswordInput { + pub user_id: Uuid, + pub password: String, + pub temporary: bool, + pub realm_name: String, +} + +pub struct AssignRoleInput { + pub realm_name: String, + pub user_id: Uuid, + pub role_id: Uuid, +} + +pub struct BulkDeleteUsersInput { + pub realm_name: String, + pub ids: Vec, +} + +pub struct CreateUserInput { + pub realm_name: String, + pub username: String, + pub firstname: String, + pub lastname: String, + pub email: String, + pub email_verified: Option, +} + +pub struct GetUserInput { + pub realm_name: String, + pub user_id: Uuid, +} + +pub struct UpdateUserInput { + pub realm_name: String, + pub user_id: Uuid, + pub firstname: String, + pub lastname: String, + pub email: String, + pub email_verified: Option, + pub enabled: bool, + pub required_actions: Option>, +} + +pub struct UnassignRoleInput { + pub realm_name: String, + pub user_id: Uuid, + pub role_id: Uuid, +} diff --git a/libs/domain/src/user/mod.rs b/libs/domain/src/user/mod.rs new file mode 100644 index 00000000..7577e9aa --- /dev/null +++ b/libs/domain/src/user/mod.rs @@ -0,0 +1,69 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + generate_uuid_v7, + realm::{Realm, RealmId}, + role::Role, + user::required_action::RequiredAction, +}; + +pub mod inputs; +pub mod ports; +pub mod required_action; +pub mod value_objects; + +#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, PartialEq)] +pub struct User { + pub id: Uuid, + pub realm_id: RealmId, + pub client_id: Option, + pub username: String, + pub firstname: String, + pub lastname: String, + pub email: String, + pub email_verified: bool, + pub enabled: bool, + pub roles: Vec, + pub realm: Option, + pub required_actions: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct UserConfig { + pub realm_id: RealmId, + pub client_id: Option, + pub username: String, + pub firstname: String, + pub lastname: String, + pub email: String, + pub email_verified: bool, + pub enabled: bool, +} + +impl User { + pub fn new(user_config: UserConfig) -> Self { + let id = generate_uuid_v7(); + let now = Utc::now(); + + Self { + id, + realm_id: user_config.realm_id, + client_id: user_config.client_id, + username: user_config.username, + firstname: user_config.firstname, + lastname: user_config.lastname, + email: user_config.email, + email_verified: user_config.email_verified, + enabled: user_config.enabled, + roles: Vec::new(), + realm: None, + required_actions: Vec::new(), + created_at: now, + updated_at: now, + } + } +} diff --git a/libs/domain/src/user/ports.rs b/libs/domain/src/user/ports.rs new file mode 100644 index 00000000..904b4fe3 --- /dev/null +++ b/libs/domain/src/user/ports.rs @@ -0,0 +1,205 @@ +use uuid::Uuid; + +use crate::{ + CoreError, + identity::Identity, + realm::{Realm, RealmId}, + role::Role, + user::{ + User, + inputs::{ + AssignRoleInput, BulkDeleteUsersInput, CreateUserInput, GetUserInput, + ResetPasswordInput, UnassignRoleInput, UpdateUserInput, + }, + required_action::{RequiredAction, RequiredActionError}, + value_objects::{CreateUserRequest, UpdateUserRequest}, + }, +}; + +pub trait UserService: Send + Sync { + fn delete_user( + &self, + identity: Identity, + realm_name: String, + user_id: Uuid, + ) -> impl Future> + Send; + + fn update_user( + &self, + identity: Identity, + input: UpdateUserInput, + ) -> impl Future> + Send; + + fn reset_password( + &self, + identity: Identity, + input: ResetPasswordInput, + ) -> impl Future> + Send; + fn get_users( + &self, + identity: Identity, + realm_name: String, + ) -> impl Future, CoreError>> + Send; + fn assign_role( + &self, + identity: Identity, + input: AssignRoleInput, + ) -> impl Future> + Send; + fn bulk_delete_users( + &self, + identity: Identity, + input: BulkDeleteUsersInput, + ) -> impl Future> + Send; + fn create_user( + &self, + identity: Identity, + input: CreateUserInput, + ) -> impl Future> + Send; + fn get_user( + &self, + identity: Identity, + input: GetUserInput, + ) -> impl Future> + Send; + fn unassign_role( + &self, + identity: Identity, + input: UnassignRoleInput, + ) -> impl Future> + Send; +} + +#[cfg_attr(test, mockall::automock)] +pub trait UserRepository: Send + Sync { + fn create_user( + &self, + dto: CreateUserRequest, + ) -> impl Future> + Send; + + fn get_by_username( + &self, + username: String, + realm_id: RealmId, + ) -> impl Future> + Send; + + fn get_by_client_id( + &self, + client_id: Uuid, + ) -> impl Future> + Send; + + fn get_by_id(&self, user_id: Uuid) -> impl Future> + Send; + + fn find_by_realm_id( + &self, + realm_id: RealmId, + ) -> impl Future, CoreError>> + Send; + + fn bulk_delete_user( + &self, + ids: Vec, + ) -> impl Future> + Send; + + fn delete_user(&self, user_id: Uuid) -> impl Future> + Send; + + fn update_user( + &self, + user_id: Uuid, + dto: UpdateUserRequest, + ) -> impl Future> + Send; +} + +#[cfg_attr(test, mockall::automock)] +pub trait UserRequiredActionRepository: Send + Sync { + fn add_required_action( + &self, + user_id: Uuid, + action: RequiredAction, + ) -> impl Future> + Send; + + fn remove_required_action( + &self, + user_id: Uuid, + action: RequiredAction, + ) -> impl Future> + Send; + + fn get_required_actions( + &self, + user_id: Uuid, + ) -> impl Future, RequiredActionError>> + Send; + + fn clear_required_actions( + &self, + user_id: Uuid, + ) -> impl Future> + Send; +} + +pub trait UserRoleService: Send + Sync { + fn assign_role( + &self, + realm_name: String, + user_id: Uuid, + role_id: Uuid, + ) -> impl Future> + Send; + + fn revoke_role( + &self, + realm_id: RealmId, + user_id: Uuid, + role_id: Uuid, + ) -> impl Future> + Send; + + fn get_user_roles( + &self, + user_id: Uuid, + ) -> impl Future, CoreError>> + Send; + + fn has_role( + &self, + user_id: Uuid, + role_id: Uuid, + ) -> impl Future> + Send; +} + +pub trait UserPolicy: Send + Sync { + fn can_create_user( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_view_user( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_update_user( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_delete_user( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; +} + +#[cfg_attr(test, mockall::automock)] +pub trait UserRoleRepository: Send + Sync { + fn assign_role( + &self, + user_id: Uuid, + role_id: Uuid, + ) -> impl Future> + Send; + fn revoke_role( + &self, + user_id: Uuid, + role_id: Uuid, + ) -> impl Future> + Send; + fn get_user_roles( + &self, + user_id: Uuid, + ) -> impl Future, CoreError>> + Send; + fn has_role( + &self, + user_id: Uuid, + role_id: Uuid, + ) -> impl Future> + Send; +} diff --git a/libs/domain/src/user/required_action.rs b/libs/domain/src/user/required_action.rs new file mode 100644 index 00000000..f0d4e5ab --- /dev/null +++ b/libs/domain/src/user/required_action.rs @@ -0,0 +1,52 @@ +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use utoipa::ToSchema; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, ToSchema)] +pub enum RequiredAction { + #[serde(rename = "configure_otp")] + ConfigureOtp, + + #[serde(rename = "verify_email")] + VerifyEmail, + + #[serde(rename = "update_password")] + UpdatePassword, +} + +#[derive(Debug, Clone, Error)] +pub enum RequiredActionError { + #[error("Required action not found")] + NotFound, + #[error("Required action already exists")] + AlreadyExists, + #[error("Invalid required action")] + Invalid, + #[error("Internal server error")] + InternalServerError, +} + +impl Display for RequiredAction { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RequiredAction::ConfigureOtp => write!(f, "configure_otp"), + RequiredAction::VerifyEmail => write!(f, "verify_email"), + RequiredAction::UpdatePassword => write!(f, "update_password"), + } + } +} + +impl TryFrom for RequiredAction { + type Error = RequiredActionError; + + fn try_from(value: String) -> Result { + match value.as_str() { + "configure_otp" => Ok(RequiredAction::ConfigureOtp), + "verify_email" => Ok(RequiredAction::VerifyEmail), + "update_password" => Ok(RequiredAction::UpdatePassword), + _ => Err(RequiredActionError::Invalid), + } + } +} diff --git a/libs/domain/src/user/value_objects.rs b/libs/domain/src/user/value_objects.rs new file mode 100644 index 00000000..52cdb315 --- /dev/null +++ b/libs/domain/src/user/value_objects.rs @@ -0,0 +1,25 @@ +use uuid::Uuid; + +use crate::realm::RealmId; + +#[derive(Debug, Clone)] +pub struct CreateUserRequest { + pub realm_id: RealmId, + pub client_id: Option, + pub username: String, + pub firstname: String, + pub lastname: String, + pub email: String, + pub email_verified: bool, + pub enabled: bool, +} + +#[derive(Debug, Clone)] +pub struct UpdateUserRequest { + pub firstname: String, + pub lastname: String, + pub email: String, + pub email_verified: bool, + pub enabled: bool, + pub required_actions: Option>, +} diff --git a/libs/webhook/Cargo.toml b/libs/webhook/Cargo.toml new file mode 100644 index 00000000..10167ffe --- /dev/null +++ b/libs/webhook/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "webhook" +version.workspace = true +authors.workspace = true +edition.workspace = true + +[dependencies] +ferriskey-domain = { path = "../domain" } +chrono = { version = "0.4.43", features = ["serde"] } +serde = "1.0.228" +utoipa = "5.4.0" +uuid = { version = "1.19.0", features = ["v7"] } +mockall = "0.14.0" diff --git a/libs/webhook/src/entities.rs b/libs/webhook/src/entities.rs new file mode 100644 index 00000000..ef3cbc05 --- /dev/null +++ b/libs/webhook/src/entities.rs @@ -0,0 +1,92 @@ +use std::collections::HashMap; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::{NoContext, Timestamp, Uuid}; + +use crate::entities::trigger::WebhookTrigger; + +pub(crate) mod inputs; +pub(crate) mod trigger; + +pub struct Webhook { + pub id: Uuid, + pub endpoint: String, + pub headers: HashMap, + pub name: Option, + pub description: Option, + pub subscribers: Vec, + pub triggered_at: Option>, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +impl Webhook { + pub fn new( + endpoint: String, + subscribers: Vec, + name: Option, + description: Option, + triggered_at: Option>, + updated_at: DateTime, + created_at: DateTime, + ) -> Self { + let now = Utc::now(); + let seconds = now.timestamp().try_into().unwrap_or(0); + let timestamp = Timestamp::from_unix(NoContext, seconds, 0); + Self { + id: Uuid::new_v7(timestamp), + headers: HashMap::new(), + endpoint, + name, + description, + subscribers, + triggered_at, + updated_at, + created_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] +pub struct WebhookSubscriber { + pub id: Uuid, + pub name: WebhookTrigger, + pub webhook_id: Uuid, +} + +impl WebhookSubscriber { + pub fn new(id: Uuid, name: WebhookTrigger, webhook_id: Uuid) -> Self { + Self { + id, + name, + webhook_id, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct WebhookPayload +where + T: Serialize + Send + Sync + Clone + 'static, +{ + pub event: WebhookTrigger, + pub timestamp: String, + pub resource_id: Uuid, + pub data: Option, +} + +impl WebhookPayload +where + T: Serialize + Send + Sync + Clone + 'static, +{ + pub fn new(event: WebhookTrigger, resource_id: Uuid, data: Option) -> Self { + WebhookPayload { + event, + timestamp: Utc::now().to_rfc3339(), + resource_id, + data, + } + } +} diff --git a/libs/webhook/src/entities/inputs.rs b/libs/webhook/src/entities/inputs.rs new file mode 100644 index 00000000..2bcb09d1 --- /dev/null +++ b/libs/webhook/src/entities/inputs.rs @@ -0,0 +1,43 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::entities::trigger::WebhookTrigger; + +pub struct GetWebhooksInput { + pub realm_name: String, +} + +pub struct GetWebhookInput { + pub realm_name: String, + pub webhook_id: Uuid, +} + +pub struct GetWebhookSubscribersInput { + pub realm_name: String, + pub subscriber: WebhookTrigger, +} + +pub struct CreateWebhookInput { + pub realm_name: String, + pub name: Option, + pub description: Option, + pub endpoint: String, + pub headers: HashMap, + pub subscribers: Vec, +} + +pub struct UpdateWebhookInput { + pub realm_name: String, + pub webhook_id: Uuid, + pub name: Option, + pub description: Option, + pub endpoint: String, + pub headers: HashMap, + pub subscribers: Vec, +} + +pub struct DeleteWebhookInput { + pub realm_name: String, + pub webhook_id: Uuid, +} diff --git a/libs/webhook/src/entities/trigger.rs b/libs/webhook/src/entities/trigger.rs new file mode 100644 index 00000000..1bb0f10b --- /dev/null +++ b/libs/webhook/src/entities/trigger.rs @@ -0,0 +1,134 @@ +use std::fmt::{Debug, Display}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, ToSchema, Ord, PartialOrd)] +#[serde(rename_all = "snake_case")] +pub enum WebhookTrigger { + #[serde(rename = "user.created")] + UserCreated, + #[serde(rename = "user.updated")] + UserUpdated, + #[serde(rename = "user.deleted")] + UserDeleted, + #[serde(rename = "user.role.assigned")] + UserRoleAssigned, + #[serde(rename = "user.role.unassigned")] + UserRoleUnassigned, + #[serde(rename = "user.bulk_deleted")] + UserBulkDeleted, + #[serde(rename = "user.credentials.deleted")] + UserDeleteCredentials, + #[serde(rename = "auth.reset_password")] + AuthResetPassword, + #[serde(rename = "client.created")] + ClientCreated, + #[serde(rename = "client.updated")] + ClientUpdated, + #[serde(rename = "client.deleted")] + ClientDeleted, + #[serde(rename = "client.role.created")] + ClientRoleCreated, + #[serde(rename = "client.role.updated")] + ClientRoleUpdated, + #[serde(rename = "redirect_uri.created")] + RedirectUriCreated, + #[serde(rename = "redirect_uri.updated")] + RedirectUriUpdated, + #[serde(rename = "redirect_uri.deleted")] + RedirectUriDeleted, + #[serde(rename = "role.created")] + RoleCreated, + #[serde(rename = "role.updated")] + RoleUpdated, + #[serde(rename = "role.deleted")] + RoleDeleted, + #[serde(rename = "role.permission.updated")] + RolePermissionUpdated, + #[serde(rename = "realm.created")] + RealmCreated, + #[serde(rename = "realm.updated")] + RealmUpdated, + #[serde(rename = "realm.deleted")] + RealmDeleted, + #[serde(rename = "realm.settings.updated")] + RealmSettingsUpdated, + #[serde(rename = "webhook.created")] + WebhookCreated, + #[serde(rename = "webhook.updated")] + WebhookUpdated, + #[serde(rename = "webhook.deleted")] + WebhookDeleted, +} + +impl Display for WebhookTrigger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + WebhookTrigger::UserCreated => write!(f, "user.created"), + WebhookTrigger::UserUpdated => write!(f, "user.updated"), + WebhookTrigger::UserDeleted => write!(f, "user.deleted"), + WebhookTrigger::UserBulkDeleted => write!(f, "user.bulk_deleted"), + WebhookTrigger::UserDeleteCredentials => write!(f, "user.credentials.deleted"), + WebhookTrigger::UserRoleAssigned => write!(f, "user.role.assigned"), + WebhookTrigger::UserRoleUnassigned => write!(f, "user.role.unassigned"), + WebhookTrigger::AuthResetPassword => write!(f, "auth.reset_password"), + WebhookTrigger::ClientCreated => write!(f, "client.created"), + WebhookTrigger::ClientUpdated => write!(f, "client.updated"), + WebhookTrigger::ClientDeleted => write!(f, "client.deleted"), + WebhookTrigger::ClientRoleCreated => write!(f, "client.role.created"), + WebhookTrigger::ClientRoleUpdated => write!(f, "client.role.updated"), + WebhookTrigger::RedirectUriCreated => write!(f, "redirect_uri.created"), + WebhookTrigger::RedirectUriUpdated => write!(f, "redirect_uri.updated"), + WebhookTrigger::RedirectUriDeleted => write!(f, "redirect_uri.deleted"), + WebhookTrigger::RoleCreated => write!(f, "role.created"), + WebhookTrigger::RoleUpdated => write!(f, "role.updated"), + WebhookTrigger::RolePermissionUpdated => write!(f, "role.permission.updated"), + WebhookTrigger::RoleDeleted => write!(f, "role.deleted"), + WebhookTrigger::RealmCreated => write!(f, "realm.created"), + WebhookTrigger::RealmUpdated => write!(f, "realm.updated"), + WebhookTrigger::RealmDeleted => write!(f, "realm.deleted"), + WebhookTrigger::RealmSettingsUpdated => write!(f, "realm.settings.updated"), + WebhookTrigger::WebhookCreated => write!(f, "webhook.created"), + WebhookTrigger::WebhookUpdated => write!(f, "webhook.updated"), + WebhookTrigger::WebhookDeleted => write!(f, "webhook.deleted"), + } + } +} + +impl TryFrom for WebhookTrigger { + type Error = String; + + fn try_from(value: String) -> Result { + match value.as_str() { + "user.created" => Ok(WebhookTrigger::UserCreated), + "user.updated" => Ok(WebhookTrigger::UserUpdated), + "user.deleted" => Ok(WebhookTrigger::UserDeleted), + "user.bulk_deleted" => Ok(WebhookTrigger::UserBulkDeleted), + "user.credentials.deleted" => Ok(WebhookTrigger::UserDeleteCredentials), + "user.role.assigned" => Ok(WebhookTrigger::UserRoleAssigned), + "user.role.unassigned" => Ok(WebhookTrigger::UserRoleUnassigned), + "auth.reset_password" => Ok(WebhookTrigger::AuthResetPassword), + "client.created" => Ok(WebhookTrigger::ClientCreated), + "client.updated" => Ok(WebhookTrigger::ClientUpdated), + "client.deleted" => Ok(WebhookTrigger::ClientDeleted), + "client.role.created" => Ok(WebhookTrigger::ClientRoleCreated), + "client.role.updated" => Ok(WebhookTrigger::ClientRoleUpdated), + "redirect_uri.created" => Ok(WebhookTrigger::RedirectUriCreated), + "redirect_uri.updated" => Ok(WebhookTrigger::RedirectUriUpdated), + "redirect_uri.deleted" => Ok(WebhookTrigger::RedirectUriDeleted), + "role.created" => Ok(WebhookTrigger::RoleCreated), + "role.updated" => Ok(WebhookTrigger::RoleUpdated), + "role.permission.updated" => Ok(WebhookTrigger::RolePermissionUpdated), + "role.deleted" => Ok(WebhookTrigger::RoleDeleted), + "realm.created" => Ok(WebhookTrigger::RealmCreated), + "realm.updated" => Ok(WebhookTrigger::RealmUpdated), + "realm.deleted" => Ok(WebhookTrigger::RealmDeleted), + "realm.settings.updated" => Ok(WebhookTrigger::RealmSettingsUpdated), + "webhook.created" => Ok(WebhookTrigger::WebhookCreated), + "webhook.updated" => Ok(WebhookTrigger::WebhookUpdated), + "webhook.deleted" => Ok(WebhookTrigger::WebhookDeleted), + _ => Err("Invalid webhook trigger".to_string()), + } + } +} diff --git a/libs/webhook/src/lib.rs b/libs/webhook/src/lib.rs new file mode 100644 index 00000000..504bbc8c --- /dev/null +++ b/libs/webhook/src/lib.rs @@ -0,0 +1,5 @@ +mod entities; +mod ports; + +pub use entities::*; +pub use ports::*; diff --git a/libs/webhook/src/ports.rs b/libs/webhook/src/ports.rs new file mode 100644 index 00000000..994eab5b --- /dev/null +++ b/libs/webhook/src/ports.rs @@ -0,0 +1,130 @@ +use std::collections::HashMap; + +use ferriskey_domain::{ + CoreError, + identity::Identity, + realm::{Realm, RealmId}, +}; +use serde::Serialize; +use uuid::Uuid; + +use crate::entities::{ + Webhook, WebhookPayload, + inputs::{ + CreateWebhookInput, DeleteWebhookInput, GetWebhookInput, GetWebhookSubscribersInput, + GetWebhooksInput, UpdateWebhookInput, + }, + trigger::WebhookTrigger, +}; + +pub trait WebhookService: Send + Sync { + fn get_webhooks_by_realm( + &self, + identity: Identity, + input: GetWebhooksInput, + ) -> impl Future, CoreError>> + Send; + + fn get_webhooks_by_subscribers( + &self, + identity: Identity, + input: GetWebhookSubscribersInput, + ) -> impl Future, CoreError>> + Send; + + fn get_webhook( + &self, + identity: Identity, + input: GetWebhookInput, + ) -> impl Future, CoreError>> + Send; + + fn create_webhook( + &self, + identity: Identity, + input: CreateWebhookInput, + ) -> impl Future> + Send; + + fn update_webhook( + &self, + identity: Identity, + input: UpdateWebhookInput, + ) -> impl Future> + Send; + + fn delete_webhook( + &self, + identity: Identity, + input: DeleteWebhookInput, + ) -> impl Future> + Send; +} + +#[cfg_attr(test, mockall::automock)] +pub trait WebhookRepository: Send + Sync { + fn fetch_webhooks_by_realm( + &self, + realm_id: RealmId, + ) -> impl Future, CoreError>> + Send; + + fn fetch_webhooks_by_subscriber( + &self, + realm_id: RealmId, + subscriber: WebhookTrigger, + ) -> impl Future, CoreError>> + Send; + + fn get_webhook_by_id( + &self, + webhook_id: Uuid, + realm_id: RealmId, + ) -> impl Future, CoreError>> + Send; + + fn create_webhook( + &self, + realm_id: RealmId, + name: Option, + description: Option, + endpoint: String, + headers: HashMap, + subscribers: Vec, + ) -> impl Future> + Send; + + fn update_webhook( + &self, + id: Uuid, + name: Option, + description: Option, + endpoint: String, + headers: HashMap, + subscribers: Vec, + ) -> impl Future> + Send; + + fn delete_webhook(&self, id: Uuid) -> impl Future> + Send; + + fn notify( + &self, + realm_id: RealmId, + payload: WebhookPayload, + ) -> impl Future> + Send; +} + +pub trait WebhookPolicy: Send + Sync { + fn can_create_webhook( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + + fn can_update_webhook( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + + fn can_delete_webhook( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + + fn can_view_webhook( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; +} From 43088dcb2d6839e0342a7a2110536597eded44c6 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Tue, 20 Jan 2026 12:47:44 +0100 Subject: [PATCH 2/5] refactor: add domain and webhook workspace crates - New libs/domain crate: client, user, realm, role, identity modules, CoreError and UUID/timestamp helpers - New libs/webhook crate: webhook entities, triggers, inputs and ports - Register both crates in workspace and add ferriskey-domain as a core dependency - Bump thiserror to 2.0.18 in Cargo.lock Refactor: Add domain and webhook workspace crates --- Cargo.lock | 1 + core/Cargo.toml | 1 + .../domain/authentication/value_objects.rs | 83 +------- core/src/domain/client/entities.rs | 166 +-------------- .../domain/client/entities/redirect_uri.rs | 33 +-- core/src/domain/client/value_objects.rs | 53 +---- core/src/domain/common/entities/app_errors.rs | 193 +----------------- core/src/domain/common/mod.rs | 27 +-- core/src/domain/realm/entities.rs | 110 ---------- core/src/domain/realm/mod.rs | 5 +- core/src/domain/realm/ports.rs | 161 +-------------- core/src/domain/realm/services.rs | 57 +++++- core/src/domain/role/entities.rs | 19 +- core/src/domain/role/services.rs | 53 ++++- core/src/domain/user/entities.rs | 167 +-------------- core/src/domain/user/value_objects.rs | 25 +-- libs/domain/src/realm/ports.rs | 164 ++++++++++++++- libs/webhook/Cargo.toml | 3 + libs/webhook/src/entities.rs | 83 ++++++++ libs/webhook/src/entities/trigger.rs | 61 ++++++ 20 files changed, 435 insertions(+), 1030 deletions(-) delete mode 100644 core/src/domain/realm/entities.rs diff --git a/Cargo.lock b/Cargo.lock index 3e671a85..bd9e6dae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,6 +1427,7 @@ dependencies = [ "utoipa", "uuid", "webauthn-rs", + "webhook", "x509-parser 0.18.0", ] diff --git a/core/Cargo.toml b/core/Cargo.toml index a6eccf62..f2c91a00 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -8,6 +8,7 @@ edition.workspace = true [dependencies] maskass = { path = "../libs/maskass" } ferriskey-domain = { path = "../libs/domain" } +webhook = { path = "../libs/webhook" } anyhow = "1.0.98" argon2 = "0.5.3" base32 = "0.5.1" diff --git a/core/src/domain/authentication/value_objects.rs b/core/src/domain/authentication/value_objects.rs index da6c4d94..e6517847 100644 --- a/core/src/domain/authentication/value_objects.rs +++ b/core/src/domain/authentication/value_objects.rs @@ -1,4 +1,3 @@ -use enum_display::EnumDisplay; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; @@ -7,9 +6,9 @@ use crate::domain::authentication::entities::DecodedToken; use crate::domain::realm::entities::RealmId; use crate::domain::{ authentication::entities::GrantType, - client::entities::Client, user::entities::{RequiredAction, User}, }; +pub use ferriskey_domain::identity::{Identity, IdentityKind}; pub struct AuthenticateRequest { pub realm_name: String, @@ -101,86 +100,6 @@ impl CreateAuthSessionRequest { } } -#[derive(Debug, Clone)] -pub enum Identity { - User(User), - Client(Client), -} - -impl Identity { - /// Get the unique identifier of this identity - pub fn id(&self) -> Uuid { - match self { - Self::User(user) => user.id, - Self::Client(client) => client.id, - } - } - - /// Check if this identity is a service account - pub fn is_service_account(&self) -> bool { - matches!(self, Self::Client(_)) - } - - /// Check if this identity is a regular user (not associated with a client) - pub fn is_regular_user(&self) -> bool { - matches!(self, Self::User(user) if user.client_id.is_none()) - } - - /// Get the user if this identity represents a user - pub fn as_user(&self) -> Option<&User> { - match self { - Self::User(user) => Some(user), - _ => None, - } - } - - /// Get the client if this identity represents a client - pub fn as_client(&self) -> Option<&Client> { - match self { - Self::Client(client) => Some(client), - _ => None, - } - } - - /// Get the realm ID this identity belongs to - pub fn realm_id(&self) -> RealmId { - match self { - Self::User(user) => user.realm_id, - Self::Client(client) => client.realm_id, - } - } - - /// Check if this identity has access to the specified realm - /// - /// Business rule: An identity can only access resources in its own realm - pub fn has_access_to_realm(&self, realm_id: Uuid) -> bool { - self.realm_id() == realm_id - } - - /// Get a display name for this identity - pub fn display_name(&self) -> String { - match self { - Self::User(user) => user.username.clone(), - Self::Client(client) => format!("client:{}", client.client_id), - } - } - - /// Get the kind of this identity - pub fn kind(&self) -> IdentityKind { - match self { - Self::User(_) => IdentityKind::User, - Self::Client(_) => IdentityKind::Client, - } - } -} - -#[derive(Clone, Copy, Debug, EnumDisplay, Eq, PartialEq)] -#[display(case = "Kebab")] -pub enum IdentityKind { - User, - Client, -} - pub struct GenerateTokenInput { pub base_url: String, pub realm_name: String, diff --git a/core/src/domain/client/entities.rs b/core/src/domain/client/entities.rs index 4118221f..dad9de51 100644 --- a/core/src/domain/client/entities.rs +++ b/core/src/domain/client/entities.rs @@ -1,164 +1,10 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::{NoContext, Timestamp, Uuid}; - -use crate::domain::realm::entities::RealmId; -use crate::domain::{ - client::{ - entities::redirect_uri::RedirectUri, - value_objects::{CreateRedirectUriRequest, UpdateClientRequest}, - }, - common::generate_random_string, +pub use ferriskey_domain::client::inputs::{ + CreateClientInput, CreateRedirectUriInput, CreateRoleInput, DeleteClientInput, + DeleteRedirectUriInput, GetClientInput, GetClientRolesInput, GetClientsInput, + GetRedirectUrisInput, UpdateClientInput, UpdateRedirectUriInput, }; +pub use ferriskey_domain::client::{Client, ClientConfig}; pub mod redirect_uri; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord, ToSchema)] -pub struct Client { - pub id: Uuid, - pub enabled: bool, - pub client_id: String, - pub secret: Option, - pub realm_id: RealmId, - pub protocol: String, - pub public_client: bool, - pub service_account_enabled: bool, - pub direct_access_grants_enabled: bool, - pub client_type: String, - pub name: String, - pub redirect_uris: Option>, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -pub struct ClientConfig { - pub realm_id: RealmId, - pub name: String, - pub client_id: String, - pub secret: Option, - pub enabled: bool, - pub protocol: String, - pub public_client: bool, - pub service_account_enabled: bool, - pub client_type: String, - pub direct_access_grants_enabled: Option, -} - -impl Client { - pub fn new(config: ClientConfig) -> Self { - let now = Utc::now(); - let seconds = now.timestamp().try_into().unwrap_or(0); - - let timestamp = Timestamp::from_unix(NoContext, seconds, 0); - Self { - id: Uuid::new_v7(timestamp), - enabled: config.enabled, - client_id: config.client_id, - secret: config.secret, - realm_id: config.realm_id, - protocol: config.protocol, - public_client: config.public_client, - service_account_enabled: config.service_account_enabled, - direct_access_grants_enabled: config.direct_access_grants_enabled.unwrap_or_default(), - client_type: config.client_type, - name: config.name, - redirect_uris: None, - created_at: now, - updated_at: now, - } - } - - pub fn from_realm_and_client_id(realm_id: RealmId, client_id: String) -> Self { - let now = Utc::now(); - let seconds = now.timestamp().try_into().unwrap_or(0); - - let timestamp = Timestamp::from_unix(NoContext, seconds, 0); - - Self { - id: Uuid::new_v7(timestamp), - enabled: true, - client_id: client_id.clone(), - secret: Some(generate_random_string()), - realm_id, - protocol: "openid-connect".to_string(), - public_client: false, - service_account_enabled: false, - direct_access_grants_enabled: false, - client_type: "confidential".to_string(), - name: format!("{client_id} Client"), - redirect_uris: None, - created_at: now, - updated_at: now, - } - } -} - -pub struct CreateClientInput { - pub realm_name: String, - pub name: String, - pub client_id: String, - pub client_type: String, - pub service_account_enabled: bool, - pub public_client: bool, - pub protocol: String, - pub enabled: bool, - pub direct_access_grants_enabled: bool, -} - -pub struct CreateRedirectUriInput { - pub client_id: Uuid, - pub realm_name: String, - pub payload: CreateRedirectUriRequest, -} - -pub struct CreateRoleInput { - pub realm_name: String, - pub client_id: Uuid, - pub description: Option, - pub name: String, - pub permissions: Vec, -} - -pub struct DeleteClientInput { - pub realm_name: String, - pub client_id: Uuid, -} - -pub struct DeleteRedirectUriInput { - pub realm_name: String, - pub client_id: Uuid, - pub uri_id: Uuid, -} - -pub struct GetClientInput { - pub client_id: Uuid, - pub realm_name: String, -} - -pub struct GetClientRolesInput { - pub client_id: Uuid, - pub realm_name: String, -} - -pub struct GetRedirectUrisInput { - pub realm_name: String, - pub client_id: Uuid, -} - -pub struct GetClientsInput { - pub realm_name: String, -} - -pub struct UpdateClientInput { - pub realm_name: String, - pub client_id: Uuid, - pub payload: UpdateClientRequest, -} - -pub struct UpdateRedirectUriInput { - pub realm_name: String, - pub client_id: Uuid, - pub redirect_uri_id: Uuid, - pub enabled: bool, -} +pub use ferriskey_domain::client::entities::redirect_uri::RedirectUri; diff --git a/core/src/domain/client/entities/redirect_uri.rs b/core/src/domain/client/entities/redirect_uri.rs index 26aa981e..6d211ccc 100644 --- a/core/src/domain/client/entities/redirect_uri.rs +++ b/core/src/domain/client/entities/redirect_uri.rs @@ -1,32 +1 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::{NoContext, Timestamp, Uuid}; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, ToSchema)] -pub struct RedirectUri { - pub id: Uuid, - pub client_id: Uuid, - pub value: String, - pub enabled: bool, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl RedirectUri { - pub fn new(client_id: Uuid, value: String, enabled: bool) -> Self { - let now = Utc::now(); - let seconds = now.timestamp().try_into().unwrap_or(0); - - let timestamp = Timestamp::from_unix(NoContext, seconds, 0); - - Self { - id: Uuid::new_v7(timestamp), - client_id, - value, - enabled, - created_at: now, - updated_at: now, - } - } -} +pub use ferriskey_domain::client::entities::redirect_uri::RedirectUri; diff --git a/core/src/domain/client/value_objects.rs b/core/src/domain/client/value_objects.rs index 2b9ba667..247b3df8 100644 --- a/core/src/domain/client/value_objects.rs +++ b/core/src/domain/client/value_objects.rs @@ -1,50 +1,3 @@ -use crate::domain::realm::entities::RealmId; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateClientRequest { - pub realm_id: RealmId, - pub name: String, - pub client_id: String, - pub secret: Option, - pub enabled: bool, - pub protocol: String, - pub public_client: bool, - pub service_account_enabled: bool, - pub direct_access_grants_enabled: bool, - pub client_type: String, -} - -impl CreateClientRequest { - pub fn create_realm_system_client( - realm_id: RealmId, - client_name: String, - ) -> CreateClientRequest { - CreateClientRequest { - realm_id, - client_id: client_name.clone(), - client_type: "system".to_string(), - direct_access_grants_enabled: false, - enabled: true, - name: client_name, - protocol: "openid-connect".to_string(), - public_client: true, - secret: None, - service_account_enabled: false, - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateClientRequest { - pub name: Option, - pub client_id: Option, - pub enabled: Option, - pub direct_access_grants_enabled: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreateRedirectUriRequest { - pub value: String, - pub enabled: bool, -} +pub use ferriskey_domain::client::value_objects::{ + CreateClientRequest, CreateRedirectUriRequest, UpdateClientRequest, +}; diff --git a/core/src/domain/common/entities/app_errors.rs b/core/src/domain/common/entities/app_errors.rs index 4d77858c..2c255ffd 100644 --- a/core/src/domain/common/entities/app_errors.rs +++ b/core/src/domain/common/entities/app_errors.rs @@ -1,192 +1 @@ -use thiserror::Error; - -#[derive(Error, Debug, Clone)] -pub enum CoreError { - #[error("Not found")] - NotFound, - - #[error("Already exists")] - AlreadyExists, - - #[error("Invalid resource")] - Invalid, - - #[error("Forbidden: {0}")] - Forbidden(String), - - #[error("Internal server error")] - InternalServerError, - - #[error("Redirect URI not found")] - RedirectUriNotFound, - - #[error("Invalid redirect URI")] - InvalidRedirectUri, - - #[error("Invalid client")] - InvalidClient, - - #[error("Invalid realm")] - InvalidRealm, - - #[error("Invalid user")] - InvalidUser, - - #[error("Invalid password")] - InvalidPassword, - - #[error("Invalid state")] - InvalidState, - - #[error("Invalid refresh token")] - InvalidRefreshToken, - - #[error("Invalid client secret")] - InvalidClientSecret, - - #[error("Invalid authorization request")] - InvalidRequest, - - #[error("Service account not found")] - ServiceAccountNotFound, - - #[error("Hash password error: {0}")] - HashPasswordError(String), - - #[error("Verify password error: {0}")] - VerifyPasswordError(String), - - #[error("Failed to delete password credential")] - DeletePasswordCredentialError, - - #[error("Failed to create credential")] - CreateCredentialError, - - #[error("Failed to get password credential")] - GetPasswordCredentialError, - - #[error("Failed to get user credentials")] - GetUserCredentialsError, - - #[error("Failed to delete credential")] - DeleteCredentialError, - - #[error("Token generation error: {0}")] - TokenGenerationError(String), - - #[error("Token validation error: {0}")] - TokenValidationError(String), - - #[error("Token parsing error: {0}")] - TokenParsingError(String), - - #[error("Token expiration error: {0}")] - TokenExpirationError(String), - - #[error("Realm key not found")] - RealmKeyNotFound, - - #[error("Invalid token")] - InvalidToken, - - #[error("Expired token")] - ExpiredToken, - - #[error("Invalid key: {0}")] - InvalidKey(String), - - #[error("Session not found")] - SessionNotFound, - - #[error("Session expired")] - SessionExpired, - - #[error("Invalid session")] - InvalidSession, - - #[error("Failed to create session")] - SessionCreateError, - - #[error("Failed to delete session")] - SessionDeleteError, - - #[error("Invalid TOTP secret format")] - InvalidTotpSecretFormat, - - #[error("TOTP generation failed: {0}")] - TotpGenerationFailed(String), - - #[error("TOTP verification failed: {0}")] - TotpVerificationFailed(String), - - #[error("Recovery code generation failed: {0}")] - RecoveryCodeGenError(String), - - #[error("Recovery code burning failed: {0}")] - RecoveryCodeBurnError(String), - - #[error("Cannot delete master realm")] - CannotDeleteMasterRealm, - - #[error("Webhook not found")] - WebhookNotFound, - - #[error("Webhook forbidden")] - WebhookForbidden, - - #[error("Failed to notify webhook: {0}")] - FailedWebhookNotification(String), - - #[error("Realm not found for webhook")] - WebhookRealmNotFound, - - #[error("Failed to create client")] - CreateClientError, - - #[error("Service unavailable: {0}")] - ServiceUnavailable(String), - - #[error("Authorization code storage failed")] - AuthorizationCodeStorageFailed, - - #[error("Expected an auth session state")] - AuthSessionExpectedState, - - #[error("Missing webauthn challenge")] - WebAuthnMissingChallenge, - - #[error("Webauthn credential not found")] - WebAuthnCredentialNotFound, - - #[error("Webauthn challenge failed")] - WebAuthnChallengeFailed, - - // Provider (Abyss) errors - #[error("Provider not found")] - ProviderNotFound, - - #[error("Provider name already exists")] - ProviderNameAlreadyExists, - - #[error("Invalid provider configuration")] - InvalidProviderConfiguration, - - #[error("Provider is disabled")] - ProviderDisabled, - - #[error("Invalid provider URL")] - InvalidProviderUrl, - - // Infrastructure errors - #[error("External error: {0}")] - External(String), - - #[error("Database error: {0}")] - Database(String), - - #[error("Configuration error: {0}")] - Configuration(String), - - #[error("Federation authentication error: {0}")] - FederationAuthenticationFailed(String), -} +pub use ferriskey_domain::CoreError; diff --git a/core/src/domain/common/mod.rs b/core/src/domain/common/mod.rs index 60b52b84..29221ca1 100644 --- a/core/src/domain/common/mod.rs +++ b/core/src/domain/common/mod.rs @@ -1,12 +1,10 @@ -use chrono::{DateTime, Utc}; -use rand::{Rng, distributions::Alphanumeric}; -use uuid::{NoContext, Timestamp, Uuid}; - pub mod entities; pub mod policies; pub mod ports; pub mod services; +pub use ferriskey_domain::{generate_random_string, generate_timestamp, generate_uuid_v7}; + pub struct AppConfig { pub database_url: String, } @@ -24,24 +22,3 @@ pub struct DatabaseConfig { pub password: String, pub name: String, } - -pub fn generate_timestamp() -> (DateTime, Timestamp) { - let now = Utc::now(); - let seconds = now.timestamp().try_into().unwrap_or(0); - let timestamp = Timestamp::from_unix(NoContext, seconds, 0); - - (now, timestamp) -} - -pub fn generate_uuid_v7() -> Uuid { - let (_, timestamp) = generate_timestamp(); - Uuid::new_v7(timestamp) -} - -pub fn generate_random_string() -> String { - rand::thread_rng() - .sample_iter(&Alphanumeric) - .take(16) - .map(char::from) - .collect() -} diff --git a/core/src/domain/realm/entities.rs b/core/src/domain/realm/entities.rs deleted file mode 100644 index 018f906c..00000000 --- a/core/src/domain/realm/entities.rs +++ /dev/null @@ -1,110 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::domain::common::{generate_timestamp, generate_uuid_v7}; - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] -pub struct RealmId(Uuid); - -impl RealmId { - pub fn new(value: Uuid) -> Self { - Self(value) - } -} - -impl Default for RealmId { - fn default() -> Self { - Self::new(generate_uuid_v7()) - } -} - -impl From for RealmId { - fn from(value: Uuid) -> Self { - Self(value) - } -} - -impl From for Uuid { - fn from(id: RealmId) -> Self { - id.0 - } -} - -impl PartialEq for RealmId { - fn eq(&self, other: &Uuid) -> bool { - self.0.eq(other) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] -pub struct Realm { - pub id: RealmId, - pub name: String, - pub settings: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] -pub struct RealmSetting { - pub id: Uuid, - pub realm_id: RealmId, - pub default_signing_algorithm: Option, - pub user_registration_enabled: bool, - pub forgot_password_enabled: bool, - pub remember_me_enabled: bool, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Ord, PartialOrd, ToSchema)] -pub struct RealmLoginSetting { - pub user_registration_enabled: bool, - pub forgot_password_enabled: bool, - pub remember_me_enabled: bool, -} - -impl From for RealmLoginSetting { - fn from(value: RealmSetting) -> Self { - Self { - forgot_password_enabled: value.forgot_password_enabled, - remember_me_enabled: value.remember_me_enabled, - user_registration_enabled: value.user_registration_enabled, - } - } -} - -impl RealmSetting { - pub fn new(realm_id: RealmId, default_signing_algorithm: Option) -> Self { - let (now, timestamp) = generate_timestamp(); - - Self { - id: Uuid::new_v7(timestamp), - realm_id, - default_signing_algorithm, - forgot_password_enabled: false, - remember_me_enabled: false, - user_registration_enabled: false, - updated_at: now, - } - } -} - -impl Realm { - pub fn new(name: String) -> Self { - let now = Utc::now(); - - Self { - id: RealmId::default(), - name, - settings: None, - created_at: now, - updated_at: now, - } - } - - pub fn can_delete(&self) -> bool { - self.name != "master" - } -} diff --git a/core/src/domain/realm/mod.rs b/core/src/domain/realm/mod.rs index 4de5eac5..4fe195b0 100644 --- a/core/src/domain/realm/mod.rs +++ b/core/src/domain/realm/mod.rs @@ -1,4 +1,7 @@ -pub mod entities; pub mod policies; pub mod ports; pub mod services; + +pub mod entities { + pub use ferriskey_domain::realm::{Realm, RealmId, RealmLoginSetting, RealmSetting}; +} diff --git a/core/src/domain/realm/ports.rs b/core/src/domain/realm/ports.rs index aa09c7c9..8b4cce72 100644 --- a/core/src/domain/realm/ports.rs +++ b/core/src/domain/realm/ports.rs @@ -1,160 +1 @@ -use std::fmt::Debug; - -use crate::domain::realm::entities::RealmId; -use crate::domain::{ - authentication::value_objects::Identity, - common::entities::app_errors::CoreError, - realm::entities::{Realm, RealmLoginSetting, RealmSetting}, - user::entities::User, -}; - -pub trait RealmService: Send + Sync { - fn get_realms_by_user( - &self, - identity: Identity, - ) -> impl Future, CoreError>> + Send; - - fn get_realm_by_name( - &self, - identity: Identity, - input: GetRealmInput, - ) -> impl Future> + Send; - - fn get_realm_setting_by_name( - &self, - identity: Identity, - input: GetRealmSettingInput, - ) -> impl Future> + Send; - - fn create_realm( - &self, - identity: Identity, - input: CreateRealmInput, - ) -> impl Future> + Send; - - fn create_realm_with_user( - &self, - identity: Identity, - input: CreateRealmWithUserInput, - ) -> impl Future> + Send; - - fn update_realm( - &self, - identity: Identity, - input: UpdateRealmInput, - ) -> impl Future> + Send; - - fn update_realm_setting( - &self, - identity: Identity, - input: UpdateRealmSettingInput, - ) -> impl Future> + Send; - - fn delete_realm( - &self, - identity: Identity, - input: DeleteRealmInput, - ) -> impl Future> + Send; - fn get_login_settings( - &self, - realm_name: String, - ) -> impl Future> + Send; -} - -pub trait RealmPolicy: Send + Sync { - fn can_create_realm( - &self, - identity: Identity, - target_realm: Realm, - ) -> impl Future> + Send; - fn can_delete_realm( - &self, - identity: Identity, - target_realm: Realm, - ) -> impl Future> + Send; - fn can_view_realm( - &self, - identity: Identity, - target_realm: Realm, - ) -> impl Future> + Send; - fn can_update_realm( - &self, - identity: Identity, - target_realm: Realm, - ) -> impl Future> + Send; -} - -#[cfg_attr(test, mockall::automock)] -pub trait RealmRepository: Send + Sync { - fn fetch_realm(&self) -> impl Future, CoreError>> + Send; - - fn get_by_name( - &self, - name: String, - ) -> impl Future, CoreError>> + Send; - - fn create_realm(&self, name: String) -> impl Future> + Send; - - fn update_realm( - &self, - realm_name: String, - name: String, - ) -> impl Future> + Send; - fn delete_by_name(&self, name: String) -> impl Future> + Send; - - fn create_realm_settings( - &self, - realm_id: RealmId, - algorithm: String, - ) -> impl Future> + Send; - - fn update_realm_setting( - &self, - realm_id: RealmId, - algorithm: Option, - user_registration_enabled: Option, - forgot_password_enabled: Option, - remember_me_enabled: Option, - ) -> impl Future> + Send; - - fn get_realm_settings( - &self, - realm_id: RealmId, - ) -> impl Future, CoreError>> + Send; -} - -#[derive(Debug)] // TODO derive debug for instrumetnation -pub struct GetRealmInput { - pub realm_name: String, -} - -pub struct GetRealmSettingInput { - pub realm_name: String, -} - -pub struct CreateRealmInput { - pub realm_name: String, -} - -pub struct CreateRealmWithUserInput { - pub realm_name: String, - pub user: User, -} - -pub struct UpdateRealmInput { - pub realm_name: String, - pub name: String, -} - -pub struct UpdateRealmSettingInput { - pub realm_name: String, - pub algorithm: Option, - - pub user_registration_enabled: Option, - pub forgot_password_enabled: Option, - pub remember_me_enabled: Option, -} - -pub struct DeleteRealmInput { - pub realm_name: String, -} +pub use ferriskey_domain::realm::ports::*; diff --git a/core/src/domain/realm/services.rs b/core/src/domain/realm/services.rs index 8637a26a..265aaf1f 100644 --- a/core/src/domain/realm/services.rs +++ b/core/src/domain/realm/services.rs @@ -8,13 +8,10 @@ use crate::domain::{ generate_random_string, policies::{FerriskeyPolicy, ensure_policy}, }, - realm::{ - entities::{Realm, RealmLoginSetting, RealmSetting}, - ports::{ - CreateRealmInput, CreateRealmWithUserInput, DeleteRealmInput, GetRealmInput, - GetRealmSettingInput, RealmPolicy, RealmRepository, RealmService, UpdateRealmInput, - UpdateRealmSettingInput, - }, + realm::ports::{ + CreateRealmInput, CreateRealmWithUserInput, DeleteRealmInput, GetRealmInput, + GetRealmSettingInput, RealmPolicy, RealmRepository, RealmService, UpdateRealmInput, + UpdateRealmSettingInput, }, role::{ entities::permission::Permissions, ports::RoleRepository, value_objects::CreateRoleRequest, @@ -25,6 +22,7 @@ use crate::domain::{ ports::WebhookRepository, }, }; +use ferriskey_domain::realm::{Realm, RealmLoginSetting, RealmSetting}; use tracing::instrument; #[derive(Clone, Debug)] @@ -540,12 +538,55 @@ mod tests { common::services::tests::{ create_test_realm_with_name, create_test_user_identity_with_realm, }, - realm::{entities::RealmId, ports::MockRealmRepository}, + realm::entities::RealmId, role::ports::MockRoleRepository, user::ports::{MockUserRepository, MockUserRoleRepository}, webhook::ports::MockWebhookRepository, }; + mockall::mock! { + pub RealmRepository {} + impl crate::domain::realm::ports::RealmRepository for RealmRepository { + fn fetch_realm( + &self, + ) -> impl Future, CoreError>> + Send; + fn get_by_name( + &self, + name: String, + ) -> impl Future, CoreError>> + Send; + fn create_realm( + &self, + name: String, + ) -> impl Future> + Send; + fn update_realm( + &self, + realm_name: String, + name: String, + ) -> impl Future> + Send; + fn delete_by_name( + &self, + name: String, + ) -> impl Future> + Send; + fn create_realm_settings( + &self, + realm_id: RealmId, + algorithm: String, + ) -> impl Future> + Send; + fn update_realm_setting( + &self, + realm_id: RealmId, + algorithm: Option, + user_registration_enabled: Option, + forgot_password_enabled: Option, + remember_me_enabled: Option, + ) -> impl Future> + Send; + fn get_realm_settings( + &self, + realm_id: RealmId, + ) -> impl Future, CoreError>> + Send; + } + } + struct RealmServiceTestBuilder { realm_repo: Arc, role_repo: Arc, diff --git a/core/src/domain/role/entities.rs b/core/src/domain/role/entities.rs index f9c12cbc..2d8f868f 100644 --- a/core/src/domain/role/entities.rs +++ b/core/src/domain/role/entities.rs @@ -1,25 +1,8 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; use uuid::Uuid; -use crate::domain::client::entities::Client; -use crate::domain::realm::entities::RealmId; - pub mod permission; -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, ToSchema)] -pub struct Role { - pub id: Uuid, - pub name: String, - pub description: Option, - pub permissions: Vec, - pub realm_id: RealmId, - pub client_id: Option, - pub client: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} +pub use ferriskey_domain::role::Role; pub struct UpdateRoleInput { pub realm_name: String, diff --git a/core/src/domain/role/services.rs b/core/src/domain/role/services.rs index f8fbd206..06d02002 100644 --- a/core/src/domain/role/services.rs +++ b/core/src/domain/role/services.rs @@ -281,6 +281,54 @@ where #[cfg(test)] mod tests { + use crate::domain::{ + common::entities::app_errors::CoreError, + realm::entities::{Realm, RealmId, RealmSetting}, + }; + + mockall::mock! { + pub RealmRepository {} + impl crate::domain::realm::ports::RealmRepository for RealmRepository { + fn fetch_realm( + &self, + ) -> impl Future, CoreError>> + Send; + fn get_by_name( + &self, + name: String, + ) -> impl Future, CoreError>> + Send; + fn create_realm( + &self, + name: String, + ) -> impl Future> + Send; + fn update_realm( + &self, + realm_name: String, + name: String, + ) -> impl Future> + Send; + fn delete_by_name( + &self, + name: String, + ) -> impl Future> + Send; + fn create_realm_settings( + &self, + realm_id: RealmId, + algorithm: String, + ) -> impl Future> + Send; + fn update_realm_setting( + &self, + realm_id: RealmId, + algorithm: Option, + user_registration_enabled: Option, + forgot_password_enabled: Option, + remember_me_enabled: Option, + ) -> impl Future> + Send; + fn get_realm_settings( + &self, + realm_id: RealmId, + ) -> impl Future, CoreError>> + Send; + } + } + use mockall::predicate::*; use uuid::Uuid; @@ -288,17 +336,12 @@ mod tests { authentication::value_objects::Identity, client::{entities::Client, ports::MockClientRepository}, common::{ - entities::app_errors::CoreError, policies::FerriskeyPolicy, services::tests::{ assert_success, create_test_realm, create_test_realm_with_name, create_test_role, create_test_role_with_params, create_test_user, create_test_user_with_realm, }, }, - realm::{ - entities::{Realm, RealmId}, - ports::MockRealmRepository, - }, role::{ entities::{Role, permission::Permissions}, ports::{MockRoleRepository, RoleService}, diff --git a/core/src/domain/user/entities.rs b/core/src/domain/user/entities.rs index 3c113282..7068e43d 100644 --- a/core/src/domain/user/entities.rs +++ b/core/src/domain/user/entities.rs @@ -1,159 +1,10 @@ -use std::fmt::Display; - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use utoipa::ToSchema; -use uuid::Uuid; - -use crate::domain::realm::entities::RealmId; -use crate::domain::{common::generate_uuid_v7, realm::entities::Realm, role::entities::Role}; - -#[derive(Debug, Clone, Deserialize, Serialize, ToSchema, PartialEq)] -pub struct User { - pub id: Uuid, - pub realm_id: RealmId, - pub client_id: Option, - pub username: String, - pub firstname: String, - pub lastname: String, - pub email: String, - pub email_verified: bool, - pub enabled: bool, - pub roles: Vec, - pub realm: Option, - pub required_actions: Vec, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -pub struct UserConfig { - pub realm_id: RealmId, - pub client_id: Option, - pub username: String, - pub firstname: String, - pub lastname: String, - pub email: String, - pub email_verified: bool, - pub enabled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, ToSchema)] -pub enum RequiredAction { - #[serde(rename = "configure_otp")] - ConfigureOtp, - - #[serde(rename = "verify_email")] - VerifyEmail, - - #[serde(rename = "update_password")] - UpdatePassword, -} - -impl Display for RequiredAction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - RequiredAction::ConfigureOtp => write!(f, "configure_otp"), - RequiredAction::VerifyEmail => write!(f, "verify_email"), - RequiredAction::UpdatePassword => write!(f, "update_password"), - } - } -} - -impl TryFrom for RequiredAction { - type Error = RequiredActionError; - - fn try_from(value: String) -> Result { - match value.as_str() { - "configure_otp" => Ok(RequiredAction::ConfigureOtp), - "verify_email" => Ok(RequiredAction::VerifyEmail), - "update_password" => Ok(RequiredAction::UpdatePassword), - _ => Err(RequiredActionError::Invalid), - } - } -} - -#[derive(Debug, Clone, Error)] -pub enum RequiredActionError { - #[error("Required action not found")] - NotFound, - #[error("Required action already exists")] - AlreadyExists, - #[error("Invalid required action")] - Invalid, - #[error("Internal server error")] - InternalServerError, -} - -impl User { - pub fn new(user_config: UserConfig) -> Self { - let now = Utc::now(); - let id = generate_uuid_v7(); - - Self { - id, - realm_id: user_config.realm_id, - client_id: user_config.client_id, - username: user_config.username, - firstname: user_config.firstname, - lastname: user_config.lastname, - email: user_config.email, - email_verified: user_config.email_verified, - enabled: user_config.enabled, - roles: Vec::new(), - realm: None, - required_actions: Vec::new(), - created_at: now, - updated_at: now, - } - } -} - -pub struct ResetPasswordInput { - pub user_id: Uuid, - pub password: String, - pub temporary: bool, - pub realm_name: String, -} - -pub struct AssignRoleInput { - pub realm_name: String, - pub user_id: Uuid, - pub role_id: Uuid, -} - -pub struct BulkDeleteUsersInput { - pub realm_name: String, - pub ids: Vec, -} - -pub struct CreateUserInput { - pub realm_name: String, - pub username: String, - pub firstname: String, - pub lastname: String, - pub email: String, - pub email_verified: Option, -} - -pub struct GetUserInput { - pub realm_name: String, - pub user_id: Uuid, -} - -pub struct UpdateUserInput { - pub realm_name: String, - pub user_id: Uuid, - pub firstname: String, - pub lastname: String, - pub email: String, - pub email_verified: Option, - pub enabled: bool, - pub required_actions: Option>, -} - -pub struct UnassignRoleInput { - pub realm_name: String, - pub user_id: Uuid, - pub role_id: Uuid, +pub use ferriskey_domain::user::inputs::{ + AssignRoleInput, BulkDeleteUsersInput, CreateUserInput, GetUserInput, ResetPasswordInput, + UnassignRoleInput, UpdateUserInput, +}; +pub use ferriskey_domain::user::required_action::{RequiredAction, RequiredActionError}; +pub use ferriskey_domain::user::{User, UserConfig}; + +pub mod required_action { + pub use ferriskey_domain::user::required_action::*; } diff --git a/core/src/domain/user/value_objects.rs b/core/src/domain/user/value_objects.rs index adbab355..6dc8e30b 100644 --- a/core/src/domain/user/value_objects.rs +++ b/core/src/domain/user/value_objects.rs @@ -1,24 +1 @@ -use crate::domain::realm::entities::RealmId; -use uuid::Uuid; - -#[derive(Debug, Clone)] -pub struct CreateUserRequest { - pub realm_id: RealmId, - pub client_id: Option, - pub username: String, - pub firstname: String, - pub lastname: String, - pub email: String, - pub email_verified: bool, - pub enabled: bool, -} - -#[derive(Debug, Clone)] -pub struct UpdateUserRequest { - pub firstname: String, - pub lastname: String, - pub email: String, - pub email_verified: bool, - pub enabled: bool, - pub required_actions: Option>, -} +pub use ferriskey_domain::user::value_objects::{CreateUserRequest, UpdateUserRequest}; diff --git a/libs/domain/src/realm/ports.rs b/libs/domain/src/realm/ports.rs index 48e05615..ef1a33fa 100644 --- a/libs/domain/src/realm/ports.rs +++ b/libs/domain/src/realm/ports.rs @@ -1,5 +1,159 @@ -// pub trait RealmService: Send + Sync { -// fn get_realms_by_user( -// &self, -// ) -// } +use std::fmt::Debug; + +use crate::{ + CoreError, + identity::Identity, + realm::{Realm, RealmId, RealmLoginSetting, RealmSetting}, + user::User, +}; + +pub trait RealmService: Send + Sync { + fn get_realms_by_user( + &self, + identity: Identity, + ) -> impl Future, CoreError>> + Send; + + fn get_realm_by_name( + &self, + identity: Identity, + input: GetRealmInput, + ) -> impl Future> + Send; + + fn get_realm_setting_by_name( + &self, + identity: Identity, + input: GetRealmSettingInput, + ) -> impl Future> + Send; + + fn create_realm( + &self, + identity: Identity, + input: CreateRealmInput, + ) -> impl Future> + Send; + + fn create_realm_with_user( + &self, + identity: Identity, + input: CreateRealmWithUserInput, + ) -> impl Future> + Send; + + fn update_realm( + &self, + identity: Identity, + input: UpdateRealmInput, + ) -> impl Future> + Send; + + fn update_realm_setting( + &self, + identity: Identity, + input: UpdateRealmSettingInput, + ) -> impl Future> + Send; + + fn delete_realm( + &self, + identity: Identity, + input: DeleteRealmInput, + ) -> impl Future> + Send; + fn get_login_settings( + &self, + realm_name: String, + ) -> impl Future> + Send; +} + +pub trait RealmPolicy: Send + Sync { + fn can_create_realm( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_delete_realm( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_view_realm( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; + fn can_update_realm( + &self, + identity: Identity, + target_realm: Realm, + ) -> impl Future> + Send; +} + +#[cfg_attr(test, mockall::automock)] +pub trait RealmRepository: Send + Sync { + fn fetch_realm(&self) -> impl Future, CoreError>> + Send; + + fn get_by_name( + &self, + name: String, + ) -> impl Future, CoreError>> + Send; + + fn create_realm(&self, name: String) -> impl Future> + Send; + + fn update_realm( + &self, + realm_name: String, + name: String, + ) -> impl Future> + Send; + fn delete_by_name(&self, name: String) -> impl Future> + Send; + + fn create_realm_settings( + &self, + realm_id: RealmId, + algorithm: String, + ) -> impl Future> + Send; + + fn update_realm_setting( + &self, + realm_id: RealmId, + algorithm: Option, + user_registration_enabled: Option, + forgot_password_enabled: Option, + remember_me_enabled: Option, + ) -> impl Future> + Send; + + fn get_realm_settings( + &self, + realm_id: RealmId, + ) -> impl Future, CoreError>> + Send; +} + +#[derive(Debug)] // TODO derive debug for instrumetnation +pub struct GetRealmInput { + pub realm_name: String, +} + +pub struct GetRealmSettingInput { + pub realm_name: String, +} + +pub struct CreateRealmInput { + pub realm_name: String, +} + +pub struct CreateRealmWithUserInput { + pub realm_name: String, + pub user: User, +} + +pub struct UpdateRealmInput { + pub realm_name: String, + pub name: String, +} + +pub struct UpdateRealmSettingInput { + pub realm_name: String, + pub algorithm: Option, + + pub user_registration_enabled: Option, + pub forgot_password_enabled: Option, + pub remember_me_enabled: Option, +} + +pub struct DeleteRealmInput { + pub realm_name: String, +} diff --git a/libs/webhook/Cargo.toml b/libs/webhook/Cargo.toml index 10167ffe..4020728f 100644 --- a/libs/webhook/Cargo.toml +++ b/libs/webhook/Cargo.toml @@ -11,3 +11,6 @@ serde = "1.0.228" utoipa = "5.4.0" uuid = { version = "1.19.0", features = ["v7"] } mockall = "0.14.0" + +[dev-dependencies] +uuid = { version = "1.19.0", features = ["v4"] } diff --git a/libs/webhook/src/entities.rs b/libs/webhook/src/entities.rs index ef3cbc05..d97092db 100644 --- a/libs/webhook/src/entities.rs +++ b/libs/webhook/src/entities.rs @@ -90,3 +90,86 @@ where } } } + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + use uuid::Uuid; + + use super::*; + use crate::entities::trigger::WebhookTrigger; + + #[test] + fn webhook_new_sets_fields() { + let endpoint = "https://example.com/webhooks".to_string(); + let webhook_id = Uuid::new_v4(); + let subscriber_id = Uuid::new_v4(); + let subscribers = vec![WebhookSubscriber::new( + subscriber_id, + WebhookTrigger::UserCreated, + webhook_id, + )]; + let name = Some("user hook".to_string()); + let description = Some("user created hook".to_string()); + let triggered_at = Some(Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap()); + let updated_at = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let created_at = Utc.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap(); + + let webhook = Webhook::new( + endpoint.clone(), + subscribers.clone(), + name.clone(), + description.clone(), + triggered_at, + updated_at, + created_at, + ); + + assert_ne!(webhook.id, Uuid::nil()); + assert_eq!(webhook.endpoint, endpoint); + assert!(webhook.headers.is_empty()); + assert_eq!(webhook.subscribers, subscribers); + assert_eq!(webhook.name, name); + assert_eq!(webhook.description, description); + assert_eq!(webhook.triggered_at, triggered_at); + assert_eq!(webhook.updated_at, updated_at); + assert_eq!(webhook.created_at, created_at); + } + + #[test] + fn webhook_subscriber_new_sets_fields() { + let id = Uuid::new_v4(); + let webhook_id = Uuid::new_v4(); + let subscriber = WebhookSubscriber::new(id, WebhookTrigger::RealmUpdated, webhook_id); + + assert_eq!(subscriber.id, id); + assert_eq!(subscriber.name, WebhookTrigger::RealmUpdated); + assert_eq!(subscriber.webhook_id, webhook_id); + } + + #[test] + fn webhook_payload_new_builds_timestamp_and_data() { + let resource_id = Uuid::new_v4(); + let payload = WebhookPayload::new( + WebhookTrigger::WebhookCreated, + resource_id, + Some("payload".to_string()), + ); + + assert_eq!(payload.event, WebhookTrigger::WebhookCreated); + assert_eq!(payload.resource_id, resource_id); + assert_eq!(payload.data, Some("payload".to_string())); + assert!(chrono::DateTime::parse_from_rfc3339(&payload.timestamp).is_ok()); + } + + #[test] + fn webhook_payload_new_allows_none_data() { + let resource_id = Uuid::new_v4(); + let payload: WebhookPayload = + WebhookPayload::new(WebhookTrigger::WebhookDeleted, resource_id, None); + + assert_eq!(payload.event, WebhookTrigger::WebhookDeleted); + assert_eq!(payload.resource_id, resource_id); + assert!(payload.data.is_none()); + } +} diff --git a/libs/webhook/src/entities/trigger.rs b/libs/webhook/src/entities/trigger.rs index 1bb0f10b..0b570d61 100644 --- a/libs/webhook/src/entities/trigger.rs +++ b/libs/webhook/src/entities/trigger.rs @@ -132,3 +132,64 @@ impl TryFrom for WebhookTrigger { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn webhook_trigger_display_and_try_from_round_trip() { + let cases = vec![ + (WebhookTrigger::UserCreated, "user.created"), + (WebhookTrigger::UserUpdated, "user.updated"), + (WebhookTrigger::UserDeleted, "user.deleted"), + (WebhookTrigger::UserRoleAssigned, "user.role.assigned"), + (WebhookTrigger::UserRoleUnassigned, "user.role.unassigned"), + (WebhookTrigger::UserBulkDeleted, "user.bulk_deleted"), + ( + WebhookTrigger::UserDeleteCredentials, + "user.credentials.deleted", + ), + (WebhookTrigger::AuthResetPassword, "auth.reset_password"), + (WebhookTrigger::ClientCreated, "client.created"), + (WebhookTrigger::ClientUpdated, "client.updated"), + (WebhookTrigger::ClientDeleted, "client.deleted"), + (WebhookTrigger::ClientRoleCreated, "client.role.created"), + (WebhookTrigger::ClientRoleUpdated, "client.role.updated"), + (WebhookTrigger::RedirectUriCreated, "redirect_uri.created"), + (WebhookTrigger::RedirectUriUpdated, "redirect_uri.updated"), + (WebhookTrigger::RedirectUriDeleted, "redirect_uri.deleted"), + (WebhookTrigger::RoleCreated, "role.created"), + (WebhookTrigger::RoleUpdated, "role.updated"), + (WebhookTrigger::RoleDeleted, "role.deleted"), + ( + WebhookTrigger::RolePermissionUpdated, + "role.permission.updated", + ), + (WebhookTrigger::RealmCreated, "realm.created"), + (WebhookTrigger::RealmUpdated, "realm.updated"), + (WebhookTrigger::RealmDeleted, "realm.deleted"), + ( + WebhookTrigger::RealmSettingsUpdated, + "realm.settings.updated", + ), + (WebhookTrigger::WebhookCreated, "webhook.created"), + (WebhookTrigger::WebhookUpdated, "webhook.updated"), + (WebhookTrigger::WebhookDeleted, "webhook.deleted"), + ]; + + for (variant, expected) in cases { + assert_eq!(variant.to_string(), expected); + assert_eq!( + WebhookTrigger::try_from(expected.to_string()), + Ok(variant.clone()) + ); + } + } + + #[test] + fn webhook_trigger_try_from_rejects_unknown() { + let result = WebhookTrigger::try_from("unknown.event".to_string()); + assert!(result.is_err()); + } +} From 2a3cb1911017ebccf9e95f8f888cad4ede937cb4 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Tue, 20 Jan 2026 12:49:05 +0100 Subject: [PATCH 3/5] refactor: include domain and webhook libs in Dockerfile --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Dockerfile b/Dockerfile index 8f0769d2..51e8af91 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,8 @@ RUN cargo install sqlx-cli --no-default-features --features postgres COPY Cargo.toml Cargo.lock ./ COPY libs/maskass/Cargo.toml ./libs/maskass/ +COPY libs/domain/Cargo.toml ./libs/domain/ +COPY libs/webhook/Cargo.toml ./libs/webhook/ COPY core/Cargo.toml ./core/ @@ -15,6 +17,8 @@ COPY operator/Cargo.toml ./operator/ RUN \ mkdir -p api/src core/src entity/src operator/src libs/maskass/src && \ touch libs/maskass/src/lib.rs && \ + touch libs/domain/src/lib.rs && \ + touch libs/webhook/src/lib.rs && \ touch core/src/lib.rs && \ echo "fn main() {}" > operator/src/main.rs && \ @@ -29,6 +33,8 @@ COPY operator operator RUN \ touch libs/maskass/src/lib.rs && \ + touch libs/domain/src/lib.rs && \ + touch libs/webhook/src/lib.rs && \ touch core/src/lib.rs && \ touch operator/src/main.rs && \ cargo build --release From 9b006f20f70ec9a01f8ab5d9e85793e2b4d0a0cd Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Tue, 20 Jan 2026 15:36:52 +0100 Subject: [PATCH 4/5] fix: include domain and webhook libs in Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 51e8af91..75e22c08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY api/Cargo.toml ./api/ COPY operator/Cargo.toml ./operator/ RUN \ - mkdir -p api/src core/src entity/src operator/src libs/maskass/src && \ + mkdir -p api/src core/src entity/src operator/src libs/maskass/src libs/domain/src libs/webhook/src && \ touch libs/maskass/src/lib.rs && \ touch libs/domain/src/lib.rs && \ touch libs/webhook/src/lib.rs && \ From 56d9bf16af071709318ba3936686ef1f12b0d126 Mon Sep 17 00:00:00 2001 From: Nathael Bonnal Date: Tue, 20 Jan 2026 18:05:00 +0100 Subject: [PATCH 5/5] fix: bump Rust base and add libs to Dockerfile --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 75e22c08..a66c1a0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.89-bookworm AS rust-build +FROM rust:1.91-bookworm AS rust-build WORKDIR /usr/local/src/ferriskey @@ -26,6 +26,8 @@ RUN \ cargo build --release COPY libs/maskass libs/maskass +COPY libs/domain libs/domain +COPY libs/webhook libs/webhook COPY core core COPY api api