Skip to main content

rskit_auth/apikey/
rotation.rs

1//! API key rotation with grace periods.
2
3use chrono::{DateTime, Duration, Utc};
4use rskit_errors::{AppError, ErrorCode};
5
6use super::{GenerateResult, Key, KeySpec, Manager, Store, validate};
7
8/// Default grace period: 7 days.
9pub const DEFAULT_GRACE_PERIOD: Duration = Duration::days(7);
10
11/// Rotation settings for API keys.
12#[derive(Debug, Clone)]
13pub struct RotationConfig {
14    /// Grace period for the old key.
15    pub grace_period: Duration,
16    /// Replacement key identifier.
17    pub new_key_id: String,
18    /// Replacement key owner.
19    pub owner_id: String,
20    /// Replacement key display name.
21    pub name: String,
22    /// Replacement key prefix.
23    pub prefix: String,
24    /// Replacement scopes. Empty reuses the existing key scopes.
25    pub scopes: Vec<String>,
26    /// Optional replacement expiry.
27    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/// Rotation result.
45#[derive(Debug, Clone)]
46pub struct RotationResult {
47    /// Newly issued key material.
48    pub issued: GenerateResult,
49    /// Persisted replacement record.
50    pub record: Key,
51    /// Grace period end for the old key.
52    pub grace_ends_at: DateTime<Utc>,
53}
54
55impl<S: Store> Manager<S> {
56    /// Rotate a key and issue a replacement.
57    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}