Skip to main content

roder_api/
identity.rs

1//! Hosted identity and tenancy contracts (roadmap phase 72, Task 1).
2//!
3//! These types are additive for local mode: a locally-run Roder never
4//! constructs them. Hosted gateways resolve a `HostedRequestContext` from
5//! validated credentials *before* JSON-RPC dispatch; no core thread/turn
6//! DTO ever accepts a caller-supplied tenant id.
7
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10
11pub type TenantId = String;
12pub type UserId = String;
13pub type ServiceAccountId = String;
14
15/// Who is acting: a human user or a service account. Credential material
16/// (tokens, key hashes) never appears here.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "snake_case", tag = "kind")]
19pub enum PrincipalContext {
20    User {
21        user_id: UserId,
22        #[serde(default, skip_serializing_if = "Option::is_none")]
23        display_name: Option<String>,
24    },
25    ServiceAccount {
26        service_account_id: ServiceAccountId,
27        #[serde(default, skip_serializing_if = "Option::is_none")]
28        display_name: Option<String>,
29    },
30}
31
32impl PrincipalContext {
33    pub fn id(&self) -> &str {
34        match self {
35            PrincipalContext::User { user_id, .. } => user_id,
36            PrincipalContext::ServiceAccount {
37                service_account_id, ..
38            } => service_account_id,
39        }
40    }
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "camelCase")]
45pub struct TenantContext {
46    pub tenant_id: TenantId,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub display_name: Option<String>,
49}
50
51/// Coarse hosted access scopes attached to a credential.
52#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
53#[serde(rename_all = "snake_case")]
54pub enum HostedScope {
55    Read,
56    Write,
57    Admin,
58}
59
60/// Tenant-level role of a principal.
61#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum HostedRole {
64    Member,
65    TenantAdmin,
66    /// Operates across tenants (service operators only).
67    SystemAdmin,
68}
69
70/// Fully resolved request identity, attached by the hosted gateway before
71/// dispatch. Local mode never builds one.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73#[serde(rename_all = "camelCase")]
74pub struct HostedRequestContext {
75    pub tenant: TenantContext,
76    pub principal: PrincipalContext,
77    pub role: HostedRole,
78    pub scopes: Vec<HostedScope>,
79    /// Stable id of the validated credential (key id / token id), for audit
80    /// correlation only — never the secret.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub credential_id: Option<String>,
83    #[serde(with = "time::serde::rfc3339")]
84    pub authenticated_at: OffsetDateTime,
85}
86
87impl HostedRequestContext {
88    pub fn has_scope(&self, scope: HostedScope) -> bool {
89        self.scopes.contains(&scope)
90            || (scope != HostedScope::Admin && self.scopes.contains(&HostedScope::Admin))
91    }
92}
93
94/// Outcome of an authorization check, carrying a redaction-safe reason.
95#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "snake_case", tag = "decision")]
97pub enum AuthorizationDecision {
98    Allow,
99    Deny {
100        /// Coarse machine-readable reason: `missing_scope`,
101        /// `wrong_tenant`, `revoked`, `expired`, `not_member`,
102        /// `system_admin_required`.
103        reason: String,
104    },
105}
106
107impl AuthorizationDecision {
108    pub fn deny(reason: impl Into<String>) -> Self {
109        Self::Deny {
110            reason: reason.into(),
111        }
112    }
113
114    pub fn is_allowed(&self) -> bool {
115        matches!(self, AuthorizationDecision::Allow)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn context(scopes: Vec<HostedScope>) -> HostedRequestContext {
124        HostedRequestContext {
125            tenant: TenantContext {
126                tenant_id: "tenant-a".to_string(),
127                display_name: Some("Tenant A".to_string()),
128            },
129            principal: PrincipalContext::ServiceAccount {
130                service_account_id: "svc-1".to_string(),
131                display_name: None,
132            },
133            role: HostedRole::Member,
134            scopes,
135            credential_id: Some("key-1".to_string()),
136            authenticated_at: OffsetDateTime::UNIX_EPOCH,
137        }
138    }
139
140    #[test]
141    fn identity_types_round_trip_without_secret_fields() {
142        let context = context(vec![HostedScope::Read, HostedScope::Write]);
143        let json = serde_json::to_value(&context).unwrap();
144        assert_eq!(json["tenant"]["tenantId"], "tenant-a");
145        assert_eq!(json["principal"]["kind"], "service_account");
146        assert_eq!(json["scopes"], serde_json::json!(["read", "write"]));
147        // The wire shape has no token/secret/key-material fields.
148        let text = json.to_string();
149        for forbidden in ["token", "secret", "apiKey", "password"] {
150            assert!(!text.contains(forbidden), "{text}");
151        }
152        let round_trip: HostedRequestContext = serde_json::from_value(json).unwrap();
153        assert_eq!(round_trip, context);
154    }
155
156    #[test]
157    fn admin_scope_implies_read_and_write() {
158        let admin = context(vec![HostedScope::Admin]);
159        assert!(admin.has_scope(HostedScope::Read));
160        assert!(admin.has_scope(HostedScope::Write));
161        assert!(admin.has_scope(HostedScope::Admin));
162
163        let read_only = context(vec![HostedScope::Read]);
164        assert!(read_only.has_scope(HostedScope::Read));
165        assert!(!read_only.has_scope(HostedScope::Write));
166        assert!(!read_only.has_scope(HostedScope::Admin));
167    }
168
169    #[test]
170    fn authorization_decisions_carry_coarse_reasons() {
171        let deny = AuthorizationDecision::deny("wrong_tenant");
172        assert!(!deny.is_allowed());
173        let json = serde_json::to_value(&deny).unwrap();
174        assert_eq!(json["decision"], "deny");
175        assert_eq!(json["reason"], "wrong_tenant");
176        assert!(AuthorizationDecision::Allow.is_allowed());
177    }
178}