1use 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#[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#[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 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#[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#[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
255pub 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}