Skip to content

Commit 4b5820b

Browse files
committed
feat: add notifications infrastructure for key auto-rotation events
- NotificationsStore trait + implementations (SQLite, PostgreSQL, MySQL) - SMTP email notifier (lettre) for renewal warnings and rotation events - SmtpConfig + RenewalNotificationStrategy configuration - dispatch_renewal_warnings() background scanner with threshold-based dedup - rotate_last_warning_days attribute to prevent duplicate warnings - HTTP API: GET /notifications, GET /notifications/unread/count, POST /notifications/{id}/read, POST /notifications/read-all - NoopNotificationsStore for Redis-findex backend - Allow 0BSD license (quoted_printable, transitive dep of lettre)
1 parent f27b7e5 commit 4b5820b

32 files changed

Lines changed: 1507 additions & 28 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Features
2+
3+
- Add notifications infrastructure for key auto-rotation events
4+
- `NotificationsStore` trait and implementations (SQLite, PostgreSQL, MySQL) for persisting in-app notifications
5+
- SMTP email notifier (`EmailNotifier`) using `lettre` for renewal warnings and rotation success/failure emails
6+
- `SmtpConfig` and `RenewalNotificationStrategy` configuration structs (`NotificationsConfig`)
7+
- `dispatch_renewal_warnings()` background function: scans objects approaching rotation and emits threshold-based warnings
8+
- `rotate_last_warning_days` attribute on `Attributes` to prevent duplicate warnings in the same cycle
9+
- HTTP API endpoints: `GET /notifications`, `GET /notifications/unread/count`, `POST /notifications/{id}/read`, `POST /notifications/read-all`
10+
- `NoopNotificationsStore` for Redis-findex backend (notifications not supported)

Cargo.lock

Lines changed: 67 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ http = "1.4"
174174
jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
175175
lazy_static = "1.5"
176176
leb128 = "0.2"
177+
lettre = { version = "0.11", default-features = false }
177178
libloading = "0.8"
178179
log = { version = "0.4", default-features = false }
179180
lru = "0.16"

crate/interfaces/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ num-bigint-dig = { workspace = true, features = [
2626
"serde",
2727
"zeroize",
2828
] }
29+
serde = { workspace = true }
2930
serde_json = { workspace = true }
3031
thiserror = { workspace = true }
3132
time = { workspace = true }

crate/interfaces/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ pub use hsm::{
1111
HSM, HsmKeyAlgorithm, HsmKeypairAlgorithm, HsmObject, HsmObjectFilter, HsmStore, KeyMaterial,
1212
RsaPrivateKeyMaterial, RsaPublicKeyMaterial,
1313
};
14-
pub use stores::{AtomicOperation, ObjectWithMetadata, ObjectsStore, PermissionsStore};
14+
pub use stores::{
15+
AtomicOperation, Notification, NotificationsStore, ObjectWithMetadata, ObjectsStore,
16+
PermissionsStore,
17+
};
1518

1619
/// Supported cryptographic object types
1720
/// in plugins

crate/interfaces/src/stores/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
mod notifications_store;
12
pub(crate) mod object_with_metadata;
23
mod objects_store;
34
mod permissions_store;
45

6+
pub use notifications_store::{Notification, NotificationsStore};
57
pub use object_with_metadata::ObjectWithMetadata;
68
pub use objects_store::{AtomicOperation, ObjectsStore};
79
pub use permissions_store::PermissionsStore;
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use async_trait::async_trait;
2+
use serde::{Deserialize, Serialize};
3+
use time::OffsetDateTime;
4+
5+
use crate::InterfaceResult;
6+
7+
/// A persisted notification event.
8+
#[derive(Debug, Clone, Serialize, Deserialize)]
9+
pub struct Notification {
10+
pub id: i64,
11+
pub user_id: String,
12+
pub event_type: String,
13+
pub message: String,
14+
pub object_id: Option<String>,
15+
#[serde(with = "time::serde::rfc3339")]
16+
pub created_at: OffsetDateTime,
17+
#[serde(with = "time::serde::rfc3339::option")]
18+
pub read_at: Option<OffsetDateTime>,
19+
}
20+
21+
#[async_trait(?Send)]
22+
pub trait NotificationsStore {
23+
/// Persist a new notification record and return its auto-generated id.
24+
async fn create_notification(
25+
&self,
26+
user_id: &str,
27+
event_type: &str,
28+
message: &str,
29+
object_id: Option<&str>,
30+
created_at: OffsetDateTime,
31+
) -> InterfaceResult<i64>;
32+
33+
/// List notifications for a user (unread first, then by descending creation date).
34+
async fn list_notifications(
35+
&self,
36+
user_id: &str,
37+
limit: i64,
38+
offset: i64,
39+
) -> InterfaceResult<Vec<Notification>>;
40+
41+
/// Return the count of unread notifications for a user.
42+
async fn count_unread(&self, user_id: &str) -> InterfaceResult<i64>;
43+
44+
/// Mark a single notification as read (returns false if not found or not owned by user).
45+
async fn mark_read(&self, id: i64, user_id: &str, now: OffsetDateTime)
46+
-> InterfaceResult<bool>;
47+
48+
/// Mark all notifications as read for the given user.
49+
async fn mark_all_read(&self, user_id: &str, now: OffsetDateTime) -> InterfaceResult<()>;
50+
}

crate/kmip/src/kmip_2_1/kmip_attributes.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,12 @@ pub struct Attributes {
401401
#[serde(skip_serializing_if = "Option::is_none")]
402402
pub rotate_offset: Option<i32>,
403403

404+
/// Tracks the last warning threshold (in days) for which a renewal-warning
405+
/// notification was already sent in the current rotation cycle.
406+
/// Reset to `None` after each successful rotation so warnings restart fresh.
407+
#[serde(skip_serializing_if = "Option::is_none")]
408+
pub rotate_last_warning_days: Option<i32>,
409+
404410
/// If True then the server SHALL prevent the object value being retrieved
405411
/// (via the Get operation) unless it is wrapped by another key. The server
406412
/// SHALL set the value to False if the value is not provided by the client.
@@ -734,6 +740,7 @@ impl Attributes {
734740
merge_option_field!(rotate_latest);
735741
merge_option_field!(rotate_name);
736742
merge_option_field!(rotate_offset);
743+
merge_option_field!(rotate_last_warning_days);
737744
merge_option_field!(sensitive);
738745
merge_option_field!(short_unique_identifier);
739746
merge_option_field!(state);
@@ -964,6 +971,9 @@ impl Display for Attributes {
964971
if let Some(value) = &self.rotate_offset {
965972
writeln!(f, " Rotate Offset: {value}")?;
966973
}
974+
if let Some(value) = &self.rotate_last_warning_days {
975+
writeln!(f, " Rotate Last Warning Days: {value}")?;
976+
}
967977
if let Some(value) = &self.sensitive {
968978
writeln!(f, " Sensitive: {value}")?;
969979
}

crate/server/Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ futures = { workspace = true }
8181
hex = { workspace = true, features = ["serde"] }
8282
http = { workspace = true }
8383
jsonwebtoken = { workspace = true }
84+
lettre = { workspace = true, features = [
85+
"tokio1",
86+
"smtp-transport",
87+
"tokio1-native-tls",
88+
"builder",
89+
] }
8490
num-bigint-dig = { workspace = true, features = [
8591
"std",
8692
"rand",

crate/server/src/config/command_line/clap_config.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
1010

1111
use super::{
1212
GoogleCseConfig, HsmConfig, HttpConfig, IdpAuthConfig, KmipPolicyConfig, MainDBConfig,
13-
WorkspaceConfig, logging::LoggingConfig, ui_config::UiConfig,
13+
NotificationsConfig, WorkspaceConfig, logging::LoggingConfig, ui_config::UiConfig,
1414
};
1515
use crate::{
1616
config::{AzureEkmConfig, ProxyConfig, SocketServerConfig, TlsConfig},
@@ -71,6 +71,7 @@ impl Default for ClapConfig {
7171
kmip_policy: KmipPolicyConfig::default(),
7272
azure_ekm_config: AzureEkmConfig::default(),
7373
auto_rotation_check_interval_secs: 0,
74+
notifications: NotificationsConfig::default(),
7475
}
7576
}
7677
}
@@ -219,6 +220,11 @@ pub struct ClapConfig {
219220
/// Set to 0 (default) to disable the auto-rotation background task.
220221
#[clap(long, default_value = "0", verbatim_doc_comment)]
221222
pub auto_rotation_check_interval_secs: u64,
223+
224+
/// Notification settings (SMTP, renewal warnings).
225+
#[clap(flatten)]
226+
#[serde(default)]
227+
pub notifications: NotificationsConfig,
222228
}
223229

224230
impl ClapConfig {
@@ -661,6 +667,7 @@ impl fmt::Debug for ClapConfig {
661667
"auto_rotation_check_interval_secs",
662668
&self.auto_rotation_check_interval_secs,
663669
);
670+
let x = x.field("notifications", &self.notifications);
664671

665672
x.finish()
666673
}

0 commit comments

Comments
 (0)