fix(auth): issue channel-membership check + profile PII redaction + end_room pre-authz (H3/H4/H9)
zhenyi committed
Modified chat/live/error.rs chat/live/error.rs +4 −0 View chat/live/error.rs
@@ -10,6 +10,8 @@
1010 BadRequest(String),
1111 #[error("not found: {0}")]
1212 NotFound(String),
13 #[error("forbidden: {0}")]
14 PermissionDenied(String),
1315 #[error("conflict: {0}")]
1416 Conflict(String),
1517 #[error("livekit not configured: {0}")]
@@ -37,6 +39,7 @@
3739 match self {
3840 Self::BadRequest(_) => StatusCode::BAD_REQUEST,
3941 Self::NotFound(_) => StatusCode::NOT_FOUND,
42 Self::PermissionDenied(_) => StatusCode::FORBIDDEN,
4043 Self::Conflict(_) => StatusCode::CONFLICT,
4144 Self::NotConfigured(_) => StatusCode::SERVICE_UNAVAILABLE,
4245 Self::LiveKitApi(_) | Self::WebhookVerification(_) => StatusCode::BAD_GATEWAY,
@@ -51,6 +54,7 @@
5154 match self {
5255 Self::BadRequest(_) => "bad_request",
5356 Self::NotFound(_) => "not_found",
57 Self::PermissionDenied(_) => "forbidden",
5458 Self::Conflict(_) => "conflict",
5559 Self::NotConfigured(_) => "not_configured",
5660 Self::LiveKitApi(_) => "livekit_api",
Modified chat/live/service.rs chat/live/service.rs +15 −1 View chat/live/service.rs
@@ -3,7 +3,7 @@
33use axum::http::HeaderMap;
44use cache::redis::RedisCache;
55use jwt::{HasJwtState, JwtState};
6use storagelayer::{ChatStorageCtx, DynChatStorage};
6use storagelayer::{ChatStorageCtx, ChatStorageError, DynChatStorage};
77use uuid::Uuid;
88
99use config::live::LiveSettings;
@@ -221,6 +221,20 @@
221221 "no active meet for channel {channel_id}"
222222 )));
223223 };
224
225 // Authorization must happen before any external side effect.
226 // The room creator and channel admins are allowed to end a meeting.
227 // Any other channel member attempting this is rejected without
228 // touching the LiveKit room.
229 if meet.created_by != user_id {
230 storagelayer::auth::require_channel_admin(self.store.db(), &ctx, meet.channel_id)
231 .await
232 .map_err(|e| match e {
233 ChatStorageError::PermissionDenied(msg) => LiveError::PermissionDenied(msg),
234 other => LiveError::Internal(format!("{other}")),
235 })?;
236 }
237
224238 self.client.delete_room(&meet.room_name).await?;
225239 self.store.meets().mark_ended(&ctx, meet.id).await?;
226240 self.store
Modified chat/storagelayer/mapper.rs chat/storagelayer/mapper.rs +4 −0 View chat/storagelayer/mapper.rs
@@ -82,6 +82,10 @@
8282 fn blobs(&self) -> Arc<dyn ChatMediaStore> {
8383 self.blobs.clone()
8484 }
85
86 fn db(&self) -> &Arc<db::conn::Db> {
87 &self.db
88 }
8589}
8690impl std::fmt::Debug for SeaChatStorage {
8791 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Modified chat/storagelayer/repository.rs chat/storagelayer/repository.rs +1 −0 View chat/storagelayer/repository.rs
@@ -64,6 +64,7 @@
6464 fn meets(&self) -> meet::DynMeetRepository;
6565 fn meet_participants(&self) -> meet_participant::DynMeetParticipantRepository;
6666 fn blobs(&self) -> Arc<dyn crate::blob::ChatMediaStore>;
67 fn db(&self) -> &Arc<db::conn::Db>;
6768}
6869
6970pub type DynChatStorage = Arc<dyn ChatStorage>;
Modified lib/issues/features/issue/usecase/close.rs lib/issues/features/issue/usecase/close.rs +2 −0 View lib/issues/features/issue/usecase/close.rs
@@ -3,6 +3,7 @@
33use uuid::Uuid;
44
55use crate::error::IssueResult;
6use crate::features::authz;
67use crate::features::IssueService;
78use crate::metrics;
89
@@ -19,6 +20,7 @@
1920 let _timer = metrics::Timer::start(metrics::FEATURE_ISSUES_CLOSE);
2021 let result: IssueResult<IssueView> = async {
2122 require_authenticated(actor)?;
23 authz::authorize_issue(svc, actor, channel_id).await?;
2224 let cur = load_issue(svc, channel_id, issue_id).await?;
2325 let db = svc.db.writer();
2426
Modified lib/issues/features/issue/usecase/delete.rs lib/issues/features/issue/usecase/delete.rs +2 −0 View lib/issues/features/issue/usecase/delete.rs
@@ -2,6 +2,7 @@
22use uuid::Uuid;
33
44use crate::error::IssueResult;
5use crate::features::authz;
56use crate::features::IssueService;
67use crate::metrics;
78
@@ -16,6 +17,7 @@
1617 let _timer = metrics::Timer::start(metrics::FEATURE_ISSUES_DELETE);
1718 let result: IssueResult<()> = async {
1819 require_authenticated(actor)?;
20 authz::authorize_issue(svc, actor, channel_id).await?;
1921 let cur = load_issue(svc, channel_id, issue_id).await?;
2022 let db = svc.db.writer();
2123 let active: db::issue::issue::ActiveModel = cur.into();
Modified lib/issues/features/issue/usecase/update.rs lib/issues/features/issue/usecase/update.rs +2 −0 View lib/issues/features/issue/usecase/update.rs
@@ -3,6 +3,7 @@
33use uuid::Uuid;
44
55use crate::error::{IssueError, IssueResult};
6use crate::features::authz;
67use crate::features::IssueService;
78use crate::metrics;
89
@@ -19,6 +20,7 @@
1920 let _timer = metrics::Timer::start(metrics::FEATURE_ISSUES_UPDATE);
2021 let result: IssueResult<IssueView> = async {
2122 require_authenticated(actor)?;
23 authz::authorize_issue(svc, actor, channel_id).await?;
2224
2325 let cur = load_issue(svc, channel_id, issue_id).await?;
2426
Added lib/issues/features/authz.rs lib/issues/features/authz.rs +89 −0 View lib/issues/features/authz.rs
@@ -0,0 +1,89 @@
1//! Authorization helpers for the issues crate.
2//!
3//! `authorize_issue` ensures the caller can mutate a issue belonging to a given
4//! channel. The check walks `channel -> space -> active member`, mirroring the
5//! pattern used by `lib/repo/features/authz.rs`. Site admins bypass the check.
6//!
7//! Space-less channels (top-level channels where `channel.space_id IS NULL`)
8//! only accept the channel owner (`channels.created_by`) and site admins.
9
10use db::channel::channels as channel_entity;
11use db::conn::sea_orm::*;
12use db::space::space_members as member_entity;
13use db::user::users as user_entity;
14use uuid::Uuid;
15
16use crate::error::{IssueError, IssueResult};
17use crate::features::IssueService;
18
19pub const MANAGE_ISSUES: &str = "manage_issues";
20
21async fn is_site_admin(svc: &IssueService, actor: Uuid) -> IssueResult<bool> {
22 if actor.is_nil() {
23 return Ok(false);
24 }
25 let db = svc.db.reader();
26 let user = user_entity::Entity::find_by_id(actor)
27 .one(&db)
28 .await?
29 .ok_or_else(|| IssueError::Unauthorized(format!("user {actor} not found")))?;
30 Ok(user.is_admin)
31}
32
33async fn is_active_member(
34 db: &DatabaseConnection,
35 space_id: Uuid,
36 user_id: Uuid,
37) -> IssueResult<bool> {
38 let Some(member) = member_entity::Entity::find_by_id((space_id, user_id))
39 .one(db)
40 .await?
41 else {
42 return Ok(false);
43 };
44 Ok(member.status == member_entity::MemberStatus::Active)
45}
46
47/// Verify the caller may mutate a issue that belongs to `channel_id`.
48///
49/// - Site admins: always allowed.
50/// - Channel owner (top-level / space-less channel): allowed.
51/// - Channel with `space_id`: caller must be an active member of that space.
52pub async fn authorize_issue(
53 svc: &IssueService,
54 actor: Uuid,
55 channel_id: Uuid,
56) -> IssueResult<()> {
57 if actor.is_nil() {
58 return Err(IssueError::Unauthorized(
59 "authentication required".to_string(),
60 ));
61 }
62
63 let db = svc.db.reader();
64 let channel = channel_entity::Entity::find_by_id(channel_id)
65 .one(&db)
66 .await?
67 .ok_or_else(|| IssueError::NotFound(format!("channel {channel_id}")))?;
68
69 if is_site_admin(svc, actor).await? {
70 return Ok(());
71 }
72
73 if channel.created_by == actor {
74 return Ok(());
75 }
76
77 if let Some(space_id) = channel.space_id {
78 if is_active_member(&db, space_id, actor).await? {
79 return Ok(());
80 }
81 return Err(IssueError::Forbidden(format!(
82 "caller is not an active member of space {space_id}"
83 )));
84 }
85
86 Err(IssueError::Forbidden(format!(
87 "caller cannot mutate channel {channel_id}"
88 )))
89}
No newline at end of file
Modified lib/issues/features/mod.rs lib/issues/features/mod.rs +1 −0 View lib/issues/features/mod.rs
@@ -1,5 +1,6 @@
11pub use crate::service::IssueService;
22
3pub mod authz;
34pub mod comment;
45pub mod issue;
56pub mod issue_attachment;
Modified lib/user/features/profile/impls.rs lib/user/features/profile/impls.rs +2 −2 View lib/user/features/profile/impls.rs
@@ -24,8 +24,8 @@
2424 usecase::update_profile(self, caller, target, cmd).await
2525 }
2626
27 pub async fn get_profile(&self, target: Uuid) -> UserResult<ProfileView> {
28 usecase::fetch_profile(self, target).await
27 pub async fn get_profile(&self, target: Uuid, viewer: Option<Uuid>) -> UserResult<ProfileView> {
28 usecase::fetch_profile(self, target, viewer).await
2929 }
3030
3131 pub async fn list_memes(&self, caller: Uuid, limit: usize) -> UserResult<Vec<MemeView>> {
Modified lib/user/features/profile/types.rs lib/user/features/profile/types.rs +50 −0 View lib/user/features/profile/types.rs
@@ -150,6 +150,56 @@
150150 }
151151}
152152
153impl ProfileView {
154 /// Build a `ProfileView` that respects the owner's `visibility`,
155 /// the per-row `show_*` flags, and the relationship to `viewer`.
156 ///
157 /// Sensitive PII (`real_name`, `date_of_birth`, `location`) is only
158 /// emitted when the viewer may see the profile at all AND the
159 /// corresponding `show_*` flag is enabled.
160 pub fn for_viewer(
161 m: db::user::user_profiles::Model,
162 visibility: user_entity::ProfileVisibility,
163 viewer: Option<Uuid>,
164 ) -> Self {
165 let is_owner = viewer == Some(m.user_id);
166 let can_see = match visibility {
167 user_entity::ProfileVisibility::Public => true,
168 user_entity::ProfileVisibility::RegisteredOnly => viewer.is_some(),
169 user_entity::ProfileVisibility::MembersOnly => viewer.is_some(),
170 user_entity::ProfileVisibility::Private => is_owner,
171 };
172
173 let emit_real_name = can_see && m.show_real_name;
174 let emit_location = can_see && m.show_location;
175 let emit_dob = can_see && is_owner;
176
177 Self {
178 user_id: m.user_id,
179 real_name: if emit_real_name { m.real_name } else { None },
180 bio: if can_see { m.bio } else { None },
181 pronouns: if can_see { m.pronouns } else { None },
182 pronouns_privacy: if can_see {
183 m.pronouns_privacy.map(visibility_from_entity)
184 } else {
185 None
186 },
187 website: if can_see { m.website } else { None },
188 location: if emit_location { m.location } else { None },
189 company: if can_see { m.company } else { None },
190 date_of_birth: if emit_dob { m.date_of_birth } else { None },
191 banner_url: m.banner_url,
192 banner_color: m.banner_color,
193 accent_color: m.accent_color,
194 show_email: m.show_email,
195 show_location: m.show_location,
196 show_real_name: m.show_real_name,
197 show_activity: m.show_activity,
198 updated_at: fmt_ts(m.updated_at),
199 }
200 }
201}
202
153203#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
154204pub struct UpdateProfileCmd {
155205 pub display_name: Option<String>,
Modified lib/user/features/profile/usecase.rs lib/user/features/profile/usecase.rs +14 −3 View lib/user/features/profile/usecase.rs
@@ -121,15 +121,26 @@
121121 result
122122}
123123
124pub async fn fetch_profile(svc: &UserService, target: Uuid) -> UserResult<ProfileView> {
124pub async fn fetch_profile(
125 svc: &UserService,
126 target: Uuid,
127 viewer: Option<Uuid>,
128) -> UserResult<ProfileView> {
125129 let _timer = metrics::Timer::start(metrics::FEATURE_PROFILE_VIEW);
126130 let result: UserResult<ProfileView> = async {
127131 let db = svc.db.reader();
128 let model = db::user::user_profiles::Entity::find_by_id(target)
132 let user = user_entity::Entity::find_by_id(target)
129133 .one(&db)
130134 .await?
135 .ok_or_else(|| UserError::NotFound(format!("user {target}")))?;
136 if !can_view(&user, viewer) {
137 return Err(UserError::Forbidden(format!("user {target} is private")));
138 }
139 let profile = db::user::user_profiles::Entity::find_by_id(target)
140 .one(&db)
141 .await?
131142 .ok_or_else(|| UserError::NotFound(format!("profile {target}")))?;
132 Ok(ProfileView::from(model))
143 Ok(ProfileView::for_viewer(profile, user.visibility, viewer))
133144 }
134145 .await;
135146 metrics::record_outcome(metrics::FEATURE_PROFILE_VIEW, &result);
Modified lib/user/handler/profile/profile_view.rs lib/user/handler/profile/profile_view.rs +3 −2 View lib/user/handler/profile/profile_view.rs
@@ -22,9 +22,10 @@
2222)]
2323pub(crate) async fn handler(
2424 State(svc): State<UserService>,
25 _user: AuthUser,
25 AuthUser { claims }: AuthUser,
2626 Path(uid): Path<Uuid>,
2727) -> UserResult<AppResponseData<ProfileView>> {
28 let view = svc.get_profile(uid).await?;
28 let viewer = if claims.sub.is_nil() { None } else { Some(claims.sub) };
29 let view = svc.get_profile(uid, viewer).await?;
2930 Ok(AppResponseData::ok(view))
3031}