Skip to main content

agentics_domain/models/
auth.rs

1//! Web authentication and human identity API models.
2
3use std::borrow::Cow;
4use std::fmt;
5
6use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8
9use super::ids::{AdminServiceTokenId, CreatorApiTokenId, HumanId};
10use super::pioneer_codes::PioneerCodeInput;
11use super::urls::GithubSignInAuthorizationUrl;
12
13/// Validation failure for [`GithubUserId`].
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct GithubUserIdError;
16
17impl fmt::Display for GithubUserIdError {
18    /// Format the user-facing GitHub user id validation error.
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        f.write_str("github_user_id must be a positive integer")
21    }
22}
23
24impl std::error::Error for GithubUserIdError {}
25
26/// Positive numeric GitHub user id returned by GitHub sign-in.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
28pub struct GithubUserId(i64);
29
30impl GithubUserId {
31    /// Parse a positive GitHub user id from an external boundary.
32    pub fn try_new(value: i64) -> Result<Self, GithubUserIdError> {
33        if value <= 0 {
34            return Err(GithubUserIdError);
35        }
36        Ok(Self(value))
37    }
38
39    /// Borrow the numeric value for database and wire boundaries.
40    pub fn as_i64(self) -> i64 {
41        self.0
42    }
43}
44
45impl fmt::Display for GithubUserId {
46    /// Handles fmt for this module.
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "{}", self.0)
49    }
50}
51
52impl Serialize for GithubUserId {
53    /// Serialize as the existing JSON integer contract.
54    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
55    where
56        S: Serializer,
57    {
58        serializer.serialize_i64(self.0)
59    }
60}
61
62impl<'de> Deserialize<'de> for GithubUserId {
63    /// Deserialize from the existing JSON integer contract.
64    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
65    where
66        D: Deserializer<'de>,
67    {
68        let value = i64::deserialize(deserializer)?;
69        Self::try_new(value).map_err(serde::de::Error::custom)
70    }
71}
72
73impl JsonSchema for GithubUserId {
74    /// Keep the generated schema inline so DTO wire shape stays a number.
75    fn inline_schema() -> bool {
76        true
77    }
78
79    /// Handles schema name for this module.
80    fn schema_name() -> Cow<'static, str> {
81        "GithubUserId".into()
82    }
83
84    /// Handles json schema for this module.
85    fn json_schema(_: &mut SchemaGenerator) -> Schema {
86        json_schema!({
87            "type": "integer",
88            "minimum": 1
89        })
90    }
91}
92
93/// Role granted to a human account.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
95#[serde(rename_all = "snake_case")]
96pub enum HumanRole {
97    Creator,
98    Admin,
99}
100
101impl HumanRole {
102    /// Stable database and wire string for a human role.
103    pub fn as_str(self) -> &'static str {
104        match self {
105            Self::Creator => "creator",
106            Self::Admin => "admin",
107        }
108    }
109
110    /// Parse a stable database string for a human role.
111    pub fn from_storage_value(value: &str) -> Option<Self> {
112        match value {
113            "creator" => Some(Self::Creator),
114            "admin" => Some(Self::Admin),
115            _ => None,
116        }
117    }
118}
119
120/// Persistent lifecycle state for a human account.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
122#[serde(rename_all = "snake_case")]
123pub enum HumanStatus {
124    Active,
125    SetupRequired,
126    Disabled,
127    Deleted,
128}
129
130impl HumanStatus {
131    /// Stable database and wire string for a human status.
132    pub fn as_str(self) -> &'static str {
133        match self {
134            Self::Active => "active",
135            Self::SetupRequired => "setup_required",
136            Self::Disabled => "disabled",
137            Self::Deleted => "deleted",
138        }
139    }
140
141    /// Parse a stable database string for a human status.
142    pub fn from_storage_value(value: &str) -> Option<Self> {
143        match value {
144            "active" => Some(Self::Active),
145            "setup_required" => Some(Self::SetupRequired),
146            "disabled" => Some(Self::Disabled),
147            "deleted" => Some(Self::Deleted),
148            _ => None,
149        }
150    }
151}
152
153/// Browser-submitted request to start GitHub sign-in.
154#[derive(Debug, Clone, Deserialize, garde::Validate, schemars::JsonSchema)]
155#[garde(allow_unvalidated)]
156#[serde(deny_unknown_fields)]
157pub struct GithubSignInLoginRequest {
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub return_to: Option<String>,
160}
161
162/// Browser-submitted request to finish setup for a signed-in human.
163#[derive(Debug, Clone, Deserialize, garde::Validate, schemars::JsonSchema)]
164#[garde(allow_unvalidated)]
165#[serde(deny_unknown_fields)]
166pub struct CompleteHumanSetupRequest {
167    pub pioneer_code: PioneerCodeInput,
168}
169
170/// URL returned to a browser or CLI so it can start GitHub sign-in.
171#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
172pub struct GithubSignInLoginResponse {
173    pub authorization_url: GithubSignInAuthorizationUrl,
174}
175
176/// Browser-submitted request that completes GitHub sign-in.
177#[derive(Debug, Clone, Deserialize, garde::Validate, schemars::JsonSchema)]
178#[garde(allow_unvalidated)]
179#[serde(deny_unknown_fields)]
180pub struct GithubSignInCallbackRequest {
181    #[garde(custom(crate::validation::trimmed_non_empty))]
182    pub code: String,
183    #[garde(custom(crate::validation::trimmed_non_empty))]
184    pub state: String,
185}
186
187/// Current human browser session identity.
188#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
189pub struct HumanSessionResponse {
190    pub human_id: HumanId,
191    pub status: HumanStatus,
192    pub github_user_id: GithubUserId,
193    pub github_login: String,
194    pub roles: Vec<HumanRole>,
195    pub csrf_token: String,
196    pub expires_at: String,
197}
198
199/// Response returned after finishing human account setup.
200#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
201pub struct CompleteHumanSetupResponse {
202    pub session: HumanSessionResponse,
203}
204
205/// Response returned after completing GitHub sign-in.
206#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
207pub struct GithubSignInCallbackResponse {
208    pub session: HumanSessionResponse,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub return_to: Option<String>,
211}
212
213/// Admin-visible human identity row.
214#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
215pub struct AdminHumanDto {
216    pub human_id: HumanId,
217    pub status: HumanStatus,
218    pub github_user_id: GithubUserId,
219    pub github_login: String,
220    pub roles: Vec<HumanRole>,
221    pub created_at: String,
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub disabled_at: Option<String>,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub deleted_at: Option<String>,
226}
227
228/// Admin list response for human identities.
229#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
230pub struct AdminHumanListResponse {
231    pub items: Vec<AdminHumanDto>,
232}
233
234/// Response returned after granting or revoking a human admin role.
235#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
236pub struct AdminHumanRoleResponse {
237    pub human: AdminHumanDto,
238}
239
240/// Browser-submitted request to create an admin service token.
241#[derive(Debug, Clone, Deserialize, garde::Validate, schemars::JsonSchema)]
242#[garde(allow_unvalidated)]
243#[serde(deny_unknown_fields)]
244pub struct CreateAdminServiceTokenRequest {
245    #[garde(custom(crate::validation::trimmed_non_empty))]
246    pub label: String,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub expires_at: Option<String>,
249}
250
251/// Admin service-token metadata.
252#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
253pub struct AdminServiceTokenDto {
254    pub id: AdminServiceTokenId,
255    pub label: String,
256    pub status: String,
257    pub created_by_human_id: HumanId,
258    pub created_at: String,
259    #[serde(skip_serializing_if = "Option::is_none")]
260    pub last_used_at: Option<String>,
261    #[serde(skip_serializing_if = "Option::is_none")]
262    pub expires_at: Option<String>,
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub revoked_by_human_id: Option<HumanId>,
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub revoked_at: Option<String>,
267}
268
269/// Response returned after creating an admin service token.
270#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
271pub struct AdminServiceTokenCreatedResponse {
272    pub token: String,
273    pub token_record: AdminServiceTokenDto,
274}
275
276impl fmt::Debug for AdminServiceTokenCreatedResponse {
277    /// Redacts the one-time raw admin service token from debug output.
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        f.debug_struct("AdminServiceTokenCreatedResponse")
280            .field("token", &"<redacted>")
281            .field("token_record", &self.token_record)
282            .finish()
283    }
284}
285
286/// Admin list response for admin service tokens.
287#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
288pub struct AdminServiceTokenListResponse {
289    pub items: Vec<AdminServiceTokenDto>,
290}
291
292/// Response returned after revoking an admin service token.
293#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
294pub struct RevokeAdminServiceTokenResponse {
295    pub token_record: AdminServiceTokenDto,
296}
297
298/// Browser-submitted request to create a creator API token.
299#[derive(Debug, Clone, Deserialize, garde::Validate, schemars::JsonSchema)]
300#[garde(allow_unvalidated)]
301#[serde(deny_unknown_fields)]
302pub struct CreateCreatorApiTokenRequest {
303    #[garde(custom(crate::validation::trimmed_non_empty))]
304    pub label: String,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub expires_at: Option<String>,
307}
308
309/// Creator API-token metadata visible to the owning creator.
310#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
311pub struct CreatorApiTokenDto {
312    pub id: CreatorApiTokenId,
313    pub label: String,
314    pub status: String,
315    pub created_by_human_id: HumanId,
316    pub created_at: String,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub last_used_at: Option<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub expires_at: Option<String>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub revoked_at: Option<String>,
323}
324
325/// Response returned after creating a creator API token.
326#[derive(Clone, Serialize, Deserialize, schemars::JsonSchema)]
327pub struct CreatorApiTokenCreatedResponse {
328    pub token: String,
329    pub token_record: CreatorApiTokenDto,
330}
331
332impl fmt::Debug for CreatorApiTokenCreatedResponse {
333    /// Redacts the one-time raw creator API token from debug output.
334    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
335        f.debug_struct("CreatorApiTokenCreatedResponse")
336            .field("token", &"<redacted>")
337            .field("token_record", &self.token_record)
338            .finish()
339    }
340}
341
342/// Creator list response for API tokens.
343#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
344pub struct CreatorApiTokenListResponse {
345    pub items: Vec<CreatorApiTokenDto>,
346}
347
348/// Response returned after revoking a creator API token.
349#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
350pub struct RevokeCreatorApiTokenResponse {
351    pub token_record: CreatorApiTokenDto,
352}
353
354#[cfg(test)]
355mod tests {
356    use super::{
357        AdminServiceTokenCreatedResponse, AdminServiceTokenDto, CreatorApiTokenCreatedResponse,
358        CreatorApiTokenDto, GithubUserId,
359    };
360    use crate::models::ids::{AdminServiceTokenId, CreatorApiTokenId, HumanId};
361
362    /// Verifies GitHub user ids keep the integer wire contract while rejecting invalid ids.
363    #[test]
364    fn github_user_ids_are_positive_wire_integers() {
365        let id = GithubUserId::try_new(42).expect("positive id should parse");
366        assert_eq!(id.as_i64(), 42);
367        assert_eq!(
368            serde_json::to_value(id).expect("id should serialize"),
369            serde_json::json!(42)
370        );
371        assert!(GithubUserId::try_new(0).is_err());
372        assert!(serde_json::from_value::<GithubUserId>(serde_json::json!(-1)).is_err());
373    }
374
375    /// Verifies one-time bearer tokens cannot leak through accidental debug output.
376    #[test]
377    fn token_creation_debug_output_redacts_raw_tokens() {
378        let human_id = HumanId::generate();
379        let admin = AdminServiceTokenCreatedResponse {
380            token: "agentics_admin_secret".to_string(),
381            token_record: AdminServiceTokenDto {
382                id: AdminServiceTokenId::generate(),
383                label: "admin".to_string(),
384                status: "active".to_string(),
385                created_by_human_id: human_id.clone(),
386                created_at: "2026-06-01T00:00:00Z".to_string(),
387                last_used_at: None,
388                expires_at: None,
389                revoked_by_human_id: None,
390                revoked_at: None,
391            },
392        };
393        let creator = CreatorApiTokenCreatedResponse {
394            token: "agentics_creator_secret".to_string(),
395            token_record: CreatorApiTokenDto {
396                id: CreatorApiTokenId::generate(),
397                label: "creator".to_string(),
398                status: "active".to_string(),
399                created_by_human_id: human_id,
400                created_at: "2026-06-01T00:00:00Z".to_string(),
401                last_used_at: None,
402                expires_at: None,
403                revoked_at: None,
404            },
405        };
406
407        let admin_debug = format!("{admin:?}");
408        let creator_debug = format!("{creator:?}");
409
410        assert!(!admin_debug.contains("agentics_admin_secret"));
411        assert!(!creator_debug.contains("agentics_creator_secret"));
412        assert!(admin_debug.contains("<redacted>"));
413        assert!(creator_debug.contains("<redacted>"));
414    }
415}