Skip to main content

vr_core/
tenant.rs

1//! TenantContext — the foundation of compile-time tenant isolation.
2//!
3//! Every API request extracts a TenantContext from the JWT. All repository
4//! methods require it. The type system makes cross-tenant data access
5//! a compile error, not a runtime bug.
6
7use crate::ids::{TenantId, UserId};
8use nexcore_chrono::DateTime;
9use serde::{Deserialize, Serialize};
10
11// ============================================================================
12// Subscription Tiers
13// ============================================================================
14
15/// Platform subscription tiers with monthly pricing (cents).
16#[non_exhaustive]
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum SubscriptionTier {
20    /// $250/mo — academic labs, .edu email required
21    Academic,
22    /// $500/mo — single program, 3 users, basic tools
23    Explorer,
24    /// $2,500/mo — 5 programs, 10 users, full computational suite
25    Accelerator,
26    /// $10,000/mo — unlimited programs, 50 users, dedicated support
27    Enterprise,
28    /// Negotiated — pharma innovation units, large biotechs
29    Custom,
30}
31
32impl SubscriptionTier {
33    /// Monthly base price in cents.
34    #[must_use]
35    pub fn monthly_price_cents(&self) -> u64 {
36        match self {
37            Self::Academic => 25_000,
38            Self::Explorer => 50_000,
39            Self::Accelerator => 250_000,
40            Self::Enterprise => 1_000_000,
41            Self::Custom => 0, // negotiated
42        }
43    }
44
45    /// Annual price with 16.7% discount (10 months for 12).
46    #[must_use]
47    #[allow(
48        clippy::arithmetic_side_effects,
49        reason = "monthly * 10 overflows only above u64::MAX / 10 ≈ 1.8e18 cents, which no subscription tier approaches"
50    )]
51    pub fn annual_price_cents(&self) -> u64 {
52        let monthly = self.monthly_price_cents();
53        // 10 months for the price of 12 = 16.67% discount
54        monthly * 10
55    }
56
57    /// Maximum number of programs allowed.
58    #[must_use]
59    pub fn max_programs(&self) -> Option<u32> {
60        match self {
61            Self::Academic => Some(3),
62            Self::Explorer => Some(1),
63            Self::Accelerator => Some(5),
64            Self::Enterprise => None, // unlimited
65            Self::Custom => None,
66        }
67    }
68
69    /// Maximum number of users per tenant.
70    #[must_use]
71    pub fn max_users(&self) -> Option<u32> {
72        match self {
73            Self::Academic => Some(10),
74            Self::Explorer => Some(3),
75            Self::Accelerator => Some(10),
76            Self::Enterprise => Some(50),
77            Self::Custom => None,
78        }
79    }
80
81    /// Storage allocation in bytes.
82    #[must_use]
83    pub fn storage_bytes(&self) -> u64 {
84        match self {
85            Self::Academic => 25 * 1_073_741_824,    // 25 GB
86            Self::Explorer => 5 * 1_073_741_824,     // 5 GB
87            Self::Accelerator => 50 * 1_073_741_824, // 50 GB
88            Self::Enterprise => 500 * 1_073_741_824, // 500 GB
89            Self::Custom => 1_000 * 1_073_741_824,   // 1 TB default
90        }
91    }
92
93    /// Maximum virtual screens per month.
94    #[must_use]
95    pub fn max_virtual_screens_per_month(&self) -> Option<u32> {
96        match self {
97            Self::Academic => Some(5),
98            Self::Explorer => None, // not available
99            Self::Accelerator => Some(10),
100            Self::Enterprise => None, // unlimited (None = unlimited here)
101            Self::Custom => None,
102        }
103    }
104
105    /// Whether this tier has API access.
106    #[must_use]
107    pub fn has_api_access(&self) -> bool {
108        !matches!(self, Self::Explorer)
109    }
110
111    /// Whether this tier supports SSO.
112    #[must_use]
113    pub fn has_sso(&self) -> bool {
114        matches!(self, Self::Enterprise | Self::Custom)
115    }
116
117    /// SLA uptime percentage (basis points, e.g., 9950 = 99.50%).
118    #[must_use]
119    pub fn sla_uptime_bps(&self) -> u32 {
120        match self {
121            Self::Academic => 9950,
122            Self::Explorer => 9950,
123            Self::Accelerator => 9990,
124            Self::Enterprise => 9995,
125            Self::Custom => 9999,
126        }
127    }
128
129    /// Tier ordering for comparison (higher = more features).
130    #[must_use]
131    pub fn rank(&self) -> u8 {
132        match self {
133            Self::Academic => 1,
134            Self::Explorer => 2,
135            Self::Accelerator => 3,
136            Self::Enterprise => 4,
137            Self::Custom => 5,
138        }
139    }
140
141    /// Check if this tier includes a feature available at `required_tier`.
142    #[must_use]
143    pub fn includes(&self, required_tier: &SubscriptionTier) -> bool {
144        self.rank() >= required_tier.rank()
145    }
146}
147
148// ============================================================================
149// User Roles
150// ============================================================================
151
152/// Role within a tenant organization. Determines permissions.
153#[non_exhaustive]
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
155#[serde(rename_all = "snake_case")]
156pub enum UserRole {
157    /// Full access + billing + team management
158    Owner,
159    /// Full access except billing
160    Admin,
161    /// Programs, compounds, assays (read/write)
162    Scientist,
163    /// Deals, asset packages (read/write); science (read)
164    BusinessDev,
165    /// Read-only access to specified programs
166    Viewer,
167    /// Scoped access (SAB members, consultants)
168    External,
169}
170
171impl UserRole {
172    /// Role ordering for comparison (higher = more privileges).
173    #[must_use]
174    pub fn rank(&self) -> u8 {
175        match self {
176            Self::Owner => 6,
177            Self::Admin => 5,
178            Self::Scientist => 4,
179            Self::BusinessDev => 3,
180            Self::Viewer => 2,
181            Self::External => 1,
182        }
183    }
184
185    /// Whether this role has at least the privileges of `required`.
186    #[must_use]
187    pub fn has_at_least(&self, required: &UserRole) -> bool {
188        self.rank() >= required.rank()
189    }
190
191    /// Whether this role can manage team members.
192    #[must_use]
193    pub fn can_manage_team(&self) -> bool {
194        matches!(self, Self::Owner | Self::Admin)
195    }
196
197    /// Whether this role can access billing.
198    #[must_use]
199    pub fn can_access_billing(&self) -> bool {
200        matches!(self, Self::Owner)
201    }
202
203    /// Whether this role can write to programs.
204    #[must_use]
205    pub fn can_write_programs(&self) -> bool {
206        matches!(self, Self::Owner | Self::Admin | Self::Scientist)
207    }
208
209    /// Whether this role can manage deals.
210    #[must_use]
211    pub fn can_manage_deals(&self) -> bool {
212        matches!(self, Self::Owner | Self::Admin | Self::BusinessDev)
213    }
214}
215
216// ============================================================================
217// Actions & Resources (for fine-grained RBAC)
218// ============================================================================
219
220/// Actions that can be performed on resources.
221#[non_exhaustive]
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Action {
225    Create,
226    Read,
227    Update,
228    Delete,
229    Export,
230    Admin,
231    Execute,
232}
233
234/// Resources that can be acted upon.
235#[non_exhaustive]
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237#[serde(rename_all = "snake_case")]
238pub enum Resource {
239    Program,
240    Compound,
241    Assay,
242    Deal,
243    Asset,
244    Order,
245    Team,
246    Billing,
247    Settings,
248    ApiKey,
249    AuditLog,
250    Terminal,
251    TerminalAi,
252}
253
254/// Permission set — evaluated at runtime from role + tier.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct Permissions {
257    /// Explicit grants: (Action, Resource) pairs.
258    grants: Vec<(Action, Resource)>,
259}
260
261impl Permissions {
262    /// Build permissions from role.
263    #[must_use]
264    pub fn from_role(role: &UserRole) -> Self {
265        use Action::*;
266        use Resource::*;
267
268        let grants = match role {
269            UserRole::Owner => vec![
270                (Create, Program),
271                (Read, Program),
272                (Update, Program),
273                (Delete, Program),
274                (Create, Compound),
275                (Read, Compound),
276                (Update, Compound),
277                (Delete, Compound),
278                (Create, Assay),
279                (Read, Assay),
280                (Update, Assay),
281                (Create, Deal),
282                (Read, Deal),
283                (Update, Deal),
284                (Delete, Deal),
285                (Create, Asset),
286                (Read, Asset),
287                (Update, Asset),
288                (Create, Order),
289                (Read, Order),
290                (Create, Team),
291                (Read, Team),
292                (Update, Team),
293                (Delete, Team),
294                (Read, Billing),
295                (Update, Billing),
296                (Read, Settings),
297                (Update, Settings),
298                (Create, ApiKey),
299                (Read, ApiKey),
300                (Delete, ApiKey),
301                (Read, AuditLog),
302                (Admin, Settings),
303                (Execute, Terminal),
304                (Execute, TerminalAi),
305                (Admin, Terminal),
306            ],
307            UserRole::Admin => vec![
308                (Create, Program),
309                (Read, Program),
310                (Update, Program),
311                (Delete, Program),
312                (Create, Compound),
313                (Read, Compound),
314                (Update, Compound),
315                (Delete, Compound),
316                (Create, Assay),
317                (Read, Assay),
318                (Update, Assay),
319                (Create, Deal),
320                (Read, Deal),
321                (Update, Deal),
322                (Create, Asset),
323                (Read, Asset),
324                (Update, Asset),
325                (Create, Order),
326                (Read, Order),
327                (Create, Team),
328                (Read, Team),
329                (Update, Team),
330                (Read, Settings),
331                (Update, Settings),
332                (Create, ApiKey),
333                (Read, ApiKey),
334                (Read, AuditLog),
335                (Execute, Terminal),
336                (Execute, TerminalAi),
337                (Admin, Terminal),
338            ],
339            UserRole::Scientist => vec![
340                (Create, Program),
341                (Read, Program),
342                (Update, Program),
343                (Create, Compound),
344                (Read, Compound),
345                (Update, Compound),
346                (Create, Assay),
347                (Read, Assay),
348                (Update, Assay),
349                (Read, Deal),
350                (Create, Order),
351                (Read, Order),
352                (Read, Settings),
353                (Execute, Terminal),
354                (Execute, TerminalAi),
355            ],
356            UserRole::BusinessDev => vec![
357                (Read, Program),
358                (Read, Compound),
359                (Read, Assay),
360                (Create, Deal),
361                (Read, Deal),
362                (Update, Deal),
363                (Create, Asset),
364                (Read, Asset),
365                (Update, Asset),
366                (Read, Order),
367                (Read, Settings),
368                (Execute, Terminal),
369                (Execute, TerminalAi),
370            ],
371            UserRole::Viewer => vec![
372                (Read, Program),
373                (Read, Compound),
374                (Read, Assay),
375                (Read, Deal),
376                (Read, Order),
377            ],
378            UserRole::External => vec![(Read, Program), (Read, Compound)],
379        };
380
381        Self { grants }
382    }
383
384    /// Check if a specific (action, resource) is permitted.
385    #[must_use]
386    pub fn allows(&self, action: Action, resource: Resource) -> bool {
387        self.grants
388            .iter()
389            .any(|&(a, r)| a == action && r == resource)
390    }
391}
392
393// ============================================================================
394// TenantContext
395// ============================================================================
396
397/// Extracted from every authenticated request. Cannot be constructed
398/// outside the auth module (fields are private). All repository methods
399/// require this — the type system prevents cross-tenant data access.
400#[derive(Debug, Clone, Serialize, Deserialize)]
401pub struct TenantContext {
402    tenant_id: TenantId,
403    user_id: UserId,
404    role: UserRole,
405    tier: SubscriptionTier,
406    permissions: Permissions,
407}
408
409impl TenantContext {
410    /// Construct from a verified auth token. In production, this is called
411    /// only by the auth middleware after JWT verification.
412    #[must_use]
413    pub fn new(
414        tenant_id: TenantId,
415        user_id: UserId,
416        role: UserRole,
417        tier: SubscriptionTier,
418    ) -> Self {
419        let permissions = Permissions::from_role(&role);
420        Self {
421            tenant_id,
422            user_id,
423            role,
424            tier,
425            permissions,
426        }
427    }
428
429    #[must_use]
430    pub fn tenant_id(&self) -> &TenantId {
431        &self.tenant_id
432    }
433
434    #[must_use]
435    pub fn user_id(&self) -> &UserId {
436        &self.user_id
437    }
438
439    #[must_use]
440    pub fn role(&self) -> &UserRole {
441        &self.role
442    }
443
444    #[must_use]
445    pub fn tier(&self) -> &SubscriptionTier {
446        &self.tier
447    }
448
449    /// Check if the user can perform an action on a resource.
450    #[must_use]
451    pub fn can(&self, action: Action, resource: Resource) -> bool {
452        self.permissions.allows(action, resource)
453    }
454
455    /// Check if the tenant's tier includes a feature at `required_tier`.
456    #[must_use]
457    pub fn tier_includes(&self, required_tier: &SubscriptionTier) -> bool {
458        self.tier.includes(required_tier)
459    }
460}
461
462// ============================================================================
463// TenantScoped<T> — compile-time isolation wrapper
464// ============================================================================
465
466/// Wraps any value with its owning tenant_id. Prevents data from one
467/// tenant leaking into another's context. The tenant_id is immutable
468/// after construction.
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct TenantScoped<T> {
471    tenant_id: TenantId,
472    inner: T,
473}
474
475impl<T> TenantScoped<T> {
476    /// Wrap a value with tenant scope. Only succeeds if the context
477    /// matches — compile-time safety.
478    #[must_use]
479    pub fn new(ctx: &TenantContext, inner: T) -> Self {
480        Self {
481            tenant_id: *ctx.tenant_id(),
482            inner,
483        }
484    }
485
486    #[must_use]
487    pub fn tenant_id(&self) -> &TenantId {
488        &self.tenant_id
489    }
490
491    /// Access the inner value (read-only).
492    #[must_use]
493    pub fn inner(&self) -> &T {
494        &self.inner
495    }
496
497    /// Consume and return the inner value.
498    #[must_use]
499    pub fn into_inner(self) -> T {
500        self.inner
501    }
502
503    /// Verify that this scoped value belongs to the given context.
504    /// Returns None if tenant_id doesn't match.
505    #[must_use]
506    pub fn verify(self, ctx: &TenantContext) -> Option<T> {
507        if self.tenant_id == *ctx.tenant_id() {
508            Some(self.inner)
509        } else {
510            None
511        }
512    }
513
514    /// Map the inner value while preserving tenant scope.
515    #[must_use]
516    pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> TenantScoped<U> {
517        TenantScoped {
518            tenant_id: self.tenant_id,
519            inner: f(self.inner),
520        }
521    }
522}
523
524// ============================================================================
525// Tenant Status
526// ============================================================================
527
528/// Lifecycle status of a tenant.
529#[non_exhaustive]
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
531#[serde(rename_all = "snake_case")]
532pub enum TenantStatus {
533    /// In trial period (14 days, Accelerator features).
534    Trial,
535    /// Active paying customer.
536    Active,
537    /// Payment failed, in grace period.
538    PastDue,
539    /// Administratively suspended.
540    Suspended,
541    /// Tenant requested cancellation, data being archived.
542    Offboarding,
543    /// Fully deprovisioned, data archived or deleted.
544    Deprovisioned,
545}
546
547impl TenantStatus {
548    /// Whether the tenant can use the platform.
549    #[must_use]
550    pub fn is_accessible(&self) -> bool {
551        matches!(self, Self::Trial | Self::Active | Self::PastDue)
552    }
553
554    /// Whether the tenant can be billed.
555    #[must_use]
556    pub fn is_billable(&self) -> bool {
557        matches!(self, Self::Active | Self::PastDue)
558    }
559}
560
561// ============================================================================
562// Tenant Record
563// ============================================================================
564
565/// Full tenant record as stored in the database.
566#[non_exhaustive]
567#[derive(Debug, Clone, Serialize, Deserialize)]
568pub struct Tenant {
569    pub id: TenantId,
570    pub name: String,
571    pub slug: String,
572    pub tier: SubscriptionTier,
573    pub status: TenantStatus,
574    pub trial_ends_at: Option<DateTime>,
575    pub settings: serde_json::Value,
576    pub created_at: DateTime,
577    pub updated_at: DateTime,
578}
579
580impl Tenant {
581    /// Construct a new Tenant record.
582    #[must_use]
583    pub fn new(
584        id: TenantId,
585        name: String,
586        slug: String,
587        tier: SubscriptionTier,
588        status: TenantStatus,
589        trial_ends_at: Option<DateTime>,
590        settings: serde_json::Value,
591        created_at: DateTime,
592        updated_at: DateTime,
593    ) -> Self {
594        Self {
595            id,
596            name,
597            slug,
598            tier,
599            status,
600            trial_ends_at,
601            settings,
602            created_at,
603            updated_at,
604        }
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    #[test]
613    fn tier_pricing() {
614        assert_eq!(SubscriptionTier::Explorer.monthly_price_cents(), 50_000);
615        assert_eq!(SubscriptionTier::Accelerator.monthly_price_cents(), 250_000);
616        assert_eq!(
617            SubscriptionTier::Enterprise.monthly_price_cents(),
618            1_000_000
619        );
620        assert_eq!(SubscriptionTier::Academic.monthly_price_cents(), 25_000);
621    }
622
623    #[test]
624    fn annual_discount_is_16_7_percent() {
625        let monthly = SubscriptionTier::Accelerator.monthly_price_cents();
626        let annual = SubscriptionTier::Accelerator.annual_price_cents();
627        // 10 months for 12 = 10/12 = 83.3% of yearly, i.e., 16.7% discount
628        assert_eq!(annual, monthly * 10);
629        let full_year = monthly * 12;
630        let discount_pct = 100.0 * (1.0 - (annual as f64 / full_year as f64));
631        assert!((discount_pct - 16.67).abs() < 0.1);
632    }
633
634    #[test]
635    fn tier_limits() {
636        assert_eq!(SubscriptionTier::Explorer.max_programs(), Some(1));
637        assert_eq!(SubscriptionTier::Enterprise.max_programs(), None);
638        assert_eq!(SubscriptionTier::Explorer.max_users(), Some(3));
639    }
640
641    #[test]
642    fn tier_includes_checks_rank() {
643        assert!(SubscriptionTier::Enterprise.includes(&SubscriptionTier::Explorer));
644        assert!(!SubscriptionTier::Explorer.includes(&SubscriptionTier::Enterprise));
645        assert!(SubscriptionTier::Accelerator.includes(&SubscriptionTier::Accelerator));
646    }
647
648    #[test]
649    fn role_permissions() {
650        let ctx = TenantContext::new(
651            TenantId::new(),
652            UserId::new(),
653            UserRole::Scientist,
654            SubscriptionTier::Accelerator,
655        );
656        assert!(ctx.can(Action::Create, Resource::Compound));
657        assert!(ctx.can(Action::Read, Resource::Program));
658        assert!(!ctx.can(Action::Delete, Resource::Program)); // scientists can't delete
659        assert!(!ctx.can(Action::Read, Resource::Billing)); // scientists can't see billing
660    }
661
662    #[test]
663    fn owner_has_all_permissions() {
664        let ctx = TenantContext::new(
665            TenantId::new(),
666            UserId::new(),
667            UserRole::Owner,
668            SubscriptionTier::Enterprise,
669        );
670        assert!(ctx.can(Action::Admin, Resource::Settings));
671        assert!(ctx.can(Action::Read, Resource::Billing));
672        assert!(ctx.can(Action::Delete, Resource::Team));
673    }
674
675    #[test]
676    fn tenant_scoped_verify() {
677        let ctx1 = TenantContext::new(
678            TenantId::new(),
679            UserId::new(),
680            UserRole::Owner,
681            SubscriptionTier::Explorer,
682        );
683        let ctx2 = TenantContext::new(
684            TenantId::new(),
685            UserId::new(),
686            UserRole::Owner,
687            SubscriptionTier::Explorer,
688        );
689
690        let scoped = TenantScoped::new(&ctx1, "secret data");
691        // Same tenant can access
692        assert!(TenantScoped::new(&ctx1, "x").verify(&ctx1).is_some());
693        // Different tenant is rejected
694        assert!(scoped.verify(&ctx2).is_none());
695    }
696
697    #[test]
698    fn tenant_scoped_map() {
699        let ctx = TenantContext::new(
700            TenantId::new(),
701            UserId::new(),
702            UserRole::Scientist,
703            SubscriptionTier::Accelerator,
704        );
705        let scoped = TenantScoped::new(&ctx, 42);
706        let doubled = scoped.map(|x| x * 2);
707        assert_eq!(*doubled.inner(), 84);
708    }
709
710    #[test]
711    fn tenant_status_accessibility() {
712        assert!(TenantStatus::Trial.is_accessible());
713        assert!(TenantStatus::Active.is_accessible());
714        assert!(TenantStatus::PastDue.is_accessible());
715        assert!(!TenantStatus::Suspended.is_accessible());
716        assert!(!TenantStatus::Offboarding.is_accessible());
717    }
718}