1use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10
11pub type TenantId = String;
12pub type UserId = String;
13pub type ServiceAccountId = String;
14
15#[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#[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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "snake_case")]
63pub enum HostedRole {
64 Member,
65 TenantAdmin,
66 SystemAdmin,
68}
69
70#[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 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "snake_case", tag = "decision")]
97pub enum AuthorizationDecision {
98 Allow,
99 Deny {
100 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 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}