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