scufflecloud_core/
cedar.rs

1use std::collections::HashSet;
2use std::str::FromStr;
3use std::sync::{Arc, OnceLock};
4
5use cedar_policy::{Decision, Entities, Entity, EntityId, EntityTypeName, EntityUid, PolicySet};
6use tonic_types::{ErrorDetails, StatusExt};
7
8use crate::CoreConfig;
9use crate::id::{Id, PrefixedId};
10use crate::models::UserSession;
11use crate::std_ext::ResultExt;
12
13fn static_policies() -> &'static PolicySet {
14    const STATIC_POLICIES_STR: &str = include_str!("../static_policies.cedar");
15    static STATIC_POLICIES: OnceLock<PolicySet> = OnceLock::new();
16
17    STATIC_POLICIES.get_or_init(|| PolicySet::from_str(STATIC_POLICIES_STR).expect("failed to parse static policies"))
18}
19
20fn uid_to_json(uid: EntityUid) -> serde_json::Value {
21    serde_json::json!({
22        "type": uid.type_name().to_string(),
23        "id": uid.id().unescaped(),
24    })
25}
26
27pub(crate) trait CedarEntity<G>: serde::Serialize {
28    /// MUST be a normalized cedar entity type name.
29    ///
30    /// See [`cedar_policy::EntityTypeName`] and <https://github.com/cedar-policy/rfcs/blob/main/text/0009-disallow-whitespace-in-entityuid.md>.
31    const ENTITY_TYPE: &'static str;
32
33    fn entity_id(&self) -> EntityId;
34
35    fn entity_uid(&self) -> EntityUid {
36        let name = EntityTypeName::from_str(Self::ENTITY_TYPE).expect("invalid entity type name");
37        EntityUid::from_type_name_and_id(name, self.entity_id())
38    }
39
40    async fn attributes(&self, global: &Arc<G>) -> Result<serde_json::value::Map<String, serde_json::Value>, tonic::Status> {
41        let _global = global;
42        if let serde_json::Value::Object(object) =
43            serde_json::to_value(self).into_tonic_internal_err("failed to serialize cedar entity")?
44        {
45            // Filter out null values because Cedar does not allow null values in attributes.
46            let object = object.into_iter().filter(|(_, v)| !v.is_null()).collect();
47            Ok(object)
48        } else {
49            Ok(serde_json::value::Map::new())
50        }
51    }
52
53    async fn parents(&self, global: &Arc<G>) -> Result<HashSet<EntityUid>, tonic::Status> {
54        let _global = global;
55        Ok(HashSet::new())
56    }
57
58    async fn to_entity(&self, global: &Arc<G>) -> Result<Entity, tonic::Status> {
59        let mut value = serde_json::value::Map::new();
60        value.insert("uid".to_string(), uid_to_json(self.entity_uid()));
61        value.insert("attrs".to_string(), serde_json::Value::Object(self.attributes(global).await?));
62        value.insert(
63            "parents".to_string(),
64            serde_json::Value::Array(self.parents(global).await?.into_iter().map(uid_to_json).collect()),
65        );
66
67        let entity = Entity::from_json_value(serde_json::Value::Object(value), None)
68            .into_tonic_internal_err("failed to create cedar entity")?;
69        Ok(entity)
70    }
71}
72
73impl<G, T: CedarEntity<G>> CedarEntity<G> for &T {
74    const ENTITY_TYPE: &'static str = T::ENTITY_TYPE;
75
76    fn entity_id(&self) -> EntityId {
77        T::entity_id(self)
78    }
79
80    fn entity_uid(&self) -> EntityUid {
81        T::entity_uid(self)
82    }
83}
84
85impl<G, T: PrefixedId + CedarEntity<G>> CedarEntity<G> for Id<T> {
86    const ENTITY_TYPE: &'static str = T::ENTITY_TYPE;
87
88    fn entity_id(&self) -> EntityId {
89        EntityId::new(self.to_string_unprefixed())
90    }
91}
92
93#[derive(Debug, serde::Serialize)]
94pub struct Unauthenticated;
95
96impl<G> CedarEntity<G> for Unauthenticated {
97    const ENTITY_TYPE: &'static str = "Unauthenticated";
98
99    fn entity_id(&self) -> EntityId {
100        EntityId::new("unauthenticated")
101    }
102}
103
104#[derive(Debug, Clone, Copy, derive_more::Display, serde::Serialize)]
105pub enum Action {
106    // User related
107    /// Register with email and password.
108    #[display("register_with_email")]
109    RegisterWithEmail,
110    #[display("complete_register_with_email")]
111    CompleteRegisterWithEmail,
112    #[display("get_login_with_email_options")]
113    GetLoginWithEmailOptions,
114    /// Login to an existing account with email and password.
115    #[display("login_with_email_password")]
116    LoginWithEmailPassword,
117    #[display("request_magic_link")]
118    RequestMagicLink,
119    #[display("login_with_magic_link")]
120    LoginWithMagicLink,
121    /// Login to an existing account with Google OAuth2.
122    #[display("login_with_google")]
123    LoginWithGoogle,
124    #[display("login_with_webauthn")]
125    LoginWithWebauthn,
126    #[display("get_user")]
127    GetUser,
128    #[display("update_user")]
129    UpdateUser,
130    #[display("list_user_emails")]
131    ListUserEmails,
132    #[display("create_user_email")]
133    CreateUserEmail,
134    #[display("delete_user_email")]
135    DeleteUserEmail,
136
137    #[display("create_webauthn_credential")]
138    CreateWebauthnCredential,
139    #[display("complete_create_webauthn_credential")]
140    CompleteCreateWebauthnCredential,
141    #[display("create_webauthn_challenge")]
142    CreateWebauthnChallenge,
143    #[display("delete_webauthn_credential")]
144    DeleteWebauthnCredential,
145    #[display("list_webauthn_credentials")]
146    ListWebauthnCredentials,
147
148    #[display("create_totp_credential")]
149    CreateTotpCredential,
150    #[display("complete_create_totp_credential")]
151    CompleteCreateTotpCredential,
152    #[display("delete_totp_credential")]
153    DeleteTotpCredential,
154    #[display("list_totp_credentials")]
155    ListTotpCredentials,
156
157    #[display("regenerate_recovery_codes")]
158    RegenerateRecoveryCodes,
159    #[display("delete_user")]
160    DeleteUser,
161
162    // UserSessionRequest related
163    #[display("create_user_session_request")]
164    CreateUserSessionRequest,
165    #[display("get_user_session_request")]
166    GetUserSessionRequest,
167    #[display("approve_user_session_request")]
168    ApproveUserSessionRequest,
169    #[display("complete_user_session_request")]
170    CompleteUserSessionRequest,
171
172    // UserSession related
173    #[display("validate_mfa_for_user_session")]
174    ValidateMfaForUserSession,
175    #[display("refresh_user_session")]
176    RefreshUserSession,
177    #[display("invalidate_user_session")]
178    InvalidateUserSession,
179
180    // Organization related
181    #[display("create_organization")]
182    CreateOrganization,
183    #[display("get_organization")]
184    GetOrganization,
185    #[display("update_organization")]
186    UpdateOrganization,
187    #[display("list_organization_members")]
188    ListOrganizationMembers,
189    #[display("list_organizations_by_user")]
190    ListOrganizationsByUser,
191    #[display("create_project")]
192    CreateProject,
193    #[display("list_project")]
194    ListProjects,
195
196    // OrganizationInvitation related
197    #[display("create_organization_invitation")]
198    CreateOrganizationInvitation,
199    #[display("list_organization_invitations_by_user")]
200    ListOrganizationInvitationsByUser,
201    #[display("list_organization_invitations_by_organization")]
202    ListOrganizationInvitationsByOrganization,
203    #[display("get_organization_invitation")]
204    GetOrganizationInvitation,
205    #[display("accept_organization_invitation")]
206    AcceptOrganizationInvitation,
207    #[display("decline_organization_invitation")]
208    DeclineOrganizationInvitation,
209}
210
211impl<G> CedarEntity<G> for Action {
212    const ENTITY_TYPE: &'static str = "Action";
213
214    fn entity_id(&self) -> EntityId {
215        EntityId::new(self.to_string())
216    }
217}
218
219/// A general resource that is used whenever there is no specific resource for a request. (e.g. user login)
220#[derive(serde::Serialize)]
221pub struct CoreApplication;
222
223impl<G> CedarEntity<G> for CoreApplication {
224    const ENTITY_TYPE: &'static str = "Application";
225
226    fn entity_id(&self) -> EntityId {
227        EntityId::new("core")
228    }
229}
230
231pub(crate) async fn is_authorized<G: CoreConfig>(
232    global: &Arc<G>,
233    user_session: Option<&UserSession>,
234    principal: impl CedarEntity<G>,
235    action: impl CedarEntity<G>,
236    resource: impl CedarEntity<G>,
237) -> Result<(), tonic::Status> {
238    let mut context = serde_json::Map::new();
239    if let Some(session) = user_session {
240        context.insert(
241            "user_session_mfa_pending".to_string(),
242            serde_json::Value::Bool(session.mfa_pending),
243        );
244    }
245
246    let context = cedar_policy::Context::from_json_value(serde_json::Value::Object(context), None)
247        .into_tonic_internal_err("failed to create cedar context")?;
248
249    let r = cedar_policy::Request::new(
250        principal.entity_uid(),
251        action.entity_uid(),
252        resource.entity_uid(),
253        context,
254        None,
255    )
256    .into_tonic_internal_err("failed to validate cedar request")?;
257
258    let entities = vec![
259        principal.to_entity(global).await?,
260        action.to_entity(global).await?,
261        resource.to_entity(global).await?,
262    ];
263
264    let entities = Entities::empty()
265        .add_entities(entities, None)
266        .into_tonic_internal_err("failed to create cedar entities")?;
267
268    match global.authorizer().is_authorized(&r, static_policies(), &entities).decision() {
269        Decision::Allow => Ok(()),
270        Decision::Deny => {
271            tracing::warn!(request = ?r, "authorization denied");
272            let message = format!(
273                "{} is not authorized to perform {} on {}",
274                r.principal().expect("is always known"),
275                r.action().expect("is always known"),
276                r.resource().expect("is always known")
277            );
278
279            Err(tonic::Status::with_error_details(
280                tonic::Code::PermissionDenied,
281                "you are not authorized to perform this action",
282                ErrorDetails::with_debug_info(vec![], message),
283            ))
284        }
285    }
286}