Skip to content

Commit e9145e8

Browse files
authored
feat: Check edit permissions (#48)
* chore(dev): Make S3 mock retain files on exit I just missed doing this in the PR which migrates to S3. :) * feat(frontend): Use daisyUI alert component Makes the `Alert` component use the daisyUI `alert` component. For some reason the code didn't use it previously. Switching since I think it's more pretty and looks more consistent. * feat: Check edit permissions Adds checks to the backend making sure that the user is allowed to edit content, and new UI to the frontend which informs the user if they're not allowed to edit a given slide group. * feat: Don't show edit buttons when not owner Hides any buttons for editing slide groups from the slide group options components when the user isn't allowed to edit it. Previously trying to interact with them under this condition would have just resulted in an error. This makes the behavior consistent with when a group is archived. I also moved the alert boxes to be above the slide group name, and made it so that the alert that the user can't edit the group isn't shown if the group is also archived.
1 parent 71b9921 commit e9145e8

18 files changed

Lines changed: 318 additions & 117 deletions

File tree

compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ services:
5151
environment:
5252
- COM_ADOBE_TESTING_S3MOCK_STORE_INITIAL_BUCKETS=meta-tv
5353
- COM_ADOBE_TESTING_S3MOCK_STORE_REGION=eu-west-1
54+
- COM_ADOBE_TESTING_S3MOCK_STORE_RETAIN_FILES_ON_EXIT=true
5455
ports:
5556
- 9090:9090
5657
nyckeln:

crates/backend/src/auth/mod.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use chrono::{DateTime, Utc};
2+
use common::dtos::{LangDto, UserInfoDto};
23
use oidc::OidcClient;
34
use rocket::{
45
http::{Cookie, CookieJar, SameSite, Status},
@@ -7,7 +8,7 @@ use rocket::{
78
};
89
use serde::{Deserialize, Serialize};
910

10-
use crate::error::AppError;
11+
use crate::{auth::hive::HiveClient, error::AppError};
1112

1213
pub mod hive;
1314
pub mod oidc;
@@ -23,6 +24,24 @@ pub struct Session {
2324
pub expiration: DateTime<Utc>,
2425
}
2526

27+
impl Session {
28+
pub async fn populate(&self, hive_client: &HiveClient) -> reqwest::Result<UserInfoDto> {
29+
let memberships = if self.is_admin {
30+
hive_client.tagged_groups(LangDto::Sv).await?
31+
} else {
32+
hive_client
33+
.tagged_memberships(&self.username, LangDto::Sv)
34+
.await?
35+
};
36+
37+
Ok(UserInfoDto {
38+
username: self.username.clone(),
39+
is_admin: self.is_admin,
40+
memberships,
41+
})
42+
}
43+
}
44+
2645
#[rocket::async_trait]
2746
impl<'r> FromRequest<'r> for Session {
2847
type Error = String;

crates/backend/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ use crate::auth::oidc::OidcAuthenticationError;
1616
pub enum AppError {
1717
#[error("you must login to perform this action")]
1818
Unauthenticated,
19+
#[error("you don't have permission to perform this action")]
20+
Unauthorized,
1921
#[error("uploaded file exceeds maximum allowed size (max is {0} bytes)")]
2022
FileTooBig(u64),
2123
#[error("screen not found")]
@@ -58,6 +60,7 @@ impl AppError {
5860
pub fn status(&self) -> Status {
5961
match self {
6062
AppError::Unauthenticated => Status::Unauthorized,
63+
AppError::Unauthorized => Status::Forbidden,
6164
AppError::FileTooBig(_) => Status::PayloadTooLarge,
6265
AppError::ScreenNotFound => Status::NotFound,
6366
AppError::SlideGroupNotFound => Status::NotFound,

crates/backend/src/lib.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,6 @@ pub(crate) fn rocket() -> Rocket<Build> {
138138
routes::auth::logout,
139139
routes::auth::oidc_callback,
140140
routes::auth::user_info,
141-
routes::auth::user_memberships,
142141
],
143142
)
144143
.register("/auth", catchers![routes::auth::not_logged_in])

crates/backend/src/routes/auth.rs

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use common::dtos::{SessionDto, TaggedGroupDto};
1+
use common::dtos::UserInfoDto;
22
use rocket::{
33
http::{uri::Host, CookieJar},
44
response::Redirect,
@@ -58,29 +58,27 @@ pub async fn logout(jar: &CookieJar<'_>) -> Redirect {
5858
Redirect::to("/")
5959
}
6060

61-
#[rocket::get("/user")]
62-
pub async fn user_info(session: Session) -> Json<SessionDto> {
63-
Json(SessionDto {
64-
username: session.username,
65-
is_admin: session.is_admin,
66-
})
67-
}
68-
69-
/// Get the groups which the logged in user is a part of.
70-
#[get("/user/memberships?<lang>")]
71-
pub async fn user_memberships(
61+
/// Get the username and groups of the logged in user, as well as if they're an admin.
62+
#[rocket::get("/user?<lang>")]
63+
pub async fn user_info(
7264
lang: Option<Lang>,
73-
session: auth::Session,
65+
session: Session,
7466
hive_client: &State<HiveClient>,
75-
) -> Result<Json<Vec<TaggedGroupDto>>, AppError> {
76-
Ok(Json(if session.is_admin {
67+
) -> Result<Json<UserInfoDto>, AppError> {
68+
let memberships = if session.is_admin {
7769
hive_client
7870
.tagged_groups(lang.unwrap_or_default().into())
7971
.await?
8072
} else {
8173
hive_client
8274
.tagged_memberships(&session.username, lang.unwrap_or_default().into())
8375
.await?
76+
};
77+
78+
Ok(Json(UserInfoDto {
79+
username: session.username,
80+
is_admin: session.is_admin,
81+
memberships,
8482
}))
8583
}
8684

@@ -91,7 +89,7 @@ pub async fn not_logged_in() -> AppError {
9189

9290
#[cfg(test)]
9391
mod tests {
94-
use common::dtos::SessionDto;
92+
use common::dtos::UserInfoDto;
9593
use rocket::http::Status;
9694

9795
use crate::test_utils::TestClient;
@@ -105,9 +103,10 @@ mod tests {
105103
assert_eq!(response.status(), Status::Ok);
106104
assert_eq!(
107105
response.into_json(),
108-
Some(SessionDto {
106+
Some(UserInfoDto {
109107
username: "johndoe".to_string(),
110-
is_admin: false
108+
is_admin: false,
109+
memberships: Vec::new(),
111110
})
112111
);
113112
}
@@ -121,9 +120,10 @@ mod tests {
121120
assert_eq!(response.status(), Status::Ok);
122121
assert_eq!(
123122
response.into_json(),
124-
Some(SessionDto {
123+
Some(UserInfoDto {
125124
username: "janedoe".to_string(),
126-
is_admin: true
125+
is_admin: true,
126+
memberships: Vec::new(),
127127
})
128128
);
129129
}

crates/backend/src/routes/content.rs

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ use sea_orm::{
66
};
77
use sea_orm_rocket::Connection;
88

9-
use crate::{auth::Session, error::AppError, files::Files, pool::Db};
9+
use crate::{
10+
auth::{hive::HiveClient, Session},
11+
error::AppError,
12+
files::Files,
13+
pool::Db,
14+
routes::slide_group,
15+
};
1016

1117
use super::{build_created_response, CreatedResponse};
1218

@@ -18,7 +24,8 @@ pub(crate) struct Upload<'r> {
1824

1925
#[post("/content", data = "<upload>")]
2026
pub async fn create_content(
21-
_session: Session, // for access control only
27+
session: Session,
28+
hive_client: &State<HiveClient>,
2229
conn: Connection<'_, Db>,
2330
files: &State<Files>,
2431
mut upload: Form<Upload<'_>>,
@@ -32,17 +39,27 @@ pub async fn create_content(
3239
.await?
3340
.ok_or_else(|| AppError::ScreenNotFound)?;
3441

35-
// ensure slide exists and is not archived (deleted)
36-
entity::slide::Entity::find_by_id(upload.data.slide)
42+
// ensure slide exists and is not archived (deleted), and that the user owns the associated
43+
// slide group.
44+
let slide_group_id = entity::slide::Entity::find_by_id(upload.data.slide)
3745
.join(
3846
JoinType::LeftJoin,
3947
entity::slide::Relation::SlideGroup.def(),
4048
)
4149
.filter(entity::slide::Column::ArchiveDate.is_null())
4250
.filter(entity::slide_group::Column::ArchiveDate.is_null())
51+
.select_only()
52+
.column(entity::slide::Column::Group)
53+
.into_tuple::<i32>()
4354
.one(&txn)
4455
.await?
4556
.ok_or_else(|| AppError::SlideNotFound)?;
57+
slide_group::check_slide_group_ownership(
58+
&session.populate(hive_client).await?,
59+
&txn,
60+
slide_group_id,
61+
)
62+
.await?;
4663

4764
// archive (delete) content already on this screen (if any)
4865
entity::content::Entity::update_many()

crates/backend/src/routes/slide.rs

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,68 @@
11
use common::dtos::{CreateSlideDto, MoveSlidesDto};
2-
use rocket::{http::Status, serde::json::Json};
2+
use rocket::{http::Status, serde::json::Json, State};
33
use sea_orm::{ActiveModelTrait, EntityTrait, Set, TransactionTrait};
44
use sea_orm_rocket::Connection;
55

6-
use crate::{auth::Session, error::AppError, pool::Db};
6+
use crate::{
7+
auth::{hive::HiveClient, Session},
8+
error::AppError,
9+
pool::Db,
10+
routes::slide_group,
11+
};
712

813
use super::{build_created_response, CreatedResponse};
914

1015
#[post("/slide", data = "<slide>")]
1116
pub async fn create_slide(
12-
_session: Session, // for access control only
17+
session: Session,
18+
hive_client: &State<HiveClient>,
1319
conn: Connection<'_, Db>,
1420
slide: Json<CreateSlideDto>,
1521
) -> Result<CreatedResponse, AppError> {
1622
let db = conn.into_inner();
23+
let txn = db.begin().await?;
24+
25+
slide_group::check_slide_group_ownership(
26+
&session.populate(hive_client).await?,
27+
&txn,
28+
slide.slide_group,
29+
)
30+
.await?;
1731

1832
let res = entity::slide::ActiveModel {
1933
position: Set(slide.position),
2034
group: Set(slide.slide_group),
2135
..Default::default()
2236
}
23-
.insert(db)
37+
.insert(&txn)
2438
.await?;
2539

40+
txn.commit().await?;
41+
2642
// NOTE: non-existent route
2743
Ok(build_created_response("/api/slide", res.id))
2844
}
2945

3046
#[post("/slide/bulk-move", data = "<positions>")]
3147
pub async fn bulk_move_slides(
32-
_session: Session, // for access control only
48+
session: Session,
49+
hive_client: &State<HiveClient>,
3350
conn: Connection<'_, Db>,
3451
positions: Json<MoveSlidesDto>,
3552
) -> Result<Status, AppError> {
3653
let db = conn.into_inner();
3754
let txn = db.begin().await?;
3855

56+
let user_info = session.populate(hive_client).await?;
57+
3958
for (&slide_id, &new_position) in &positions.new_positions {
4059
let slide = entity::slide::Entity::find_by_id(slide_id)
4160
.one(&txn)
4261
.await?
4362
.ok_or(AppError::SlideNotFound)?;
4463

64+
slide_group::check_slide_group_ownership(&user_info, &txn, slide.group).await?;
65+
4566
if slide.archive_date.is_some() {
4667
return Err(AppError::SlideArchived);
4768
}
@@ -64,9 +85,10 @@ pub async fn bulk_move_slides(
6485

6586
#[delete("/slide/<id>")]
6687
pub async fn delete_slide(
67-
_session: Session,
68-
id: i32,
88+
session: Session,
89+
hive_client: &State<HiveClient>,
6990
conn: Connection<'_, Db>,
91+
id: i32,
7092
) -> Result<Status, AppError> {
7193
let db = conn.into_inner();
7294
let txn = db.begin().await?;
@@ -78,6 +100,13 @@ pub async fn delete_slide(
78100
.await?
79101
.ok_or(AppError::SlideNotFound)?;
80102

103+
slide_group::check_slide_group_ownership(
104+
&session.populate(hive_client).await?,
105+
&txn,
106+
slide.group,
107+
)
108+
.await?;
109+
81110
if slide.archive_date.is_some() {
82111
return Err(AppError::SlideArchived);
83112
}

0 commit comments

Comments
 (0)