Skip to main content

headless_lms_authorization/
lib.rs

1/*!
2Decides whether a user may perform an [Action] on a [Resource].
3
4A passing check hands out an [AuthorizationToken]. Its only field is private, so the token
5cannot be forged outside this crate and therefore proves that a check was made; callers that
6answer requests are expected to require one before responding.
7*/
8
9pub mod error;
10
11use std::borrow::Cow;
12
13use error::{AuthorizationError, AuthorizationErrorType, AuthorizationResult, authorization_err};
14use headless_lms_base::error::backend_error::BackendError;
15use headless_lms_models::chatbot_configurations::ChatbotConfiguration;
16use headless_lms_models::{self as models, CourseOrExamId, roles::Role, roles::UserRole};
17use serde::{Deserialize, Serialize};
18use sqlx::PgConnection;
19use tracing::info;
20use utoipa::ToSchema;
21use uuid::Uuid;
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, ToSchema)]
24#[serde(rename_all = "snake_case")]
25pub struct ActionOnResource {
26    pub action: Action,
27    pub resource: Resource,
28}
29
30/// Describes an action that a user can take on some resource.
31#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone, Copy, ToSchema)]
32#[serde(rename_all = "snake_case", tag = "type", content = "variant")]
33pub enum Action {
34    ViewMaterial,
35    View,
36    Edit,
37    Grade,
38    Teach,
39    Download,
40    Duplicate,
41    DeleteAnswer,
42    EditRole(UserRole),
43    CreateCoursesOrExams,
44    /// Deletion that we usually don't want to allow.
45    UsuallyUnacceptableDeletion,
46    UploadFile,
47    ViewUserProgressOrDetails,
48    ViewInternalCourseStructure,
49    ViewStats,
50    /// Seeing a course's credit registrations and acting on them. Separate from
51    /// `ViewUserProgressOrDetails` and `Edit` because these surfaces carry every student's unmasked
52    /// student number, which is their key in the national study registry, and an assistant on a
53    /// course is often another student on it.
54    ViewAndManageCreditRegistrations,
55    /// Editing someone else's account identity or credentials: their email, its verification
56    /// state, a password reset link minted on their behalf. Separate from `Edit` because `Edit` is
57    /// held by teachers and assistants on their own courses, and account administration is not a
58    /// course-scoped power.
59    AdministrateUserAccount,
60    Administrate,
61}
62
63/// The target of an action.
64#[derive(Debug, Serialize, Deserialize, PartialEq, Clone, ToSchema)]
65#[serde(rename_all = "snake_case", tag = "type", content = "id")]
66pub enum Resource {
67    GlobalPermissions,
68    Chapter(Uuid),
69    Course(Uuid),
70    CourseInstance(Uuid),
71    Exam(Uuid),
72    Exercise(Uuid),
73    ExerciseSlideSubmission(Uuid),
74    ExerciseTask(Uuid),
75    ExerciseTaskGrading(Uuid),
76    ExerciseTaskSubmission(Uuid),
77    Organization(Uuid),
78    Page(Uuid),
79    StudyRegistry(String),
80    AnyCourse,
81    Role,
82    /// A specific user account. Only a global role can hold anything on it: [is_permitted] has no
83    /// per-user rule, so the id names the target for the audit trail and for a future scoping rule
84    /// rather than widening who passes.
85    User(Uuid),
86    PlaygroundExample,
87    ExerciseService,
88}
89
90impl Resource {
91    pub fn from_course_or_exam_id(course_or_exam_id: CourseOrExamId) -> Self {
92        match course_or_exam_id {
93            CourseOrExamId::Course(id) => Self::Course(id),
94            CourseOrExamId::Exam(id) => Self::Exam(id),
95        }
96    }
97}
98
99/// Proof that an authorization check passed.
100#[derive(Copy, Clone, Debug)]
101pub struct AuthorizationToken(());
102
103/** Skips authorize(), for anonymous and test-user code paths where there is no user to check.
104
105# Example
106
107```ignore
108async fn example_function(
109) -> ControllerResult<....> {
110    let token = skip_authorize();
111
112    token.authorized_ok(web::Json(organizations))
113
114}
115```
116*/
117pub fn skip_authorize() -> AuthorizationToken {
118    AuthorizationToken(())
119}
120
121/// Where a check gets the user's roles: a snapshot the caller already holds, or a query this
122/// crate runs only if the check gets far enough to need one.
123enum RoleSource<'roles> {
124    Fetched(&'roles [Role]),
125    OfUser(Option<Uuid>),
126}
127
128impl<'roles> RoleSource<'roles> {
129    async fn resolve(&self, conn: &mut PgConnection) -> AuthorizationResult<Cow<'roles, [Role]>> {
130        match *self {
131            Self::Fetched(roles) => Ok(Cow::Borrowed(roles)),
132            Self::OfUser(user_id) => Ok(Cow::Owned(fetch_user_roles(conn, user_id).await?)),
133        }
134    }
135}
136
137/** Handles authorization for global chatbots and course chatbots */
138pub async fn authorize_access_to_chatbot(
139    conn: &mut PgConnection,
140    user_id: Option<Uuid>,
141    chatbot_configuration: &ChatbotConfiguration,
142) -> AuthorizationResult<AuthorizationToken> {
143    access_to_chatbot(
144        conn,
145        user_id,
146        chatbot_configuration,
147        RoleSource::OfUser(user_id),
148    )
149    .await
150}
151
152/// Same as [authorize_access_to_chatbot], but takes already-fetched roles instead of querying
153/// for them.
154pub async fn authorize_access_to_chatbot_with_fetched_list_of_roles(
155    conn: &mut PgConnection,
156    user_id: Option<Uuid>,
157    chatbot_configuration: &ChatbotConfiguration,
158    user_roles: &[Role],
159) -> AuthorizationResult<AuthorizationToken> {
160    access_to_chatbot(
161        conn,
162        user_id,
163        chatbot_configuration,
164        RoleSource::Fetched(user_roles),
165    )
166    .await
167}
168
169async fn access_to_chatbot(
170    conn: &mut PgConnection,
171    user_id: Option<Uuid>,
172    chatbot_configuration: &ChatbotConfiguration,
173    roles: RoleSource<'_>,
174) -> AuthorizationResult<AuthorizationToken> {
175    if chatbot_configuration.publicly_accessible {
176        return Ok(skip_authorize());
177    }
178
179    match (user_id, chatbot_configuration.course_id) {
180        (Some(_), Some(course_id)) => {
181            access_to_course_material(conn, user_id, course_id, roles).await
182        }
183        _ => Err(authorization_err!(
184            Unauthorized,
185            "You are not authorized to access the chatbot.".to_string()
186        )),
187    }
188}
189
190/// Checks whether the user may view course material.
191pub async fn authorize_access_to_course_material(
192    conn: &mut PgConnection,
193    user_id: Option<Uuid>,
194    course_id: Uuid,
195) -> AuthorizationResult<AuthorizationToken> {
196    access_to_course_material(conn, user_id, course_id, RoleSource::OfUser(user_id)).await
197}
198
199/// Same as [authorize_access_to_course_material], but takes already-fetched roles instead of
200/// querying for them.
201pub async fn authorize_access_to_course_material_with_fetched_list_of_roles(
202    conn: &mut PgConnection,
203    user_id: Option<Uuid>,
204    course_id: Uuid,
205    user_roles: &[Role],
206) -> AuthorizationResult<AuthorizationToken> {
207    access_to_course_material(conn, user_id, course_id, RoleSource::Fetched(user_roles)).await
208}
209
210async fn access_to_course_material(
211    conn: &mut PgConnection,
212    user_id: Option<Uuid>,
213    course_id: Uuid,
214    roles: RoleSource<'_>,
215) -> AuthorizationResult<AuthorizationToken> {
216    if models::courses::is_draft(conn, course_id).await? {
217        info!("Course is in draft mode");
218        if user_id.is_none() {
219            return Err(authorization_err!(
220                Unauthorized,
221                "This course is currently in draft mode and not publicly available. Please log in if you have access permissions.".to_string()
222            ));
223        }
224        let user_roles = roles.resolve(conn).await?;
225        return authorize_with_fetched_list_of_roles(
226            conn,
227            Action::ViewMaterial,
228            Resource::Course(course_id),
229            &user_roles,
230        )
231        .await;
232    }
233
234    if models::courses::is_joinable_by_code_only(conn, course_id).await? {
235        info!("Course is joinable by code only");
236        let Some(user_id) = user_id else {
237            return Err(authorization_err!(
238                Unauthorized,
239                "This course requires authentication to access".to_string()
240            ));
241        };
242        if models::join_code_uses::check_if_user_has_access_to_course(conn, user_id, course_id)
243            .await
244            .is_err()
245        {
246            let user_roles = roles.resolve(conn).await?;
247            authorize_with_fetched_list_of_roles(
248                conn,
249                Action::ViewMaterial,
250                Resource::Course(course_id),
251                &user_roles,
252            )
253            .await?;
254        }
255        return Ok(skip_authorize());
256    }
257
258    // The course is publicly available, no need to authorize
259    Ok(skip_authorize())
260}
261
262/// Checks whether the user may view a chapter, which may be closed to everyone but certain roles.
263pub async fn can_user_view_chapter(
264    conn: &mut PgConnection,
265    user_id: Option<Uuid>,
266    course_id: Option<Uuid>,
267    chapter_id: Option<Uuid>,
268) -> AuthorizationResult<bool> {
269    user_can_view_chapter(
270        conn,
271        user_id,
272        course_id,
273        chapter_id,
274        RoleSource::OfUser(user_id),
275    )
276    .await
277}
278
279/// Same as [can_user_view_chapter], but takes already-fetched roles instead of querying for them.
280pub async fn can_user_view_chapter_with_fetched_list_of_roles(
281    conn: &mut PgConnection,
282    user_id: Option<Uuid>,
283    course_id: Option<Uuid>,
284    chapter_id: Option<Uuid>,
285    user_roles: &[Role],
286) -> AuthorizationResult<bool> {
287    user_can_view_chapter(
288        conn,
289        user_id,
290        course_id,
291        chapter_id,
292        RoleSource::Fetched(user_roles),
293    )
294    .await
295}
296
297async fn user_can_view_chapter(
298    conn: &mut PgConnection,
299    user_id: Option<Uuid>,
300    course_id: Option<Uuid>,
301    chapter_id: Option<Uuid>,
302    roles: RoleSource<'_>,
303) -> AuthorizationResult<bool> {
304    if let Some(course_id) = course_id
305        && let Some(chapter_id) = chapter_id
306        && !models::chapters::is_open(&mut *conn, chapter_id).await?
307    {
308        if user_id.is_none() {
309            return Ok(false);
310        }
311        // Access to view the material also unlocks unopened chapters, so teachers can test them with real students.
312        let user_roles = roles.resolve(conn).await?;
313        // A check that cannot be completed is no reason to reveal an unopened chapter.
314        return Ok(is_permitted(
315            conn,
316            Action::ViewMaterial,
317            Resource::Course(course_id),
318            &user_roles,
319        )
320        .await
321        .unwrap_or(false));
322    }
323    Ok(true)
324}
325
326/// Checks whether the user may perform `action` on `resource`, fetching their roles.
327///
328/// The returned token is the only way to build a controller response, so only call this from a
329/// controller function:
330///
331/// ```ignore
332/// let token = authorize(&mut conn, Action::Edit, Some(user.id), Resource::Page(*page_id)).await?;
333/// token.authorized_ok(web::Json(cms_page_info))
334/// ```
335pub async fn authorize(
336    conn: &mut PgConnection,
337    action: Action,
338    user_id: Option<Uuid>,
339    resource: Resource,
340) -> AuthorizationResult<AuthorizationToken> {
341    let user_roles = fetch_user_roles(conn, user_id).await?;
342
343    authorize_with_fetched_list_of_roles(conn, action, resource, &user_roles).await
344}
345
346/// Whether the user holds a global admin role.
347///
348/// Answers the same question as `authorize(Administrate, GlobalPermissions)`, as a boolean for
349/// the callers that branch on admin status rather than gate on it. Errors only when the user's
350/// roles cannot be fetched.
351pub async fn is_user_global_admin(
352    conn: &mut PgConnection,
353    user_id: Uuid,
354) -> AuthorizationResult<bool> {
355    let user_roles = fetch_user_roles(conn, Some(user_id)).await?;
356
357    is_permitted(
358        conn,
359        Action::Administrate,
360        Resource::GlobalPermissions,
361        &user_roles,
362    )
363    .await
364}
365
366/// The roles a user holds, for callers that check several permissions and want to pay for the
367/// roles query once by passing the result to [authorize_with_fetched_list_of_roles].
368///
369/// An anonymous request has no roles rather than an error, and costs no query.
370pub async fn fetch_user_roles(
371    conn: &mut PgConnection,
372    user_id: Option<Uuid>,
373) -> AuthorizationResult<Vec<Role>> {
374    match user_id {
375        Some(user_id) => models::roles::get_roles(conn, user_id)
376            .await
377            .map_err(|original_err| {
378                authorization_err!(
379                    InternalServerError,
380                    format!("Failed to fetch user roles: {}", original_err),
381                    original_err
382                )
383            }),
384        None => Ok(Vec::new()),
385    }
386}
387
388/// Builds the generic Forbidden error shown to the user, nesting the actual roles and attempted
389/// action in the source error so they only surface in logs.
390fn create_authorization_error(user_roles: &[Role], action: Action) -> AuthorizationError {
391    let mut detail_message = String::new();
392
393    if user_roles.is_empty() {
394        detail_message.push_str("You don't have any assigned roles.");
395    } else {
396        detail_message.push_str("Your current roles are: ");
397        let roles_str = user_roles
398            .iter()
399            .map(|r| format!("{:?} ({})", r.role, r.domain_description()))
400            .collect::<Vec<_>>()
401            .join(", ");
402        detail_message.push_str(&roles_str);
403    }
404
405    detail_message.push_str(&format!("\nAction attempted: {:?}", action));
406
407    authorization_err!(
408        Forbidden,
409        "Unauthorized. Please contact course staff if you believe you should have access."
410            .to_string(),
411        authorization_err!(Forbidden, detail_message)
412    )
413}
414
415/// Same as [authorize], but takes already-fetched roles instead of querying for them; use when
416/// checking several actions for the same user.
417pub async fn authorize_with_fetched_list_of_roles(
418    conn: &mut PgConnection,
419    action: Action,
420    resource: Resource,
421    user_roles: &[Role],
422) -> AuthorizationResult<AuthorizationToken> {
423    if is_permitted(conn, action, resource, user_roles).await? {
424        Ok(AuthorizationToken(()))
425    } else {
426        Err(create_authorization_error(user_roles, action))
427    }
428}
429
430/// Whether `user_roles` allow `action` on `resource`.
431///
432/// The boolean answer for callers that ask a permission question instead of gating on it: a
433/// denial costs no error, and therefore no backtrace, span trace or roles dump. Errors only
434/// when the check itself cannot be completed.
435pub async fn is_permitted(
436    conn: &mut PgConnection,
437    action: Action,
438    resource: Resource,
439    user_roles: &[Role],
440) -> AuthorizationResult<bool> {
441    for role in user_roles {
442        if role.is_global() && has_permission(role.role, action) {
443            return Ok(true);
444        }
445    }
446
447    // for this resource, the domain of the role does not matter (e.g. organization role, course role, etc.)
448    if resource == Resource::AnyCourse {
449        return Ok(user_roles
450            .iter()
451            .any(|role| has_permission(role.role, action)));
452    }
453
454    match resource {
455        Resource::Chapter(id) => {
456            // if trying to View a chapter that is not open, check for permission to view the material
457            let action =
458                if matches!(action, Action::View) && !models::chapters::is_open(conn, id).await? {
459                    Action::ViewMaterial
460                } else {
461                    action
462                };
463            // there are no chapter roles so we check the course instead
464            let course_id = models::chapters::get_course_id(conn, id).await?;
465            check_course_permission(conn, user_roles, action, course_id).await
466        }
467        Resource::Course(id) => check_course_permission(conn, user_roles, action, id).await,
468        Resource::CourseInstance(id) => {
469            check_course_instance_permission(conn, user_roles, action, id).await
470        }
471        Resource::Exercise(id) => {
472            let course_or_exam_id = models::exercises::get_course_or_exam_id(conn, id).await?;
473            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
474        }
475        Resource::ExerciseSlideSubmission(id) => {
476            let course_or_exam_id =
477                models::exercise_slide_submissions::get_course_and_exam_id(conn, id).await?;
478            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
479        }
480        Resource::ExerciseTask(id) => {
481            let course_or_exam_id = models::exercise_tasks::get_course_or_exam_id(conn, id).await?;
482            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
483        }
484        Resource::ExerciseTaskSubmission(id) => {
485            let course_or_exam_id =
486                models::exercise_task_submissions::get_course_and_exam_id(conn, id).await?;
487            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
488        }
489        Resource::ExerciseTaskGrading(id) => {
490            let course_or_exam_id =
491                models::exercise_task_gradings::get_course_or_exam_id(conn, id).await?;
492            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
493        }
494        Resource::Organization(id) => Ok(check_organization_permission(user_roles, action, id)),
495        Resource::Page(id) => {
496            let course_or_exam_id = models::pages::get_course_and_exam_id(conn, id).await?;
497            check_course_or_exam_permission(conn, user_roles, action, course_or_exam_id).await
498        }
499        Resource::StudyRegistry(secret_key) => {
500            check_study_registry_permission(conn, secret_key, action).await
501        }
502        Resource::Exam(exam_id) => check_exam_permission(conn, user_roles, action, exam_id).await,
503        Resource::Role
504        | Resource::User(_)
505        | Resource::AnyCourse
506        | Resource::PlaygroundExample
507        | Resource::ExerciseService
508        | Resource::GlobalPermissions => {
509            // permissions for these resources have already been checked
510            Ok(false)
511        }
512    }
513}
514
515fn check_organization_permission(roles: &[Role], action: Action, organization_id: Uuid) -> bool {
516    if action == Action::View {
517        return true;
518    };
519
520    roles.iter().any(|role| {
521        role.is_role_for_organization(organization_id) && has_permission(role.role, action)
522    })
523}
524
525/// Also checks organization role which is valid for courses.
526async fn check_course_permission(
527    conn: &mut PgConnection,
528    roles: &[Role],
529    action: Action,
530    course_id: Uuid,
531) -> AuthorizationResult<bool> {
532    if roles
533        .iter()
534        .any(|role| role.is_role_for_course(course_id) && has_permission(role.role, action))
535    {
536        return Ok(true);
537    }
538    let organization_id = models::courses::get_organization_id(conn, course_id).await?;
539    Ok(check_organization_permission(
540        roles,
541        action,
542        organization_id,
543    ))
544}
545
546/// Also checks organization and course roles which are valid for course instances.
547async fn check_course_instance_permission(
548    conn: &mut PgConnection,
549    roles: &[Role],
550    mut action: Action,
551    course_instance_id: Uuid,
552) -> AuthorizationResult<bool> {
553    // if trying to View a course instance that is not open, we check for permission to Teach
554    if action == Action::View
555        && !models::course_instances::is_open(conn, course_instance_id).await?
556    {
557        action = Action::Teach;
558    }
559
560    if roles.iter().any(|role| {
561        role.is_role_for_course_instance(course_instance_id) && has_permission(role.role, action)
562    }) {
563        return Ok(true);
564    }
565    let course_id = models::course_instances::get_course_id(conn, course_instance_id).await?;
566    check_course_permission(conn, roles, action, course_id).await
567}
568
569/// Also checks organization role which is valid for exams.
570async fn check_exam_permission(
571    conn: &mut PgConnection,
572    roles: &[Role],
573    action: Action,
574    exam_id: Uuid,
575) -> AuthorizationResult<bool> {
576    if roles
577        .iter()
578        .any(|role| role.is_role_for_exam(exam_id) && has_permission(role.role, action))
579    {
580        return Ok(true);
581    }
582    let organization_id = models::exams::get_organization_id(conn, exam_id).await?;
583    Ok(check_organization_permission(
584        roles,
585        action,
586        organization_id,
587    ))
588}
589
590async fn check_course_or_exam_permission(
591    conn: &mut PgConnection,
592    roles: &[Role],
593    action: Action,
594    course_or_exam_id: CourseOrExamId,
595) -> AuthorizationResult<bool> {
596    match course_or_exam_id {
597        CourseOrExamId::Course(course_id) => {
598            check_course_permission(conn, roles, action, course_id).await
599        }
600        CourseOrExamId::Exam(exam_id) => check_exam_permission(conn, roles, action, exam_id).await,
601    }
602}
603
604async fn check_study_registry_permission(
605    conn: &mut PgConnection,
606    secret_key: String,
607    action: Action,
608) -> AuthorizationResult<bool> {
609    let _registrar = models::study_registry_registrars::get_by_secret_key(conn, &secret_key)
610        .await
611        .map_err(|original_error| {
612            authorization_err!(
613                Forbidden,
614                format!("Study registry access denied: Invalid or missing secret key. The operation {:?} cannot be performed.", action),
615                original_error
616            )
617        })?;
618    Ok(true)
619}
620
621fn has_permission(user_role: UserRole, action: Action) -> bool {
622    use Action::*;
623    use UserRole::*;
624
625    match user_role {
626        Admin => true,
627        Teacher => matches!(
628            action,
629            View | Teach
630                | Edit
631                | Grade
632                | Duplicate
633                | DeleteAnswer
634                | EditRole(Teacher | Assistant | Reviewer | MaterialViewer | StatsViewer)
635                | CreateCoursesOrExams
636                | ViewMaterial
637                | UploadFile
638                | ViewUserProgressOrDetails
639                | ViewInternalCourseStructure
640                | ViewStats
641                | ViewAndManageCreditRegistrations
642        ),
643        Assistant => matches!(
644            action,
645            View | Edit
646                | Grade
647                | DeleteAnswer
648                | EditRole(Assistant | Reviewer | MaterialViewer)
649                | Teach
650                | ViewMaterial
651                | ViewUserProgressOrDetails
652                | ViewInternalCourseStructure
653        ),
654        Reviewer => matches!(
655            action,
656            View | Grade | ViewMaterial | ViewInternalCourseStructure
657        ),
658        CourseOrExamCreator => matches!(action, CreateCoursesOrExams),
659        MaterialViewer => matches!(action, ViewMaterial),
660        TeachingAndLearningServices => {
661            matches!(
662                action,
663                View | ViewMaterial
664                    | ViewUserProgressOrDetails
665                    | ViewInternalCourseStructure
666                    | ViewStats
667            )
668        }
669        StatsViewer => matches!(action, ViewStats),
670    }
671}