Skip to main content

maincopy_shared/
auth.rs

1//! Wire-safe identity and authorization contracts for the private admin plane.
2
3use std::{collections::BTreeSet, fmt, str::FromStr};
4
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8macro_rules! uuid_identifier {
9    ($name:ident) => {
10        #[derive(
11            Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize,
12        )]
13        #[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
14        #[cfg_attr(feature = "schema", schema(value_type = Uuid, format = Uuid))]
15        #[serde(transparent)]
16        pub struct $name(Uuid);
17
18        impl $name {
19            pub const fn from_uuid(value: Uuid) -> Self {
20                Self(value)
21            }
22
23            pub const fn as_uuid(&self) -> &Uuid {
24                &self.0
25            }
26
27            pub const fn into_uuid(self) -> Uuid {
28                self.0
29            }
30        }
31
32        impl fmt::Display for $name {
33            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34                self.0.fmt(formatter)
35            }
36        }
37
38        impl FromStr for $name {
39            type Err = uuid::Error;
40
41            fn from_str(value: &str) -> Result<Self, Self::Err> {
42                Uuid::parse_str(value).map(Self)
43            }
44        }
45
46        impl From<Uuid> for $name {
47            fn from(value: Uuid) -> Self {
48                Self(value)
49            }
50        }
51
52        impl From<$name> for Uuid {
53            fn from(value: $name) -> Self {
54                value.0
55            }
56        }
57    };
58}
59
60uuid_identifier!(InstanceId);
61uuid_identifier!(UserId);
62uuid_identifier!(AdminSessionId);
63uuid_identifier!(LoginChallengeId);
64uuid_identifier!(AgentCredentialId);
65uuid_identifier!(AdminAuditEventId);
66
67/// Whether a human account can authenticate and exercise its assigned roles.
68#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
69#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
70#[serde(rename_all = "snake_case")]
71pub enum UserStatus {
72    Enabled,
73    Disabled,
74}
75
76impl UserStatus {
77    pub const fn as_str(self) -> &'static str {
78        match self {
79            Self::Enabled => "enabled",
80            Self::Disabled => "disabled",
81        }
82    }
83
84    pub fn parse(value: &str) -> Option<Self> {
85        match value {
86            "enabled" => Some(Self::Enabled),
87            "disabled" => Some(Self::Disabled),
88            _ => None,
89        }
90    }
91}
92
93/// A built-in bundle of fixed admin scopes.
94#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
95#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
96#[serde(rename_all = "snake_case")]
97pub enum UserRole {
98    Owner,
99    Administrator,
100    Publisher,
101}
102
103impl UserRole {
104    pub const fn as_str(self) -> &'static str {
105        match self {
106            Self::Owner => "owner",
107            Self::Administrator => "administrator",
108            Self::Publisher => "publisher",
109        }
110    }
111
112    pub fn parse(value: &str) -> Option<Self> {
113        match value {
114            "owner" => Some(Self::Owner),
115            "administrator" => Some(Self::Administrator),
116            "publisher" => Some(Self::Publisher),
117            _ => None,
118        }
119    }
120
121    /// Returns the immutable v1 scope bundle for this role.
122    pub const fn scopes(self) -> &'static [AdminScope] {
123        match self {
124            Self::Owner => &AdminScope::ALL,
125            Self::Administrator => &AdminScope::ADMINISTRATOR,
126            Self::Publisher => &AdminScope::PUBLISHER,
127        }
128    }
129}
130
131/// One independently enforced authority in the private admin plane.
132#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
133#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
134#[serde(rename_all = "snake_case")]
135pub enum AdminScope {
136    ContentRead,
137    StatusRead,
138    SourceSync,
139    PreviewRead,
140    ReleaseManage,
141    SourceManage,
142    ProfileManage,
143    LightningManage,
144    UserManage,
145    CredentialManage,
146    RoleAssign,
147    AuditRead,
148}
149
150impl AdminScope {
151    pub const ALL: [Self; 12] = [
152        Self::ContentRead,
153        Self::StatusRead,
154        Self::SourceSync,
155        Self::PreviewRead,
156        Self::ReleaseManage,
157        Self::SourceManage,
158        Self::ProfileManage,
159        Self::LightningManage,
160        Self::UserManage,
161        Self::CredentialManage,
162        Self::RoleAssign,
163        Self::AuditRead,
164    ];
165
166    pub const ADMINISTRATOR: [Self; 10] = [
167        Self::ContentRead,
168        Self::StatusRead,
169        Self::SourceSync,
170        Self::PreviewRead,
171        Self::ReleaseManage,
172        Self::ProfileManage,
173        Self::LightningManage,
174        Self::UserManage,
175        Self::CredentialManage,
176        Self::AuditRead,
177    ];
178
179    pub const PUBLISHER: [Self; 5] = [
180        Self::ContentRead,
181        Self::StatusRead,
182        Self::SourceSync,
183        Self::PreviewRead,
184        Self::ReleaseManage,
185    ];
186
187    pub const fn as_str(self) -> &'static str {
188        match self {
189            Self::ContentRead => "content_read",
190            Self::StatusRead => "status_read",
191            Self::SourceSync => "source_sync",
192            Self::PreviewRead => "preview_read",
193            Self::ReleaseManage => "release_manage",
194            Self::SourceManage => "source_manage",
195            Self::ProfileManage => "profile_manage",
196            Self::LightningManage => "lightning_manage",
197            Self::UserManage => "user_manage",
198            Self::CredentialManage => "credential_manage",
199            Self::RoleAssign => "role_assign",
200            Self::AuditRead => "audit_read",
201        }
202    }
203
204    pub fn parse(value: &str) -> Option<Self> {
205        match value {
206            "content_read" => Some(Self::ContentRead),
207            "status_read" => Some(Self::StatusRead),
208            "source_sync" => Some(Self::SourceSync),
209            "preview_read" => Some(Self::PreviewRead),
210            "release_manage" => Some(Self::ReleaseManage),
211            "source_manage" => Some(Self::SourceManage),
212            "profile_manage" => Some(Self::ProfileManage),
213            "lightning_manage" => Some(Self::LightningManage),
214            "user_manage" => Some(Self::UserManage),
215            "credential_manage" => Some(Self::CredentialManage),
216            "role_assign" => Some(Self::RoleAssign),
217            "audit_read" => Some(Self::AuditRead),
218            _ => None,
219        }
220    }
221}
222
223impl fmt::Display for AdminScope {
224    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
225        formatter.write_str(self.as_str())
226    }
227}
228
229/// Configurable provider used to authenticate a human user.
230#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
231#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
232#[serde(rename_all = "snake_case")]
233pub enum HumanLoginProvider {
234    Password,
235    Nostr,
236}
237
238impl HumanLoginProvider {
239    pub const fn as_str(self) -> &'static str {
240        match self {
241            Self::Password => "password",
242            Self::Nostr => "nostr",
243        }
244    }
245
246    pub fn parse(value: &str) -> Option<Self> {
247        match value {
248            "password" => Some(Self::Password),
249            "nostr" => Some(Self::Nostr),
250            _ => None,
251        }
252    }
253}
254
255/// Returns the union of the immutable scope bundles for a user's roles.
256pub fn effective_scopes(roles: impl IntoIterator<Item = UserRole>) -> BTreeSet<AdminScope> {
257    roles
258        .into_iter()
259        .flat_map(UserRole::scopes)
260        .copied()
261        .collect()
262}
263
264#[cfg(test)]
265mod tests {
266    use serde_json::json;
267
268    use super::*;
269
270    #[test]
271    fn identifiers_have_canonical_uuid_wire_values() {
272        let uuid = Uuid::parse_str("12345678-1234-4234-8234-123456789abc").unwrap();
273        let user_id = UserId::from_uuid(uuid);
274
275        assert_eq!(serde_json::to_value(user_id).unwrap(), json!(uuid));
276        assert_eq!(
277            serde_json::from_value::<UserId>(json!(uuid)).unwrap(),
278            user_id
279        );
280        assert_eq!(user_id.to_string(), uuid.to_string());
281    }
282
283    #[test]
284    fn fixed_role_scope_mapping_enforces_the_publisher_boundary() {
285        assert_eq!(UserRole::Owner.scopes(), AdminScope::ALL);
286        assert!(
287            UserRole::Administrator
288                .scopes()
289                .contains(&AdminScope::UserManage)
290        );
291        assert!(
292            !UserRole::Administrator
293                .scopes()
294                .contains(&AdminScope::RoleAssign)
295        );
296        assert!(
297            !UserRole::Administrator
298                .scopes()
299                .contains(&AdminScope::SourceManage)
300        );
301
302        for allowed in [
303            AdminScope::ContentRead,
304            AdminScope::StatusRead,
305            AdminScope::SourceSync,
306            AdminScope::PreviewRead,
307            AdminScope::ReleaseManage,
308        ] {
309            assert!(UserRole::Publisher.scopes().contains(&allowed), "{allowed}");
310        }
311        for denied in [
312            AdminScope::ProfileManage,
313            AdminScope::LightningManage,
314            AdminScope::UserManage,
315            AdminScope::CredentialManage,
316            AdminScope::RoleAssign,
317            AdminScope::AuditRead,
318            AdminScope::SourceManage,
319        ] {
320            assert!(!UserRole::Publisher.scopes().contains(&denied), "{denied}");
321        }
322    }
323
324    #[test]
325    fn scope_storage_names_are_stable_and_exhaustive() {
326        for scope in AdminScope::ALL {
327            assert_eq!(AdminScope::parse(scope.as_str()), Some(scope));
328            assert_eq!(scope.to_string(), scope.as_str());
329        }
330        assert_eq!(AdminScope::parse("publisher"), None);
331    }
332
333    #[test]
334    fn role_unions_do_not_invent_authority() {
335        let publisher = effective_scopes([UserRole::Publisher]);
336        assert_eq!(publisher.len(), AdminScope::PUBLISHER.len());
337
338        let combined = effective_scopes([UserRole::Publisher, UserRole::Administrator]);
339        assert_eq!(combined.len(), AdminScope::ADMINISTRATOR.len());
340        assert!(!combined.contains(&AdminScope::RoleAssign));
341        assert_eq!(
342            effective_scopes([UserRole::Owner]).len(),
343            AdminScope::ALL.len()
344        );
345    }
346
347    #[test]
348    fn enums_have_closed_snake_case_wire_values() {
349        assert_eq!(
350            serde_json::to_value(UserStatus::Enabled).unwrap(),
351            json!("enabled")
352        );
353        assert_eq!(
354            serde_json::to_value(UserRole::Administrator).unwrap(),
355            json!("administrator")
356        );
357        assert_eq!(
358            serde_json::to_value(HumanLoginProvider::Nostr).unwrap(),
359            json!("nostr")
360        );
361        assert!(serde_json::from_value::<UserRole>(json!("editor")).is_err());
362    }
363}