1use chrono::{DateTime, Duration, Utc};
4use rskit_errors::{AppError, ErrorCode};
5
6use super::{GenerateResult, Key, KeySpec, Manager, Store, validate};
7
8pub const DEFAULT_GRACE_PERIOD: Duration = Duration::days(7);
10
11#[derive(Debug, Clone)]
13pub struct RotationConfig {
14 pub grace_period: Duration,
16 pub new_key_id: String,
18 pub owner_id: String,
20 pub name: String,
22 pub prefix: String,
24 pub scopes: Vec<String>,
26 pub expires_at: Option<DateTime<Utc>>,
28}
29
30impl Default for RotationConfig {
31 fn default() -> Self {
32 Self {
33 grace_period: DEFAULT_GRACE_PERIOD,
34 new_key_id: String::new(),
35 owner_id: String::new(),
36 name: String::new(),
37 prefix: String::new(),
38 scopes: Vec::new(),
39 expires_at: None,
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct RotationResult {
47 pub issued: GenerateResult,
49 pub record: Key,
51 pub grace_ends_at: DateTime<Utc>,
53}
54
55impl<S: Store> Manager<S> {
56 pub async fn rotate_key(
58 &self,
59 old_key_id: &str,
60 config: RotationConfig,
61 ) -> Result<RotationResult, AppError> {
62 if config.new_key_id.is_empty() {
63 return Err(AppError::invalid_input(
64 "new_key_id",
65 "new_key_id is required for rotation",
66 ));
67 }
68
69 let old_key = self.store().get_by_id(old_key_id).await?;
70 validate(&old_key)
71 .map_err(|error| invalid_input_error(format!("cannot rotate key: {error}")))?;
72
73 let scopes = if config.scopes.is_empty() {
74 old_key.scopes.clone()
75 } else {
76 config.scopes.clone()
77 };
78 let owner_id = if config.owner_id.is_empty() {
79 old_key.owner_id.clone()
80 } else {
81 config.owner_id.clone()
82 };
83 let name = if config.name.is_empty() {
84 old_key.name.clone()
85 } else {
86 config.name.clone()
87 };
88 let prefix = if config.prefix.is_empty() {
89 old_key.key_prefix.clone()
90 } else {
91 config.prefix.clone()
92 };
93
94 let (issued, record) = self
95 .issue_key(KeySpec {
96 key_id: config.new_key_id.clone(),
97 owner_id,
98 name,
99 prefix,
100 scopes,
101 expires_at: config.expires_at,
102 })
103 .await?;
104
105 let grace_ends_at = Utc::now() + config.grace_period;
106 self.store()
107 .set_rotation(old_key_id, grace_ends_at, Some(record.id.clone()))
108 .await?;
109
110 Ok(RotationResult {
111 issued,
112 record,
113 grace_ends_at,
114 })
115 }
116}
117
118fn invalid_input_error(message: impl Into<String>) -> AppError {
119 AppError::new(ErrorCode::InvalidInput, message.into())
120}
121
122#[cfg(test)]
123mod tests {
124 use chrono::Utc;
125
126 use super::{KeySpec, RotationConfig};
127 use crate::apikey::Store;
128 use crate::apikey::test_support::manager;
129
130 #[tokio::test]
131 async fn issue_validate_and_rotate_key() {
132 let manager = manager();
133 let (issued, record) = manager
134 .issue_key(KeySpec {
135 key_id: String::from("key-1"),
136 owner_id: String::from("user-1"),
137 name: String::from("primary"),
138 prefix: String::from("pk"),
139 scopes: vec![String::from("read")],
140 expires_at: None,
141 })
142 .await
143 .unwrap();
144 assert_eq!(record.key_prefix, "pk");
145
146 let validated = manager
147 .validate_key_with_scopes(&issued.plain_key, &[String::from("read")])
148 .await
149 .unwrap();
150 assert_eq!(validated.owner_id, "user-1");
151 assert!(validated.last_used_at.is_some());
152
153 let rotation = manager
154 .rotate_key(
155 "key-1",
156 RotationConfig {
157 new_key_id: String::from("key-2"),
158 owner_id: String::from("user-1"),
159 name: String::from("secondary"),
160 prefix: String::from("pk"),
161 ..RotationConfig::default()
162 },
163 )
164 .await
165 .unwrap();
166 assert_eq!(rotation.record.id, "key-2");
167 let original = manager.store().get_by_id("key-1").await.unwrap();
168 assert_eq!(original.rotated_by_id.as_deref(), Some("key-2"));
169 }
170
171 #[tokio::test]
172 async fn rotate_key_inherits_existing_metadata_when_config_fields_are_empty() {
173 let manager = manager();
174 let (_issued, original) = manager
175 .issue_key(KeySpec {
176 key_id: String::from("key-1"),
177 owner_id: String::from("owner-1"),
178 name: String::from("primary"),
179 prefix: String::from("pk"),
180 scopes: vec![String::from("read"), String::from("write")],
181 expires_at: None,
182 })
183 .await
184 .unwrap();
185
186 let rotation = manager
187 .rotate_key(
188 &original.id,
189 RotationConfig {
190 new_key_id: String::from("key-2"),
191 ..RotationConfig::default()
192 },
193 )
194 .await
195 .unwrap();
196
197 assert_eq!(rotation.record.owner_id, "owner-1");
198 assert_eq!(rotation.record.name, "primary");
199 assert_eq!(rotation.record.key_prefix, "pk");
200 assert_eq!(
201 rotation.record.scopes,
202 vec![String::from("read"), String::from("write")]
203 );
204 assert!(rotation.grace_ends_at > Utc::now());
205 assert!(!rotation.issued.plain_key.is_empty());
206 }
207
208 #[tokio::test]
209 async fn rotate_key_rejects_missing_new_key_id_before_mutating_store() {
210 let manager = manager();
211 manager
212 .issue_key(KeySpec {
213 key_id: String::from("key-1"),
214 owner_id: String::from("owner-1"),
215 name: String::from("primary"),
216 prefix: String::from("pk"),
217 ..KeySpec::default()
218 })
219 .await
220 .unwrap();
221
222 let error = manager
223 .rotate_key("key-1", RotationConfig::default())
224 .await
225 .unwrap_err();
226
227 assert_eq!(error.code(), rskit_errors::ErrorCode::InvalidInput);
228 let original = manager.store().get_by_id("key-1").await.unwrap();
229 assert!(original.rotated_by_id.is_none());
230 }
231}