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 Administrate,
61}
62
63#[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 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#[derive(Copy, Clone, Debug)]
101pub struct AuthorizationToken(());
102
103pub fn skip_authorize() -> AuthorizationToken {
118 AuthorizationToken(())
119}
120
121enum 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
137pub 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
152pub 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
190pub 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
199pub 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 Ok(skip_authorize())
260}
261
262pub 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
279pub 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 let user_roles = roles.resolve(conn).await?;
313 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
326pub 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
346pub 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
366pub 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
388fn 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
415pub 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
430pub 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 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 let action =
458 if matches!(action, Action::View) && !models::chapters::is_open(conn, id).await? {
459 Action::ViewMaterial
460 } else {
461 action
462 };
463 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 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
525async 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
546async 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 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
569async 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}