Skip to main content

headless_lms_mcp/
access.rs

1//! Who may use a tool on a course: the token's scopes are checked by dispatch, the teacher's live
2//! roles here, with the course-edit rule the web UI uses.
3
4use headless_lms_authorization::{Action, fetch_user_roles, is_permitted_on_course};
5use headless_lms_models::{
6    courses, courses::Course, error::TryToOptional, page_language_groups, roles::Role,
7};
8use sqlx::{PgConnection, Postgres, pool::PoolConnection};
9use uuid::Uuid;
10
11use crate::engine::PageDocument;
12use crate::engine::read::ReadState;
13use crate::snapshots::{self, PageFacts};
14use crate::state::McpState;
15use crate::tools::{RequestContext, ToolError};
16use headless_lms_models::library::agent_changesets::{
17    course_state::{CourseParts, CourseState, CourseView, load_view},
18    risk::load_student_activity,
19};
20
21/// The course, if it exists and the user may edit it.
22pub async fn authorize_course(
23    conn: &mut PgConnection,
24    user_id: Uuid,
25    course_id: Uuid,
26) -> Result<Course, ToolError> {
27    let course = courses::get_course(conn, course_id)
28        .await
29        .optional()?
30        .ok_or_else(|| ToolError::NotFound {
31            message: format!("no course {course_id}; list_courses shows the courses you can edit"),
32        })?;
33    let roles = fetch_user_roles(conn, Some(user_id)).await?;
34    if !is_permitted_on_course(&roles, Action::Edit, course.id, course.organization_id) {
35        return Err(ToolError::no_edit_rights());
36    }
37    Ok(course)
38}
39
40/// The courses among `courses` a user with `roles` may edit, by the rule of
41/// [`authorize_course`].
42pub fn editable_courses(roles: &[Role], courses: Vec<Course>) -> Vec<Course> {
43    courses
44        .into_iter()
45        .filter(|c| is_permitted_on_course(roles, Action::Edit, c.id, c.organization_id))
46        .collect()
47}
48
49/// The state a read asks for.
50pub fn state_of(view: &CourseView, read_state: ReadState) -> &CourseState {
51    match read_state {
52        ReadState::Staged => &view.staged,
53        ReadState::Published => &view.live,
54    }
55}
56
57/// A pooled connection; a busy pool is a retryable error.
58pub async fn connection(state: &McpState) -> Result<PoolConnection<Postgres>, ToolError> {
59    state.pool.acquire().await.map_err(|e| {
60        ToolError::retryable(
61            format!("the database is busy ({e}); the same call can be retried"),
62            1,
63        )
64    })
65}
66
67/// Authorizes the course argument and loads the course as the user sees it. The connection goes
68/// back to the pool before the tool's own work; handlers that query again take one with
69/// [`connection`].
70pub async fn open_course(
71    state: &McpState,
72    ctx: &RequestContext,
73    course_id: Uuid,
74) -> Result<CourseView, ToolError> {
75    open_course_parts(state, ctx, course_id, CourseParts::All).await
76}
77
78/// The live [`PageFacts`] of the course's pages.
79pub async fn load_page_facts(state: &McpState, course_id: Uuid) -> Result<PageFacts, ToolError> {
80    let mut conn = connection(state).await?;
81    let activity = load_student_activity(&mut conn, course_id).await?;
82    Ok(PageFacts {
83        pages_with_submissions: activity.pages_with_submissions,
84        page_language_groups: page_language_groups::get_by_course_id(&mut conn, course_id).await?,
85    })
86}
87
88/// [`open_course`] loading only the parts of the course a tool reads.
89pub async fn open_course_parts(
90    state: &McpState,
91    ctx: &RequestContext,
92    course_id: Uuid,
93    parts: CourseParts,
94) -> Result<CourseView, ToolError> {
95    let mut conn = connection(state).await?;
96    let course = authorize_course(&mut conn, ctx.principal.actor.user_id, course_id).await?;
97    Ok(load_view(&mut conn, ctx.principal.actor.user_id, course, parts).await?)
98}
99
100/// The page id argument, checked to belong to the course (as staged or as published).
101pub fn page_in_course(view: &CourseView, page_id: Uuid) -> Result<Uuid, ToolError> {
102    if view.staged.page(page_id).is_some() || view.live.page(page_id).is_some() {
103        Ok(page_id)
104    } else {
105        Err(ToolError::NotFound {
106            message: format!(
107                "course {} has no page {page_id}; get_course_structure lists its pages",
108                view.course.id
109            ),
110        })
111    }
112}
113
114/// The page as `staged`, a state with the user's staged changes, has it.
115pub fn staged_page_document(
116    staged: &CourseState,
117    page_id: Uuid,
118) -> Result<PageDocument, ToolError> {
119    snapshots::page_document(staged, page_id).ok_or_else(|| deleted_in_staged_changes(page_id))
120}
121
122/// The error for a page the user's staged changes delete.
123pub fn deleted_in_staged_changes(page_id: Uuid) -> ToolError {
124    ToolError::NotFound {
125        message: format!("page {page_id} is deleted in your staged changes"),
126    }
127}