Skip to main content

headless_lms_chatbot/chatbot_tools/
tool_authorization.rs

1//! What a chatbot tool requires of its caller: checked against the turn before the tool is
2//! offered to the LLM, and against the call's own arguments before it runs.
3//!
4//! A tool states its requirements in the application's own authorization vocabulary rather than a
5//! chatbot-local one, so a tool cannot bring a policy of its own and the chatbot cannot drift from
6//! what the equivalent HTTP endpoint allows.
7
8use std::marker::PhantomData;
9
10use headless_lms_authorization::{
11    Action, AuthorizationToken, Resource,
12    authorize_access_to_course_material_with_fetched_list_of_roles,
13    authorize_with_fetched_list_of_roles, error::AuthorizationErrorType, skip_authorize,
14};
15
16use crate::{prelude::*, user_context::ChatbotTurnContext};
17
18/// One check a tool's caller must pass. A tool lists every resource its call touches; all of them
19/// must pass, so a call naming both a user and a course is authorized for both.
20#[derive(Debug, Clone, PartialEq)]
21pub enum ToolRequirement {
22    /// The ordinary application check, exactly as a controller would make it.
23    ActionOnResource(Action, Resource),
24    /// Access to a course's material, as `authorize_access_to_course_material` decides it: draft
25    /// state, joinability and anonymous access included. Not expressible as an action on a
26    /// resource, which is why it is its own variant.
27    CourseMaterial(Uuid),
28}
29
30impl ToolRequirement {
31    /// A check against the course the call names.
32    pub fn on_course(action: Action, course_id: Uuid) -> Self {
33        Self::ActionOnResource(action, Resource::Course(course_id))
34    }
35
36    /// A check against the user the call targets. Only a global role can hold anything on a user,
37    /// so this is a global capability that names its target rather than a per-user rule.
38    pub fn on_user(action: Action, user_id: Uuid) -> Self {
39        Self::ActionOnResource(action, Resource::User(user_id))
40    }
41
42    /// A check against the turn itself, for deciding what to offer the LLM before any call has
43    /// named a target: the chatbot's own course, or global permissions when it has none.
44    pub fn on_turn(action: Action, user_context: &ChatbotTurnContext) -> Self {
45        Self::ActionOnResource(action, user_context.turn_resource())
46    }
47
48    /// A global capability, held only through a global role.
49    pub fn global(action: Action) -> Self {
50        Self::ActionOnResource(action, Resource::GlobalPermissions)
51    }
52}
53
54/// The token proving `requirements` all passed, or `None` when any of them did not.
55///
56/// A denial is not an error: the chatbot answers one by telling the model the call was aborted,
57/// not by failing the request. The error case is a check that could not be completed.
58///
59/// An empty slice passes without a query, for a tool the chatbot's own access check already
60/// covers.
61async fn authorize_requirements(
62    conn: &mut PgConnection,
63    user_context: &ChatbotTurnContext,
64    requirements: &[ToolRequirement],
65) -> ChatbotResult<Option<AuthorizationToken>> {
66    let mut token = skip_authorize();
67    for requirement in requirements {
68        let roles = user_context.roles(conn).await?;
69        let outcome = match requirement {
70            ToolRequirement::ActionOnResource(action, resource) => {
71                authorize_with_fetched_list_of_roles(conn, *action, resource.clone(), roles).await
72            }
73            ToolRequirement::CourseMaterial(course_id) => {
74                authorize_access_to_course_material_with_fetched_list_of_roles(
75                    conn,
76                    user_context.user_id,
77                    *course_id,
78                    roles,
79                )
80                .await
81            }
82        };
83        match outcome {
84            Ok(granted) => token = granted,
85            Err(error)
86                if matches!(
87                    error.error_type(),
88                    AuthorizationErrorType::Forbidden | AuthorizationErrorType::Unauthorized
89                ) =>
90            {
91                return Ok(None);
92            }
93            Err(error) => return Err(error.into()),
94        }
95    }
96    Ok(Some(token))
97}
98
99/// Whether `requirements` all pass, for the callers that decide what to offer or whether to abort
100/// rather than gating a mutation.
101pub async fn requirements_are_satisfied(
102    conn: &mut PgConnection,
103    user_context: &ChatbotTurnContext,
104    requirements: &[ToolRequirement],
105) -> ChatbotResult<bool> {
106    Ok(authorize_requirements(conn, user_context, requirements)
107        .await?
108        .is_some())
109}
110
111/// Proof that the caller was authorized for `Tool` before the tool ran.
112///
113/// Wraps the application-wide [AuthorizationToken] and adds the two things a chatbot tool boundary
114/// needs that a controller's does not: *which* tool the check was for, as the type parameter, so a
115/// proof minted for one tool cannot be handed to another; and *who* was checked, so an audit row
116/// cannot name a user the check never saw. Every field is private, so [authorize_tool_call] is the
117/// only way to obtain one.
118pub struct ToolAuthorization<Tool> {
119    acting_user_id: Uuid,
120    /// Kept only as evidence that the ordinary `authorize` path produced a token for this caller.
121    _authorization: AuthorizationToken,
122    _tool: PhantomData<fn() -> Tool>,
123}
124
125impl<Tool> ToolAuthorization<Tool> {
126    /// The user whose permission was verified, and therefore the one an action tool must record as
127    /// the actor.
128    pub fn acting_user_id(&self) -> Uuid {
129        self.acting_user_id
130    }
131}
132
133/// Proof that the caller may make this call of `Tool`, or `None` when they may not.
134///
135/// `requirements` are the ones the call's own arguments produce, not the ones the turn was offered
136/// under: the target is chosen by the model, so it is the target that has to be authorized.
137/// `None` also covers a caller who is not logged in, since whatever runs behind this proof records
138/// an actor and an anonymous caller is nobody.
139pub async fn authorize_tool_call<Tool>(
140    conn: &mut PgConnection,
141    user_context: &ChatbotTurnContext,
142    requirements: &[ToolRequirement],
143) -> ChatbotResult<Option<ToolAuthorization<Tool>>> {
144    let Some(acting_user_id) = user_context.user_id else {
145        return Ok(None);
146    };
147    let Some(authorization) = authorize_requirements(conn, user_context, requirements).await?
148    else {
149        return Ok(None);
150    };
151    Ok(Some(ToolAuthorization {
152        acting_user_id,
153        _authorization: authorization,
154        _tool: PhantomData,
155    }))
156}
157
158#[cfg(test)]
159pub(crate) mod test_helpers {
160    use headless_lms_models::{
161        chatbot_configurations::ToolCategory,
162        roles::{Role, UserRole},
163    };
164    use uuid::Uuid;
165
166    use crate::{
167        chatbot_tools::tool_category::EnabledToolCategories, user_context::ChatbotTurnContext,
168    };
169
170    /// A caller whose roles are known already, so that a test does not have to seed them into the
171    /// database. Every category is enabled, so this is for tests of authorization, not categories.
172    pub fn context(
173        user_id: Option<Uuid>,
174        course_id: Option<Uuid>,
175        roles: Vec<Role>,
176    ) -> ChatbotTurnContext {
177        ChatbotTurnContext::with_roles(user_id, course_id, None, roles)
178    }
179
180    /// Like [context], but with a specific enabled-category set instead of everything enabled —
181    /// for tests of the category gate.
182    pub fn context_with_categories(
183        user_id: Option<Uuid>,
184        course_id: Option<Uuid>,
185        roles: Vec<Role>,
186        categories: &[ToolCategory],
187    ) -> ChatbotTurnContext {
188        ChatbotTurnContext::with_roles_and_categories(
189            user_id,
190            course_id,
191            None,
192            roles,
193            EnabledToolCategories::only(categories),
194        )
195    }
196
197    pub fn course_role(user_id: Uuid, course_id: Uuid, role: UserRole) -> Role {
198        Role {
199            is_global: false,
200            organization_id: None,
201            course_id: Some(course_id),
202            course_instance_id: None,
203            exam_id: None,
204            role,
205            user_id,
206        }
207    }
208
209    pub fn global_role(user_id: Uuid, role: UserRole) -> Role {
210        Role {
211            is_global: true,
212            organization_id: None,
213            course_id: None,
214            course_instance_id: None,
215            exam_id: None,
216            role,
217            user_id,
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use headless_lms_models::roles::UserRole;
225
226    use super::{test_helpers::*, *};
227    use headless_lms_models::{
228        insert_data,
229        test_helper::{Conn, init_app_conf},
230    };
231
232    /// A tool the chatbot's own access check already covers asks nothing further, of anyone.
233    #[tokio::test]
234    async fn no_requirements_pass_for_an_anonymous_caller() {
235        insert_data!(:tx, :user, :org, :course);
236        let anonymous = context(None, Some(course), Vec::new());
237
238        assert!(
239            requirements_are_satisfied(tx.as_mut(), &anonymous, &[])
240                .await
241                .expect("the check completes")
242        );
243    }
244
245    /// The point of the whole scheme: the course a call names decides the answer, not the course
246    /// the chatbot happens to sit on.
247    #[tokio::test]
248    async fn a_teacher_is_authorized_only_on_the_course_they_teach() {
249        insert_data!(:tx, :user, :org, :course);
250        let requirement = [ToolRequirement::on_course(Action::Teach, course)];
251
252        let teacher_here = context(
253            Some(user),
254            Some(course),
255            vec![course_role(user, course, UserRole::Teacher)],
256        );
257        assert!(
258            requirements_are_satisfied(tx.as_mut(), &teacher_here, &requirement)
259                .await
260                .expect("the check completes")
261        );
262
263        let teacher_elsewhere = context(
264            Some(user),
265            Some(course),
266            vec![course_role(user, Uuid::new_v4(), UserRole::Teacher)],
267        );
268        assert!(
269            !requirements_are_satisfied(tx.as_mut(), &teacher_elsewhere, &requirement)
270                .await
271                .expect("the check completes"),
272            "a teacher of another course must not be authorized on this one"
273        );
274    }
275
276    /// Every requirement has to pass: a caller who holds one but not the other is refused.
277    #[tokio::test]
278    async fn requirements_are_all_required() {
279        insert_data!(:tx, :user, :org, :course);
280        let teacher = context(
281            Some(user),
282            Some(course),
283            vec![course_role(user, course, UserRole::Teacher)],
284        );
285
286        assert!(
287            !requirements_are_satisfied(
288                tx.as_mut(),
289                &teacher,
290                &[
291                    ToolRequirement::on_course(Action::Teach, course),
292                    ToolRequirement::global(Action::AdministrateUserAccount),
293                ]
294            )
295            .await
296            .expect("the check completes")
297        );
298    }
299
300    /// Account administration is deliberately out of reach of course roles, and naming the target
301    /// user does not widen it.
302    #[tokio::test]
303    async fn account_administration_needs_a_global_admin() {
304        insert_data!(:tx, :user, :org, :course);
305        let requirement = [ToolRequirement::on_user(
306            Action::AdministrateUserAccount,
307            user,
308        )];
309
310        let teacher = context(
311            Some(user),
312            Some(course),
313            vec![course_role(user, course, UserRole::Teacher)],
314        );
315        assert!(
316            !requirements_are_satisfied(tx.as_mut(), &teacher, &requirement)
317                .await
318                .expect("the check completes")
319        );
320
321        let admin = context(
322            Some(user),
323            Some(course),
324            vec![global_role(user, UserRole::Admin)],
325        );
326        assert!(
327            requirements_are_satisfied(tx.as_mut(), &admin, &requirement)
328                .await
329                .expect("the check completes")
330        );
331    }
332
333    /// The proof exists only for a caller the check would have let through, and the actor it names
334    /// is the caller that was checked.
335    #[tokio::test]
336    async fn only_an_authorized_caller_gets_a_tool_authorization() {
337        struct SomeTool;
338
339        insert_data!(:tx, :user, :org, :course);
340        let requirement = [ToolRequirement::global(Action::AdministrateUserAccount)];
341
342        let learner = context(Some(user), Some(course), Vec::new());
343        assert!(
344            authorize_tool_call::<SomeTool>(tx.as_mut(), &learner, &requirement)
345                .await
346                .expect("the check completes")
347                .is_none()
348        );
349
350        let anonymous = context(None, Some(course), Vec::new());
351        assert!(
352            authorize_tool_call::<SomeTool>(tx.as_mut(), &anonymous, &requirement)
353                .await
354                .expect("the check completes")
355                .is_none()
356        );
357
358        let admin = context(
359            Some(user),
360            Some(course),
361            vec![global_role(user, UserRole::Admin)],
362        );
363        let authorization = authorize_tool_call::<SomeTool>(tx.as_mut(), &admin, &requirement)
364            .await
365            .expect("the check completes")
366            .expect("an admin is authorized");
367        assert_eq!(authorization.acting_user_id(), user);
368    }
369}