1pub 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#[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 UsuallyUnacceptableDeletion,
46 UploadFile,
47 ViewUserProgressOrDetails,
48 ViewInternalCourseStructure,
49 ViewStats,
50 ViewAndManageCreditRegistrations,
55 AdministrateUserAccount,
60 AdministrateCreditRegistrations,
63 Administrate,
64}
65
66#[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 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#[derive(Copy, Clone, Debug)]
104pub struct AuthorizationToken(());
105
106pub fn skip_authorize() -> AuthorizationToken {
121 AuthorizationToken(())
122}
123
124enum 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
140pub 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
155pub 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
193pub 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
202pub 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 Ok(skip_authorize())
263}
264
265pub 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
282pub 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 let user_roles = roles.resolve(conn).await?;
316 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
329pub 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
349pub 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
369pub 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
391fn 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
418pub 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
433pub 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 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 let action =
461 if matches!(action, Action::View) && !models::chapters::is_open(conn, id).await? {
462 Action::ViewMaterial
463 } else {
464 action
465 };
466 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 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
528async 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
549async 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 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
572async 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}