Skip to main content

everruns_core/
organization.rs

1// Organization types for multitenancy
2// See specs/multitenancy.md
3//
4// Decision: Hierarchical org roles (Owner > Admin > Member) using PartialOrd.
5// External auth providers map their roles to OrgRole via AuthBackend trait.
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::fmt;
10use std::str::FromStr;
11use uuid::Uuid;
12
13/// Default organization ID (internal, for DB queries)
14pub const DEFAULT_ORG_ID: i64 = 1;
15
16/// Default organization public ID (external, for API)
17pub const DEFAULT_ORG_PUBLIC_ID: &str = "org_00000000000000000000000000000001";
18
19/// Well-known anonymous user UUID for auth=none mode.
20/// This is a real database user seeded at startup, so all code paths
21/// (org membership, API keys, etc.) work without special-casing.
22pub const ANONYMOUS_USER_ID: Uuid = Uuid::from_bytes([
23    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
24]);
25
26/// Anonymous user email
27pub const ANONYMOUS_USER_EMAIL: &str = "anonymous@local";
28
29/// Anonymous user display name
30pub const ANONYMOUS_USER_NAME: &str = "Anonymous";
31
32/// Organization entity (domain type)
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
35pub struct Organization {
36    /// External identifier (org_<32-hex-chars>)
37    pub public_id: String,
38    /// Display name
39    pub name: String,
40    pub created_at: DateTime<Utc>,
41    pub updated_at: DateTime<Utc>,
42}
43
44/// Organization-level role with hierarchical permissions.
45/// Owner > Admin > Member — checked via `has_permission()`.
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
47#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
48#[serde(rename_all = "lowercase")]
49pub enum OrgRole {
50    Member,
51    Admin,
52    #[default]
53    Owner,
54}
55
56impl OrgRole {
57    /// Check if this role has at least the `required` permission level.
58    pub fn has_permission(self, required: OrgRole) -> bool {
59        self.level() >= required.level()
60    }
61
62    /// String representation for DB storage.
63    pub fn as_str(self) -> &'static str {
64        match self {
65            OrgRole::Member => "member",
66            OrgRole::Admin => "admin",
67            OrgRole::Owner => "owner",
68        }
69    }
70
71    fn level(self) -> u8 {
72        match self {
73            OrgRole::Member => 0,
74            OrgRole::Admin => 1,
75            OrgRole::Owner => 2,
76        }
77    }
78}
79
80impl fmt::Display for OrgRole {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86impl FromStr for OrgRole {
87    type Err = String;
88
89    fn from_str(s: &str) -> Result<Self, Self::Err> {
90        match s {
91            "member" => Ok(OrgRole::Member),
92            "admin" => Ok(OrgRole::Admin),
93            "owner" => Ok(OrgRole::Owner),
94            _ => Err(format!("invalid org role: {s}")),
95        }
96    }
97}
98
99/// Organization membership info (for user context)
100#[derive(Debug, Clone, Serialize, Deserialize)]
101#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
102pub struct OrgMembership {
103    /// Internal org_id for DB queries (not serialized to API)
104    #[serde(skip_serializing)]
105    pub org_id: i64,
106    /// External identifier
107    pub public_id: String,
108    /// Display name
109    pub name: String,
110    /// User's role in this organization
111    #[serde(default)]
112    pub role: OrgRole,
113}
114
115/// Derive a deterministic public_id from an internal org_id.
116///
117/// For `DEFAULT_ORG_ID` this returns `DEFAULT_ORG_PUBLIC_ID`.
118/// For other IDs it produces `org_<032x>` so callers can avoid
119/// an async DB lookup when only the public_id format is needed.
120pub fn org_public_id_from_internal(org_id: i64) -> String {
121    if org_id == DEFAULT_ORG_ID {
122        return DEFAULT_ORG_PUBLIC_ID.to_string();
123    }
124    format!("org_{:032x}", org_id)
125}
126
127/// Generate a new organization public ID
128/// Format: org_<32-hex-chars> (UUIDv4 lowercase hex, no dashes)
129pub fn generate_org_public_id() -> String {
130    let uuid = Uuid::new_v4();
131    format!("org_{}", uuid.simple())
132}
133
134/// Validate organization public ID format
135/// Pattern: ^org_[0-9a-f]{32}$
136pub fn validate_org_public_id(public_id: &str) -> bool {
137    if !public_id.starts_with("org_") {
138        return false;
139    }
140    let suffix = &public_id[4..];
141    suffix.len() == 32
142        && suffix
143            .chars()
144            .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn test_generate_org_public_id() {
153        let id = generate_org_public_id();
154        assert!(id.starts_with("org_"));
155        assert_eq!(id.len(), 36); // "org_" + 32 hex chars
156        assert!(validate_org_public_id(&id));
157    }
158
159    #[test]
160    fn test_validate_org_public_id() {
161        // Valid
162        assert!(validate_org_public_id(
163            "org_00000000000000000000000000000001"
164        ));
165        assert!(validate_org_public_id(
166            "org_2f3c1b3e6a9d4c6f8a1d4e9c9b7f21a0"
167        ));
168
169        // Invalid - wrong prefix
170        assert!(!validate_org_public_id(
171            "organization_12345678901234567890123456789012"
172        ));
173
174        // Invalid - too short
175        assert!(!validate_org_public_id("org_123"));
176
177        // Invalid - too long
178        assert!(!validate_org_public_id(
179            "org_123456789012345678901234567890123"
180        ));
181
182        // Invalid - uppercase
183        assert!(!validate_org_public_id(
184            "org_2F3C1B3E6A9D4C6F8A1D4E9C9B7F21A0"
185        ));
186
187        // Invalid - non-hex characters
188        assert!(!validate_org_public_id(
189            "org_ghijklmnopqrstuvwxyz1234567890"
190        ));
191    }
192
193    #[test]
194    fn test_default_org_public_id_valid() {
195        assert!(validate_org_public_id(DEFAULT_ORG_PUBLIC_ID));
196    }
197
198    #[test]
199    fn test_org_role_hierarchy() {
200        assert!(OrgRole::Owner.has_permission(OrgRole::Owner));
201        assert!(OrgRole::Owner.has_permission(OrgRole::Admin));
202        assert!(OrgRole::Owner.has_permission(OrgRole::Member));
203
204        assert!(!OrgRole::Admin.has_permission(OrgRole::Owner));
205        assert!(OrgRole::Admin.has_permission(OrgRole::Admin));
206        assert!(OrgRole::Admin.has_permission(OrgRole::Member));
207
208        assert!(!OrgRole::Member.has_permission(OrgRole::Owner));
209        assert!(!OrgRole::Member.has_permission(OrgRole::Admin));
210        assert!(OrgRole::Member.has_permission(OrgRole::Member));
211    }
212
213    #[test]
214    fn test_org_role_str_roundtrip() {
215        for role in [OrgRole::Member, OrgRole::Admin, OrgRole::Owner] {
216            let s = role.as_str();
217            let parsed: OrgRole = s.parse().unwrap();
218            assert_eq!(parsed, role);
219        }
220    }
221
222    #[test]
223    fn test_org_role_default() {
224        assert_eq!(OrgRole::default(), OrgRole::Owner);
225    }
226}