1use 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#[derive(Debug, Clone, PartialEq)]
21pub enum ToolRequirement {
22 ActionOnResource(Action, Resource),
24 CourseMaterial(Uuid),
28}
29
30impl ToolRequirement {
31 pub fn on_course(action: Action, course_id: Uuid) -> Self {
33 Self::ActionOnResource(action, Resource::Course(course_id))
34 }
35
36 pub fn on_user(action: Action, user_id: Uuid) -> Self {
39 Self::ActionOnResource(action, Resource::User(user_id))
40 }
41
42 pub fn on_turn(action: Action, user_context: &ChatbotTurnContext) -> Self {
45 Self::ActionOnResource(action, user_context.turn_resource())
46 }
47
48 pub fn global(action: Action) -> Self {
50 Self::ActionOnResource(action, Resource::GlobalPermissions)
51 }
52}
53
54async 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
99pub 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
111pub struct ToolAuthorization<Tool> {
119 acting_user_id: Uuid,
120 _authorization: AuthorizationToken,
122 _tool: PhantomData<fn() -> Tool>,
123}
124
125impl<Tool> ToolAuthorization<Tool> {
126 pub fn acting_user_id(&self) -> Uuid {
129 self.acting_user_id
130 }
131}
132
133pub 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 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 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 #[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 #[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 #[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 #[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 #[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}