Skip to main content

headless_lms_server/domain/
authorization.rs

1//! Actix plumbing around the authorization policy engine in [headless_lms_authorization].
2
3use crate::prelude::*;
4use actix_web::{HttpRequest, Responder};
5
6pub use headless_lms_authorization::error::{AuthorizationError, AuthorizationErrorType};
7pub use headless_lms_authorization::{
8    Action, ActionOnResource, AuthorizationToken, Resource, authorize, authorize_access_to_chatbot,
9    authorize_access_to_course_material, authorize_with_fetched_list_of_roles,
10    can_user_view_chapter, is_permitted, is_user_global_admin, skip_authorize,
11};
12
13/// A controller's response payload. Only [AuthorizedOk::authorized_ok] can build one, so
14/// answering a request requires having passed an authorization check.
15#[derive(Copy, Clone)]
16pub struct AuthorizedResponse<T> {
17    pub data: T,
18}
19
20impl<T: Responder> Responder for AuthorizedResponse<T> {
21    type Body = T::Body;
22
23    fn respond_to(self, req: &HttpRequest) -> actix_web::HttpResponse<Self::Body> {
24        T::respond_to(self.data, req)
25    }
26}
27
28/// Turns a token into a [ControllerResult], which is the only way for a controller to build
29/// one. Lives here rather than on [AuthorizationToken] itself because the response type is
30/// tied to actix, which the policy engine deliberately does not depend on.
31pub trait AuthorizedOk {
32    fn authorized_ok<T>(self, t: T) -> ControllerResult<T>;
33}
34
35impl AuthorizedOk for AuthorizationToken {
36    fn authorized_ok<T>(self, t: T) -> ControllerResult<T> {
37        Ok(AuthorizedResponse { data: t })
38    }
39}
40
41/// Rejects saving `exercise_tasks` on a page when that would add a task of a type only global
42/// admins can add and the user is not one. `page_id` is `None` for a page that does not exist yet.
43pub async fn ensure_user_can_add_exercise_tasks(
44    conn: &mut PgConnection,
45    user_id: Uuid,
46    page_id: Option<Uuid>,
47    exercise_tasks: &[models::pages::CmsPageExerciseTask],
48) -> Result<(), ControllerError> {
49    let added_service_names =
50        models::pages::get_global_admin_only_exercise_service_names_added_by_tasks(
51            conn,
52            models::pages::ExistingTasks::OnPage(page_id),
53            exercise_tasks,
54        )
55        .await?;
56    let Some(service_name) = added_service_names.first() else {
57        return Ok(());
58    };
59    if is_user_global_admin(conn, user_id).await? {
60        return Ok(());
61    }
62    Err(controller_err!(
63        BadRequest,
64        format!("Only global admins can add exercises of type “{service_name}”.")
65    ))
66}
67
68#[cfg(test)]
69mod test {
70    use super::*;
71    // Explicit: a workspace-wide test build enables the models crate's test-helpers feature, whose
72    // own insert_data! then clashes with ours in the globs below.
73    use crate::insert_data;
74    use crate::test_helper::*;
75    use headless_lms_models::*;
76    use models::roles::{RoleDomain, UserRole};
77
78    #[actix_web::test]
79    async fn test_authorization() {
80        let mut conn = Conn::init().await;
81        let mut tx = conn.begin().await;
82
83        let user = users::insert(
84            tx.as_mut(),
85            PKeyPolicy::Generate,
86            "auth@example.com",
87            None,
88            None,
89        )
90        .await
91        .unwrap();
92        let org = organizations::insert(
93            tx.as_mut(),
94            PKeyPolicy::Generate,
95            "auth",
96            "auth",
97            Some("auth"),
98            false,
99        )
100        .await
101        .unwrap();
102
103        authorize(
104            tx.as_mut(),
105            Action::Edit,
106            Some(user),
107            Resource::Organization(org),
108        )
109        .await
110        .unwrap_err();
111
112        roles::insert(
113            tx.as_mut(),
114            user,
115            UserRole::Teacher,
116            RoleDomain::Organization(org),
117        )
118        .await
119        .unwrap();
120
121        authorize(
122            tx.as_mut(),
123            Action::Edit,
124            Some(user),
125            Resource::Organization(org),
126        )
127        .await
128        .unwrap();
129    }
130
131    #[actix_web::test]
132    async fn course_role_chapter_resource() {
133        insert_data!(:tx, :user, :org, :course, instance: _instance, :course_module, :chapter);
134
135        authorize(
136            tx.as_mut(),
137            Action::Edit,
138            Some(user),
139            Resource::Chapter(chapter),
140        )
141        .await
142        .unwrap_err();
143
144        roles::insert(
145            tx.as_mut(),
146            user,
147            UserRole::Teacher,
148            RoleDomain::Course(course),
149        )
150        .await
151        .unwrap();
152
153        authorize(
154            tx.as_mut(),
155            Action::Edit,
156            Some(user),
157            Resource::Chapter(chapter),
158        )
159        .await
160        .unwrap();
161    }
162
163    #[actix_web::test]
164    async fn anonymous_user_can_view_open_course() {
165        insert_data!(:tx, :user, :org, :course);
166
167        authorize(tx.as_mut(), Action::View, None, Resource::Course(course))
168            .await
169            .unwrap();
170    }
171}