Skip to content
Clone

Merge branch 'worktree-fix-c1-repo-authz'

zhenyi committed
parents 1588610 + ceb547e commit a437e2c Browse files

27 files changed

+655 −250

File tree 27
Modified dvcs/gitauth/permission.rs View dvcs/gitauth/permission.rs
@@ -1,75 +1,9 @@
1use db::conn::sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
2use db::repo::repo_collaborations;
3use db::space::{space_member_roles, space_members, space_role_permissions, spaces};
4use repo::actor::{ActorContext, RepoAccess, SpaceRole};
5use uuid::Uuid;
1use db::conn::sea_orm::ConnectionTrait;
2use repo::actor::{ActorContext, RepoAccess};
63
74use crate::error::{GitAuthError, GitAuthResult};
85use crate::resolve::ResolvedRepo;
96
10pub const SPACE_ADMIN_PERMISSION: &str = "manage_members";
11
12pub async fn space_role_for(
13 db: &impl ConnectionTrait,
14 space: &spaces::Model,
15 user_id: Uuid,
16) -> GitAuthResult<Option<SpaceRole>> {
17 if space.owner_id == user_id {
18 return Ok(Some(SpaceRole::Owner));
19 }
20 let Some(member) = space_members::Entity::find_by_id((space.id, user_id))
21 .one(db)
22 .await?
23 else {
24 return Ok(None);
25 };
26 if member.status != space_members::MemberStatus::Active {
27 return Ok(None);
28 }
29 let role_ids: Vec<Uuid> = space_member_roles::Entity::find()
30 .filter(space_member_roles::Column::SpaceId.eq(space.id))
31 .filter(space_member_roles::Column::UserId.eq(user_id))
32 .all(db)
33 .await?
34 .into_iter()
35 .map(|m| m.role_id)
36 .collect();
37 let is_admin = if role_ids.is_empty() {
38 false
39 } else {
40 let perms = space_role_permissions::Entity::find()
41 .filter(space_role_permissions::Column::RoleId.is_in(role_ids))
42 .all(db)
43 .await?;
44 perms.iter().any(|p| p.permission == SPACE_ADMIN_PERMISSION)
45 };
46 Ok(Some(if is_admin {
47 SpaceRole::Admin
48 } else {
49 SpaceRole::Member
50 }))
51}
52
53pub async fn collaborator_access(
54 db: &impl ConnectionTrait,
55 repo_id: Uuid,
56 user_id: Uuid,
57) -> GitAuthResult<Option<RepoAccess>> {
58 let Some(collab) = repo_collaborations::Entity::find()
59 .filter(repo_collaborations::Column::RepoId.eq(repo_id))
60 .filter(repo_collaborations::Column::UserId.eq(user_id))
61 .one(db)
62 .await?
63 else {
64 return Ok(None);
65 };
66 Ok(Some(match collab.mode {
67 repo_collaborations::CollaborationMode::Read => RepoAccess::Read,
68 repo_collaborations::CollaborationMode::Write => RepoAccess::Write,
69 repo_collaborations::CollaborationMode::Admin => RepoAccess::Admin,
70 }))
71}
72
737pub async fn check_access(
748 db: &impl ConnectionTrait,
759 actor: &ActorContext,
@@ -84,11 +18,12 @@
8418 let (space_role, collaborator) = match actor.user_id {
8519 Some(user_id) => {
8620 let space_role = if resolved.channel.space_id.is_some() {
87 space_role_for(db, &resolved.space, user_id).await?
21 repo::permission::space_role_for(db, &resolved.space, user_id).await?
8822 } else {
8923 None
9024 };
91 let collaborator = collaborator_access(db, resolved.repo.id, user_id).await?;
25 let collaborator =
26 repo::permission::collaborator_access(db, resolved.repo.id, user_id).await?;
9227 (space_role, collaborator)
9328 }
9429 None => (None, None),
@@ -118,4 +53,4 @@
11853 GitAuthError::Forbidden("access forbidden".into())
11954 }
12055 }
121}
56}
No newline at end of file
Modified lib/repo/features/branch/usecase.rs View lib/repo/features/branch/usecase.rs
@@ -3,8 +3,9 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::actor::RepoAccess;
67use crate::error::{RepoError, RepoResult};
7use crate::features::RepoService;
8use crate::features::{authz, RepoService};
89use crate::metrics;
910use crate::path::validate_branch_name;
1011
@@ -13,13 +14,6 @@
1314 MergeUpstreamResponse, RenameBranchOption, UpdateBranchOption,
1415};
1516
16fn require_authenticated(actor: Uuid) -> RepoResult<()> {
17 if actor.is_nil() {
18 return Err(RepoError::Unauthorized("authentication required".into()));
19 }
20 Ok(())
21}
22
2317async fn load_branch(
2418 svc: &RepoService,
2519 repo_id: Uuid,
@@ -101,7 +95,8 @@
10195) -> RepoResult<BranchView> {
10296 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCHES_CREATE);
10397 let result: RepoResult<BranchView> = async {
104 require_authenticated(actor)?;
98 let repo = authz::load_repo_by_id(svc, repo_id).await?;
99 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
105100 let new_name =
106101 validate_branch_name(&input.new_branch_name, svc.config.max_branch_name_len)?;
107102 let source_name = input
@@ -140,7 +135,8 @@
140135) -> RepoResult<BranchView> {
141136 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCHES_UPDATE);
142137 let result: RepoResult<BranchView> = async {
143 require_authenticated(actor)?;
138 let repo = authz::load_repo_by_id(svc, repo_id).await?;
139 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
144140 let branch = load_branch(svc, repo_id, name).await?;
145141 if let Some(ref old) = input.old_commit_id {
146142 if !old.is_empty() && branch.commit_sha != *old {
@@ -169,7 +165,8 @@
169165) -> RepoResult<()> {
170166 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCHES_DELETE);
171167 let result: RepoResult<()> = async {
172 require_authenticated(actor)?;
168 let repo = authz::load_repo_by_id(svc, repo_id).await?;
169 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
173170 let branch = load_branch(svc, repo_id, name).await?;
174171 if branch.is_protected {
175172 return Err(RepoError::Forbidden(format!("branch {name} is protected")));
@@ -197,7 +194,8 @@
197194) -> RepoResult<BranchView> {
198195 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCHES_RENAME);
199196 let result: RepoResult<BranchView> = async {
200 require_authenticated(actor)?;
197 let repo = authz::load_repo_by_id(svc, repo_id).await?;
198 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
201199 let new_name = validate_branch_name(&input.new_name, svc.config.max_branch_name_len)?;
202200 let branch = load_branch(svc, repo_id, old_name).await?;
203201 if branch.is_protected {
@@ -218,14 +216,15 @@
218216}
219217
220218pub async fn merge_upstream(
221 _svc: &RepoService,
219 svc: &RepoService,
222220 actor: Uuid,
223221 repo_id: Uuid,
224222 input: MergeUpstreamRequest,
225223) -> RepoResult<MergeUpstreamResponse> {
226224 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCHES_MERGE_UPSTREAM);
227225 let result: RepoResult<MergeUpstreamResponse> = async {
228 require_authenticated(actor)?;
226 let repo = authz::load_repo_by_id(svc, repo_id).await?;
227 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
229228 let _ = repo_id;
230229 let _ = input.branch;
231230 let merge_type = if input.ff_only.unwrap_or(false) {
Modified lib/repo/features/branch_protection/usecase.rs View lib/repo/features/branch_protection/usecase.rs
@@ -3,8 +3,9 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::actor::RepoAccess;
67use crate::error::{RepoError, RepoResult};
7use crate::features::RepoService;
8use crate::features::{authz, RepoService};
89use crate::metrics;
910use crate::path::validate_branch_name;
1011
@@ -13,13 +14,6 @@
1314 EditBranchProtectionOption, ListBranchProtectionQuery, UpdateBranchProtectionPriorities,
1415};
1516
16fn require_admin(actor: Uuid) -> RepoResult<()> {
17 if actor.is_nil() {
18 return Err(RepoError::Unauthorized("authentication required".into()));
19 }
20 Ok(())
21}
22
2317async fn load_policy(
2418 svc: &RepoService,
2519 repo_id: Uuid,
@@ -78,7 +72,8 @@
7872) -> RepoResult<BranchProtectionView> {
7973 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCH_PROTECTIONS_CREATE);
8074 let result: RepoResult<BranchProtectionView> = async {
81 require_admin(actor)?;
75 let repo = authz::load_repo_by_id(svc, repo_id).await?;
76 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
8277 let branch_name = validate_branch_name(&input.branch_name, svc.config.max_branch_name_len)?;
8378 let db = svc.db.writer();
8479 let now = OffsetDateTime::now_utc();
@@ -138,7 +133,8 @@
138133) -> RepoResult<BranchProtectionView> {
139134 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCH_PROTECTIONS_EDIT);
140135 let result: RepoResult<BranchProtectionView> = async {
141 require_admin(actor)?;
136 let repo = authz::load_repo_by_id(svc, repo_id).await?;
137 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
142138 let policy = load_policy(svc, repo_id, policy_id).await?;
143139 let db = svc.db.writer();
144140 let mut active: policy_entity::ActiveModel = policy.into();
@@ -198,7 +194,8 @@
198194) -> RepoResult<()> {
199195 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCH_PROTECTIONS_DELETE);
200196 let result: RepoResult<()> = async {
201 require_admin(actor)?;
197 let repo = authz::load_repo_by_id(svc, repo_id).await?;
198 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
202199 let _ = load_policy(svc, repo_id, policy_id).await?;
203200 let db = svc.db.writer();
204201 policy_entity::Entity::delete_by_id(policy_id)
@@ -219,7 +216,8 @@
219216) -> RepoResult<()> {
220217 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCH_PROTECTIONS_UPDATE_PRIORITIES);
221218 let result: RepoResult<()> = async {
222 require_admin(actor)?;
219 let repo = authz::load_repo_by_id(svc, repo_id).await?;
220 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
223221 if input.ids.len() as i64 > svc.config.branch_protection_priority_max {
224222 return Err(RepoError::BadRequest(format!(
225223 "too many priority entries (max {})",
Modified lib/repo/features/collab/usecase.rs View lib/repo/features/collab/usecase.rs
@@ -4,8 +4,9 @@
44use time::OffsetDateTime;
55use uuid::Uuid;
66
7use crate::actor::RepoAccess;
78use crate::error::{RepoError, RepoResult};
8use crate::features::RepoService;
9use crate::features::{authz, RepoService};
910use crate::metrics;
1011
1112use super::{
@@ -28,13 +29,6 @@
2829 CollaborationMode::Write => "write".to_string(),
2930 CollaborationMode::Admin => "admin".to_string(),
3031 }
31}
32
33fn require_admin(actor: Uuid) -> RepoResult<()> {
34 if actor.is_nil() {
35 return Err(RepoError::Unauthorized("authentication required".into()));
36 }
37 Ok(())
3832}
3933
4034pub async fn list_collaborators(
@@ -102,7 +96,8 @@
10296) -> RepoResult<()> {
10397 let _timer = metrics::Timer::start(metrics::FEATURE_COLLAB_ADD);
10498 let result: RepoResult<()> = async {
105 require_admin(actor)?;
99 let repo = authz::load_repo_by_id(svc, repo_id).await?;
100 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
106101 let mode = parse_mode(&input.permission)?;
107102 let db = svc.db.writer();
108103 let now = OffsetDateTime::now_utc();
@@ -142,7 +137,8 @@
142137) -> RepoResult<()> {
143138 let _timer = metrics::Timer::start(metrics::FEATURE_COLLAB_DELETE);
144139 let result: RepoResult<()> = async {
145 require_admin(actor)?;
140 let repo = authz::load_repo_by_id(svc, repo_id).await?;
141 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
146142 let db = svc.db.writer();
147143 collab_entity::Entity::delete_many()
148144 .filter(collab_entity::Column::RepoId.eq(repo_id))
Modified lib/repo/features/commit_status/impls.rs View lib/repo/features/commit_status/impls.rs
@@ -23,10 +23,8 @@
2323 repo_id: uuid::Uuid,
2424 sha: &str,
2525 input: CreateStatusOption,
26 ) -> super::types::CommitStatusView {
27 usecase::create_commit_status(self, actor, repo_id, sha, input)
28 .await
29 .expect("create_commit_status")
26 ) -> RepoResult<super::types::CommitStatusView> {
27 usecase::create_commit_status(self, actor, repo_id, sha, input).await
3028 }
3129
3230 pub async fn list_commit_statuses_by_ref(
Modified lib/repo/features/commit_status/usecase.rs View lib/repo/features/commit_status/usecase.rs
@@ -3,8 +3,9 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::actor::RepoAccess;
67use crate::error::{RepoError, RepoResult};
7use crate::features::RepoService;
8use crate::features::{authz, RepoService};
89use crate::metrics;
910
1011use super::types::{
@@ -12,13 +13,6 @@
1213 parse_state_pub,
1314};
1415
15fn require_authenticated(actor: Uuid) -> RepoResult<()> {
16 if actor.is_nil() {
17 return Err(RepoError::Unauthorized("authentication required".into()));
18 }
19 Ok(())
20}
21
2216fn aggregate_state(items: &[status_entity::Model]) -> &'static str {
2317 let mut has_failure = false;
2418 let mut has_error = false;
@@ -93,7 +87,8 @@
9387) -> RepoResult<CommitStatusView> {
9488 let _timer = metrics::Timer::start(metrics::FEATURE_COMMIT_STATUSES_CREATE);
9589 let result: RepoResult<CommitStatusView> = async {
96 require_authenticated(actor)?;
90 let repo = authz::load_repo_by_id(svc, repo_id).await?;
91 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
9792 let state = parse_state_pub(&input.state)?;
9893 let db = svc.db.writer();
9994 let now = OffsetDateTime::now_utc();
Modified lib/repo/features/contents/usecase.rs View lib/repo/features/contents/usecase.rs
@@ -1,7 +1,8 @@
11use uuid::Uuid;
22
3use crate::actor::RepoAccess;
34use crate::error::{RepoError, RepoResult};
4use crate::features::RepoService;
5use crate::features::{authz, RepoService};
56use crate::metrics;
67
78use super::{
@@ -9,10 +10,6 @@
910 ContentsResponse, CreateFileOption, DeleteFileOption, UpdateFileOption,
1011};
1112
12fn require_write(_actor: Uuid) -> RepoResult<()> {
13 Ok(())
14}
15
1613pub async fn list_contents(
1714 _svc: &RepoService,
1815 actor: Uuid,
@@ -54,15 +51,16 @@
5451}
5552
5653pub async fn create_file(
57 _svc: &RepoService,
54 svc: &RepoService,
5855 actor: Uuid,
59 _repo_id: Uuid,
56 repo_id: Uuid,
6057 path: &str,
6158 input: CreateFileOption,
6259) -> RepoResult<ContentsResponse> {
6360 let _timer = metrics::Timer::start(metrics::FEATURE_CONTENTS_CREATE_FILE);
6461 let result: RepoResult<ContentsResponse> = async {
65 require_write(actor)?;
62 let repo = authz::load_repo_by_id(svc, repo_id).await?;
63 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
6664 if path.is_empty() {
6765 return Err(RepoError::BadRequest("path is required".into()));
6866 }
@@ -86,15 +84,16 @@
8684}
8785
8886pub async fn update_file(
89 _svc: &RepoService,
87 svc: &RepoService,
9088 actor: Uuid,
91 _repo_id: Uuid,
89 repo_id: Uuid,
9290 path: &str,
9391 input: UpdateFileOption,
9492) -> RepoResult<ContentsResponse> {
9593 let _timer = metrics::Timer::start(metrics::FEATURE_CONTENTS_UPDATE_FILE);
9694 let result: RepoResult<ContentsResponse> = async {
97 require_write(actor)?;
95 let repo = authz::load_repo_by_id(svc, repo_id).await?;
96 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
9897 if path.is_empty() {
9998 return Err(RepoError::BadRequest("path is required".into()));
10099 }
@@ -113,15 +112,16 @@
113112}
114113
115114pub async fn delete_file(
116 _svc: &RepoService,
115 svc: &RepoService,
117116 actor: Uuid,
118 _repo_id: Uuid,
117 repo_id: Uuid,
119118 path: &str,
120119 input: DeleteFileOption,
121120) -> RepoResult<()> {
122121 let _timer = metrics::Timer::start(metrics::FEATURE_CONTENTS_DELETE_FILE);
123122 let result: RepoResult<()> = async {
124 require_write(actor)?;
123 let repo = authz::load_repo_by_id(svc, repo_id).await?;
124 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
125125 if path.is_empty() {
126126 return Err(RepoError::BadRequest("path is required".into()));
127127 }
@@ -140,14 +140,15 @@
140140}
141141
142142pub async fn change_files(
143 _svc: &RepoService,
143 svc: &RepoService,
144144 actor: Uuid,
145 _repo_id: Uuid,
145 repo_id: Uuid,
146146 input: ChangeFilesOptions,
147147) -> RepoResult<ContentsListResponse> {
148148 let _timer = metrics::Timer::start(metrics::FEATURE_CONTENTS_CHANGE_FILES);
149149 let result: RepoResult<ContentsListResponse> = async {
150 require_write(actor)?;
150 let repo = authz::load_repo_by_id(svc, repo_id).await?;
151 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
151152 if input.files.is_empty() {
152153 return Err(RepoError::BadRequest(
153154 "at least one file entry required".into(),
@@ -163,14 +164,15 @@
163164}
164165
165166pub async fn apply_diff_patch(
166 _svc: &RepoService,
167 svc: &RepoService,
167168 actor: Uuid,
168 _repo_id: Uuid,
169 repo_id: Uuid,
169170 input: ApplyDiffPatchFileOptions,
170171) -> RepoResult<ContentsResponse> {
171172 let _timer = metrics::Timer::start(metrics::FEATURE_CONTENTS_APPLY_DIFF_PATCH);
172173 let result: RepoResult<ContentsResponse> = async {
173 require_write(actor)?;
174 let repo = authz::load_repo_by_id(svc, repo_id).await?;
175 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
174176 if input.content.is_empty() {
175177 return Err(RepoError::BadRequest("patch content is required".into()));
176178 }
@@ -181,4 +183,4 @@
181183 .await;
182184 metrics::record_outcome(metrics::FEATURE_CONTENTS_APPLY_DIFF_PATCH, &result);
183185 result
184}
186}
No newline at end of file
Modified lib/repo/features/deploy_key/usecase.rs View lib/repo/features/deploy_key/usecase.rs
@@ -3,18 +3,12 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::actor::RepoAccess;
67use crate::error::{RepoError, RepoResult};
7use crate::features::RepoService;
8use crate::features::{authz, RepoService};
89use crate::metrics;
910
1011use super::{CreateDeployKeyOption, DeployKeyPage, DeployKeyView, ListDeployKeyQuery};
11
12fn require_admin(actor: Uuid) -> RepoResult<()> {
13 if actor.is_nil() {
14 return Err(RepoError::Unauthorized("authentication required".into()));
15 }
16 Ok(())
17}
1812
1913async fn load_key(svc: &RepoService, repo_id: Uuid, key_id: Uuid) -> RepoResult<key_entity::Model> {
2014 let db = svc.db.reader();
@@ -92,7 +86,8 @@
9286) -> RepoResult<DeployKeyView> {
9387 let _timer = metrics::Timer::start(metrics::FEATURE_DEPLOY_KEYS_CREATE);
9488 let result: RepoResult<DeployKeyView> = async {
95 require_admin(actor)?;
89 let repo = authz::load_repo_by_id(svc, repo_id).await?;
90 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
9691 if input.title.trim().is_empty() {
9792 return Err(RepoError::BadRequest("title is required".into()));
9893 }
@@ -129,7 +124,8 @@
129124) -> RepoResult<()> {
130125 let _timer = metrics::Timer::start(metrics::FEATURE_DEPLOY_KEYS_DELETE);
131126 let result: RepoResult<()> = async {
132 require_admin(actor)?;
127 let repo = authz::load_repo_by_id(svc, repo_id).await?;
128 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
133129 let _ = load_key(svc, repo_id, key_id).await?;
134130 let db = svc.db.writer();
135131 key_entity::Entity::delete_by_id(key_id).exec(&db).await?;
Modified lib/repo/features/extras/usecase.rs View lib/repo/features/extras/usecase.rs
@@ -2,8 +2,9 @@
22use time::OffsetDateTime;
33use uuid::Uuid;
44
5use crate::actor::RepoAccess;
56use crate::error::{RepoError, RepoResult};
6use crate::features::RepoService;
7use crate::features::{authz, RepoService};
78use crate::metrics;
89
910use super::*;
@@ -33,12 +34,11 @@
3334 result
3435}
3536
36pub async fn update_repo_avatar(_svc: &RepoService, actor: Uuid, _repo_id: Uuid) -> RepoResult<()> {
37pub async fn update_repo_avatar(svc: &RepoService, actor: Uuid, repo_id: Uuid) -> RepoResult<()> {
3738 let _timer = metrics::Timer::start(metrics::FEATURE_EXTRAS_UPDATE_REPO_AVATAR);
3839 let result: RepoResult<()> = async {
39 if actor.is_nil() {
40 return Err(RepoError::Unauthorized("authentication required".into()));
41 }
40 let repo = authz::load_repo_by_id(svc, repo_id).await?;
41 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
4242 Ok(())
4343 }
4444 .await;
@@ -46,12 +46,11 @@
4646 result
4747}
4848
49pub async fn delete_repo_avatar(_svc: &RepoService, actor: Uuid, _repo_id: Uuid) -> RepoResult<()> {
49pub async fn delete_repo_avatar(svc: &RepoService, actor: Uuid, repo_id: Uuid) -> RepoResult<()> {
5050 let _timer = metrics::Timer::start(metrics::FEATURE_EXTRAS_DELETE_REPO_AVATAR);
5151 let result: RepoResult<()> = async {
52 if actor.is_nil() {
53 return Err(RepoError::Unauthorized("authentication required".into()));
54 }
52 let repo = authz::load_repo_by_id(svc, repo_id).await?;
53 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
5554 Ok(())
5655 }
5756 .await;
@@ -375,15 +374,14 @@
375374}
376375
377376pub async fn watch_repo(
378 _svc: &RepoService,
377 svc: &RepoService,
379378 actor: Uuid,
380 _repo_id: Uuid,
379 repo_id: Uuid,
381380) -> RepoResult<Subscription> {
382381 let _timer = metrics::Timer::start(metrics::FEATURE_EXTRAS_WATCH_REPO);
383382 let result: RepoResult<Subscription> = async {
384 if actor.is_nil() {
385 return Err(RepoError::Unauthorized("authentication required".into()));
386 }
383 let repo = authz::load_repo_by_id(svc, repo_id).await?;
384 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
387385 let now = OffsetDateTime::now_utc()
388386 .format(&time::format_description::well_known::Rfc3339)
389387 .unwrap_or_default();
@@ -399,12 +397,11 @@
399397 result
400398}
401399
402pub async fn unwatch_repo(_svc: &RepoService, actor: Uuid, _repo_id: Uuid) -> RepoResult<()> {
400pub async fn unwatch_repo(svc: &RepoService, actor: Uuid, repo_id: Uuid) -> RepoResult<()> {
403401 let _timer = metrics::Timer::start(metrics::FEATURE_EXTRAS_UNWATCH_REPO);
404402 let result: RepoResult<()> = async {
405 if actor.is_nil() {
406 return Err(RepoError::Unauthorized("authentication required".into()));
407 }
403 let repo = authz::load_repo_by_id(svc, repo_id).await?;
404 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
408405 Ok(())
409406 }
410407 .await;
Modified lib/repo/features/lifecycle/usecase/archive.rs View lib/repo/features/lifecycle/usecase/archive.rs
@@ -7,8 +7,11 @@
77use crate::metrics;
88
99use super::super::{ArchiveRepoOption, RepoView};
10use super::helpers::{load_repo, require_authenticated};
10use super::helpers::load_repo;
1111
12use crate::actor::RepoAccess;
13use crate::features::authz;
14
1215pub async fn archive_repo(
1316 svc: &RepoService,
1417 actor: Uuid,
@@ -18,8 +21,8 @@
1821) -> RepoResult<RepoView> {
1922 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_ARCHIVE);
2023 let result: RepoResult<RepoView> = async {
21 require_authenticated(actor)?;
2224 let repo = load_repo(svc, channel_id, repo_id).await?;
25 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
2326 let db = svc.db.writer();
2427 let now = OffsetDateTime::now_utc();
2528 let mut active = db::repo::repos::ActiveModel::from(repo);
Modified lib/repo/features/lifecycle/usecase/delete.rs View lib/repo/features/lifecycle/usecase/delete.rs
@@ -6,8 +6,11 @@
66use crate::features::RepoService;
77use crate::metrics;
88
9use super::helpers::{load_repo, require_authenticated};
9use super::helpers::load_repo;
1010
11use crate::actor::RepoAccess;
12use crate::features::authz;
13
1114pub async fn delete_repo(
1215 svc: &RepoService,
1316 actor: Uuid,
@@ -16,8 +19,8 @@
1619) -> RepoResult<()> {
1720 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_DELETE);
1821 let result: RepoResult<()> = async {
19 require_authenticated(actor)?;
2022 let repo = load_repo(svc, channel_id, repo_id).await?;
23 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
2124 let db = svc.db.writer();
2225 let _ = repo;
2326 repo_entity::Entity::delete_by_id(repo_id).exec(&db).await?;
Modified lib/repo/features/lifecycle/usecase/fork.rs View lib/repo/features/lifecycle/usecase/fork.rs
@@ -3,13 +3,16 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::error::{RepoError, RepoResult};
6use crate::error::RepoResult;
77use crate::features::RepoService;
88use crate::metrics;
99use crate::path::validate_repo_name;
1010
1111use super::super::{ForkRepoOption, RepoView};
12use super::helpers::{load_repo, require_authenticated};
12use super::helpers::load_repo;
13
14use crate::actor::RepoAccess;
15use crate::features::authz;
1316
1417pub async fn fork_repo(
1518 svc: &RepoService,
@@ -20,8 +23,9 @@
2023) -> RepoResult<RepoView> {
2124 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_FORK);
2225 let result: RepoResult<RepoView> = async {
23 require_authenticated(actor)?;
24 let _ = load_repo(svc, channel_id, repo_id).await?;
26 let source = load_repo(svc, channel_id, repo_id).await?;
27 authz::authorize_repo(svc, actor, &source, RepoAccess::Read).await?;
28 authz::authorize_channel_resource(svc, actor, input.to_channel_id).await?;
2529 let new_name = match input.name {
2630 Some(n) => validate_repo_name(&n, svc.config.max_repo_name_len)?,
2731 None => format!("{}-fork", Uuid::now_v7()),
Modified lib/repo/features/lifecycle/usecase/generate.rs View lib/repo/features/lifecycle/usecase/generate.rs
@@ -9,8 +9,10 @@
99use crate::path::validate_repo_name;
1010
1111use super::super::{GenerateRepoOption, RepoView};
12use super::helpers::require_authenticated;
1312
13use crate::actor::RepoAccess;
14use crate::features::authz;
15
1416pub async fn generate_repo(
1517 svc: &RepoService,
1618 actor: Uuid,
@@ -19,11 +21,12 @@
1921) -> RepoResult<RepoView> {
2022 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_GENERATE);
2123 let result: RepoResult<RepoView> = async {
22 require_authenticated(actor)?;
2324 let source = repo_entity::Entity::find_by_id(repo_id)
2425 .one(&svc.db.reader())
2526 .await?
2627 .ok_or_else(|| RepoError::NotFound(format!("source repo {repo_id}")))?;
28 authz::authorize_repo(svc, actor, &source, RepoAccess::Read).await?;
29 authz::authorize_channel_resource(svc, actor, input.to_channel_id).await?;
2730 let new_name = validate_repo_name(&input.name, svc.config.max_repo_name_len)?;
2831 let db = svc.db.writer();
2932 let now = OffsetDateTime::now_utc();
Modified lib/repo/features/lifecycle/usecase/get.rs View lib/repo/features/lifecycle/usecase/get.rs
@@ -1,7 +1,8 @@
11use uuid::Uuid;
22
3use crate::actor::RepoAccess;
34use crate::error::RepoResult;
4use crate::features::RepoService;
5use crate::features::{authz, RepoService};
56use crate::metrics;
67
78use super::super::RepoView;
@@ -16,10 +17,12 @@
1617 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_GET);
1718 let result: RepoResult<RepoView> = async {
1819 let repo = load_repo(svc, channel_id, repo_id).await?;
19 if repo.is_private && actor.is_nil() {
20 return Err(crate::error::RepoError::Unauthorized(
21 "authentication required".into(),
22 ));
20 if repo.is_private {
21 // Private repos are readable only by actors allowed at least
22 // RepoAccess::Read (channel creator / space member / collaborator
23 // / site admin). Public repos stay readable by anyone, including
24 // the anonymous GraphQL path.
25 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
2326 }
2427 Ok(RepoView::from(repo))
2528 }
Modified lib/repo/features/lifecycle/usecase/list.rs View lib/repo/features/lifecycle/usecase/list.rs
@@ -3,7 +3,7 @@
33use uuid::Uuid;
44
55use crate::error::RepoResult;
6use crate::features::RepoService;
6use crate::features::{authz, RepoService};
77use crate::metrics;
88
99use super::super::{ListRepoQuery, RepoPage, RepoView};
@@ -11,7 +11,6 @@
1111pub async fn list_repos(svc: &RepoService, actor: Uuid, q: ListRepoQuery) -> RepoResult<RepoPage> {
1212 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_LIST);
1313 let result: RepoResult<RepoPage> = async {
14 let _ = actor;
1514 let limit = q.limit.unwrap_or(svc.config.default_pagination);
1615 let offset = q.offset.unwrap_or(0);
1716 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -31,6 +30,19 @@
3130 query = query.filter(repo_entity::Column::LowerName.contains(search));
3231 }
3332 }
33 // Visibility: repos that are public, repos inside channels the actor
34 // can reach, or repos where the actor holds a direct collaborator
35 // grant. Prevents repository enumeration for repositories the caller
36 // is not allowed to read (CWE-200 / CWE-639).
37 let (visible_channels, collab_repos) = authz::visible_repo_scope(svc, actor).await?;
38 let mut visible = Condition::any().add(repo_entity::Column::IsPrivate.eq(false));
39 if !visible_channels.is_empty() {
40 visible = visible.add(repo_entity::Column::ChannelId.is_in(visible_channels));
41 }
42 if !collab_repos.is_empty() {
43 visible = visible.add(repo_entity::Column::Id.is_in(collab_repos));
44 }
45 query = query.filter(visible);
3446 let total = query.clone().count(&db).await?;
3547 let items = query
3648 .order_by_desc(repo_entity::Column::CreatedAt)
Modified lib/repo/features/lifecycle/usecase/rename.rs View lib/repo/features/lifecycle/usecase/rename.rs
@@ -7,8 +7,11 @@
77use crate::path::validate_repo_name;
88
99use super::super::{RenameRepoOption, RepoView};
10use super::helpers::{load_repo, require_authenticated};
10use super::helpers::load_repo;
1111
12use crate::actor::RepoAccess;
13use crate::features::authz;
14
1215pub async fn rename_repo(
1316 svc: &RepoService,
1417 actor: Uuid,
@@ -18,8 +21,8 @@
1821) -> RepoResult<RepoView> {
1922 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_RENAME);
2023 let result: RepoResult<RepoView> = async {
21 require_authenticated(actor)?;
2224 let repo = load_repo(svc, channel_id, repo_id).await?;
25 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
2326 let new_name = validate_repo_name(&input.new_name, svc.config.max_repo_name_len)?;
2427 let db = svc.db.writer();
2528 let mut active = db::repo::repos::ActiveModel::from(repo);
Modified lib/repo/features/lifecycle/usecase/transfer.rs View lib/repo/features/lifecycle/usecase/transfer.rs
@@ -7,8 +7,11 @@
77use crate::path::validate_repo_name;
88
99use super::super::{RepoView, TransferRepoOption};
10use super::helpers::{load_repo, require_authenticated};
10use super::helpers::load_repo;
1111
12use crate::actor::RepoAccess;
13use crate::features::authz;
14
1215pub async fn transfer_repo(
1316 svc: &RepoService,
1417 actor: Uuid,
@@ -18,8 +21,8 @@
1821) -> RepoResult<RepoView> {
1922 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_TRANSFER);
2023 let result: RepoResult<RepoView> = async {
21 require_authenticated(actor)?;
2224 let repo = load_repo(svc, channel_id, repo_id).await?;
25 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
2326 let new_name = match input.new_name {
2427 Some(n) => validate_repo_name(&n, svc.config.max_repo_name_len)?,
2528 None => repo.lower_name.clone(),
Modified lib/repo/features/lifecycle/usecase/update.rs View lib/repo/features/lifecycle/usecase/update.rs
@@ -6,8 +6,11 @@
66use crate::metrics;
77
88use super::super::{RepoView, UpdateRepoOption};
9use super::helpers::{load_repo, require_authenticated};
9use super::helpers::load_repo;
1010
11use crate::actor::RepoAccess;
12use crate::features::authz;
13
1114pub async fn update_repo(
1215 svc: &RepoService,
1316 actor: Uuid,
@@ -17,8 +20,8 @@
1720) -> RepoResult<RepoView> {
1821 let _timer = metrics::Timer::start(metrics::FEATURE_REPOS_UPDATE);
1922 let result: RepoResult<RepoView> = async {
20 require_authenticated(actor)?;
2123 let repo = load_repo(svc, channel_id, repo_id).await?;
24 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
2225 let db = svc.db.writer();
2326 let mut active = db::repo::repos::ActiveModel::from(repo);
2427 if let Some(d) = input.description {
Modified lib/repo/features/release/usecase.rs View lib/repo/features/release/usecase.rs
@@ -3,20 +3,14 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::actor::RepoAccess;
67use crate::error::{RepoError, RepoResult};
7use crate::features::RepoService;
8use crate::features::{authz, RepoService};
89use crate::metrics;
910use crate::path::validate_tag_name;
1011
1112use super::{CreateReleaseOption, EditReleaseOption, ListReleaseQuery, ReleasePage, ReleaseView};
1213
13fn require_authenticated(actor: Uuid) -> RepoResult<()> {
14 if actor.is_nil() {
15 return Err(RepoError::Unauthorized("authentication required".into()));
16 }
17 Ok(())
18}
19
2014async fn load_release(
2115 svc: &RepoService,
2216 repo_id: Uuid,
@@ -150,7 +144,8 @@
150144) -> RepoResult<ReleaseView> {
151145 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_CREATE);
152146 let result: RepoResult<ReleaseView> = async {
153 require_authenticated(actor)?;
147 let repo = authz::load_repo_by_id(svc, repo_id).await?;
148 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
154149 let tag = validate_tag_name(&input.tag_name, svc.config.max_tag_name_len)?;
155150 if let Some(ref c) = input.content {
156151 if c.len() > svc.config.max_release_body_len {
@@ -198,7 +193,8 @@
198193) -> RepoResult<ReleaseView> {
199194 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_EDIT);
200195 let result: RepoResult<ReleaseView> = async {
201 require_authenticated(actor)?;
196 let repo = authz::load_repo_by_id(svc, repo_id).await?;
197 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
202198 let release = load_release(svc, repo_id, release_id).await?;
203199 let db = svc.db.writer();
204200 let mut active: release_entity::ActiveModel = release.into();
@@ -247,7 +243,8 @@
247243) -> RepoResult<()> {
248244 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_DELETE);
249245 let result: RepoResult<()> = async {
250 require_authenticated(actor)?;
246 let repo = authz::load_repo_by_id(svc, repo_id).await?;
247 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
251248 let _ = load_release(svc, repo_id, release_id).await?;
252249 let db = svc.db.writer();
253250 release_entity::Entity::delete_by_id(release_id)
@@ -268,7 +265,8 @@
268265) -> RepoResult<()> {
269266 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_DELETE_BY_TAG);
270267 let result: RepoResult<()> = async {
271 require_authenticated(actor)?;
268 let repo = authz::load_repo_by_id(svc, repo_id).await?;
269 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
272270 let db = svc.db.writer();
273271 release_entity::Entity::delete_many()
274272 .filter(release_entity::Column::RepoId.eq(repo_id))
Modified lib/repo/features/sync/usecase.rs View lib/repo/features/sync/usecase.rs
@@ -4,19 +4,13 @@
44use time::OffsetDateTime;
55use uuid::Uuid;
66
7use crate::actor::RepoAccess;
78use crate::error::{RepoError, RepoResult};
8use crate::features::RepoService;
9use crate::features::{authz, RepoService};
910use crate::metrics;
1011
1112use super::{SyncResult, SyncStatus};
1213
13fn require_authenticated(actor: Uuid) -> RepoResult<()> {
14 if actor.is_nil() {
15 return Err(RepoError::Unauthorized("authentication required".into()));
16 }
17 Ok(())
18}
19
2014pub async fn get_sync_status(
2115 svc: &RepoService,
2216 actor: Uuid,
@@ -67,7 +61,8 @@
6761) -> RepoResult<SyncResult> {
6862 let _timer = metrics::Timer::start(metrics::FEATURE_SYNC_REPO_METADATA);
6963 let result: RepoResult<SyncResult> = async {
70 require_authenticated(actor)?;
64 let repo = authz::load_repo_by_id(svc, repo_id).await?;
65 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
7166 let db = svc.db.reader();
7267 let branches_added = branch_entity::Entity::find()
7368 .filter(branch_entity::Column::RepoId.eq(repo_id))
Modified lib/repo/features/tag/usecase.rs View lib/repo/features/tag/usecase.rs
@@ -4,19 +4,13 @@
44use time::OffsetDateTime;
55use uuid::Uuid;
66
7use crate::actor::RepoAccess;
78use crate::error::{RepoError, RepoResult};
8use crate::features::RepoService;
9use crate::features::{authz, RepoService};
910use crate::metrics;
1011use crate::path::validate_tag_name;
1112
1213use super::{CreateTagOption, ListTagQuery, TagPage, TagView};
13
14fn require_authenticated(actor: Uuid) -> RepoResult<()> {
15 if actor.is_nil() {
16 return Err(RepoError::Unauthorized("authentication required".into()));
17 }
18 Ok(())
19}
2014
2115async fn load_tag(svc: &RepoService, repo_id: Uuid, name: &str) -> RepoResult<tag_entity::Model> {
2216 let db = svc.db.reader();
@@ -88,7 +82,8 @@
8882) -> RepoResult<TagView> {
8983 let _timer = metrics::Timer::start(metrics::FEATURE_TAGS_CREATE);
9084 let result: RepoResult<TagView> = async {
91 require_authenticated(actor)?;
85 let repo = authz::load_repo_by_id(svc, repo_id).await?;
86 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
9287 let name = validate_tag_name(&input.tag_name, svc.config.max_tag_name_len)?;
9388 let commit_sha = input.target.unwrap_or_else(|| "HEAD".to_string());
9489 let is_annotated = input.message.is_some();
@@ -125,7 +120,8 @@
125120) -> RepoResult<()> {
126121 let _timer = metrics::Timer::start(metrics::FEATURE_TAGS_DELETE);
127122 let result: RepoResult<()> = async {
128 require_authenticated(actor)?;
123 let repo = authz::load_repo_by_id(svc, repo_id).await?;
124 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
129125 let _ = load_tag(svc, repo_id, name).await?;
130126 let db = svc.db.writer();
131127 tag_entity::Entity::delete_many()
Modified lib/repo/features/tag_protection/usecase.rs View lib/repo/features/tag_protection/usecase.rs
@@ -3,21 +3,15 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::actor::RepoAccess;
67use crate::error::{RepoError, RepoResult};
7use crate::features::RepoService;
8use crate::features::{authz, RepoService};
89use crate::metrics;
910
1011use super::{
1112 CreateTagProtectionOption, EditTagProtectionOption, ListTagProtectionQuery, TagProtectionPage,
1213 TagProtectionView,
1314};
14
15fn require_admin(actor: Uuid) -> RepoResult<()> {
16 if actor.is_nil() {
17 return Err(RepoError::Unauthorized("authentication required".into()));
18 }
19 Ok(())
20}
2115
2216fn join_uuids(ids: &[Uuid]) -> String {
2317 let mut out = String::with_capacity(ids.len() * 36);
@@ -88,7 +82,8 @@
8882) -> RepoResult<TagProtectionView> {
8983 let _timer = metrics::Timer::start(metrics::FEATURE_TAG_PROTECTIONS_CREATE);
9084 let result: RepoResult<TagProtectionView> = async {
91 require_admin(actor)?;
85 let repo = authz::load_repo_by_id(svc, repo_id).await?;
86 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
9287 if input.name_pattern.trim().is_empty() {
9388 return Err(RepoError::BadRequest("name_pattern is required".into()));
9489 }
@@ -137,7 +132,8 @@
137132) -> RepoResult<TagProtectionView> {
138133 let _timer = metrics::Timer::start(metrics::FEATURE_TAG_PROTECTIONS_EDIT);
139134 let result: RepoResult<TagProtectionView> = async {
140 require_admin(actor)?;
135 let repo = authz::load_repo_by_id(svc, repo_id).await?;
136 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
141137 let policy = load_policy(svc, repo_id, policy_id).await?;
142138 let db = svc.db.writer();
143139 let mut active: policy_entity::ActiveModel = policy.into();
@@ -164,7 +160,8 @@
164160) -> RepoResult<()> {
165161 let _timer = metrics::Timer::start(metrics::FEATURE_TAG_PROTECTIONS_DELETE);
166162 let result: RepoResult<()> = async {
167 require_admin(actor)?;
163 let repo = authz::load_repo_by_id(svc, repo_id).await?;
164 authz::authorize_repo(svc, actor, &repo, RepoAccess::Admin).await?;
168165 let _ = load_policy(svc, repo_id, policy_id).await?;
169166 let db = svc.db.writer();
170167 policy_entity::Entity::delete_by_id(policy_id)
Modified lib/repo/features/topic/usecase.rs View lib/repo/features/topic/usecase.rs
@@ -3,19 +3,13 @@
33use time::OffsetDateTime;
44use uuid::Uuid;
55
6use crate::actor::RepoAccess;
67use crate::error::{RepoError, RepoResult};
7use crate::features::RepoService;
8use crate::features::{authz, RepoService};
89use crate::metrics;
910use crate::path::{normalize_topic, sanitize_topics};
1011
1112use super::{RepoTopicOptions, TopicList, TopicSearchQuery, TopicSearchResult, TopicView};
12
13fn require_authenticated(actor: Uuid) -> RepoResult<()> {
14 if actor.is_nil() {
15 return Err(RepoError::Unauthorized("authentication required".into()));
16 }
17 Ok(())
18}
1913
2014async fn ensure_topic(svc: &RepoService, topic_name: &str) -> RepoResult<topic_entity::Model> {
2115 let db = svc.db.writer();
@@ -75,7 +69,8 @@
7569) -> RepoResult<()> {
7670 let _timer = metrics::Timer::start(metrics::FEATURE_TOPICS_UPDATE);
7771 let result: RepoResult<()> = async {
78 require_authenticated(actor)?;
72 let repo = authz::load_repo_by_id(svc, repo_id).await?;
73 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
7974 let normalized = sanitize_topics(&input.topics, svc.config.max_topics_per_repo)?;
8075 let db = svc.db.writer();
8176 let existing = db::repo::repo_relation_topic::Entity::find()
@@ -127,7 +122,8 @@
127122) -> RepoResult<()> {
128123 let _timer = metrics::Timer::start(metrics::FEATURE_TOPICS_ADD);
129124 let result: RepoResult<()> = async {
130 require_authenticated(actor)?;
125 let repo = authz::load_repo_by_id(svc, repo_id).await?;
126 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
131127 let name = normalize_topic(topic)?;
132128 let current = db::repo::repo_relation_topic::Entity::find()
133129 .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id))
@@ -172,7 +168,8 @@
172168) -> RepoResult<()> {
173169 let _timer = metrics::Timer::start(metrics::FEATURE_TOPICS_DELETE);
174170 let result: RepoResult<()> = async {
175 require_authenticated(actor)?;
171 let repo = authz::load_repo_by_id(svc, repo_id).await?;
172 authz::authorize_repo(svc, actor, &repo, RepoAccess::Write).await?;
176173 let name = normalize_topic(topic)?;
177174 let db = svc.db.writer();
178175 let topic_model = topic_entity::Entity::find()
Added lib/repo/features/authz.rs View lib/repo/features/authz.rs
@@ -0,0 +1,282 @@
1use std::collections::HashSet;
2
3use db::channel::channels as channel_entity;
4use db::conn::sea_orm::*;
5use db::repo::repo_collaborations as collab_entity;
6use db::repo::repos as repo_entity;
7use db::space::space_member_roles as role_entity;
8use db::space::space_members as member_entity;
9use db::space::space_role_permissions as perm_entity;
10use db::space::spaces as space_entity;
11use db::user::users as user_entity;
12use uuid::Uuid;
13
14use crate::actor::{ActorContext, ChannelRole, RepoAccess};
15use crate::error::{RepoError, RepoResult};
16use crate::features::RepoService;
17use crate::permission;
18
19/// Resolve an authenticated actor id into an `ActorContext` with an empty
20/// scope set (so `permission::effective_access` reaches the role/channel
21/// branches; an actor built with `for_user()` short-circuits them to `Read`).
22/// `is_site_admin` comes from the live `users` row, not the JWT snapshot.
23pub async fn resolve_actor(svc: &RepoService, actor_id: Uuid) -> RepoResult<ActorContext> {
24 if actor_id.is_nil() {
25 return Err(RepoError::Unauthorized("authentication required".into()));
26 }
27 let db = svc.db.reader();
28 let user = user_entity::Entity::find_by_id(actor_id)
29 .one(&db)
30 .await?
31 .ok_or_else(|| RepoError::Unauthorized("account not found".into()))?;
32 if !user.is_active || user.prohibit_login {
33 return Err(RepoError::Unauthorized("account is not active".into()));
34 }
35 Ok(ActorContext {
36 user_id: Some(actor_id),
37 scopes: HashSet::new(),
38 is_site_admin: user.is_admin,
39 channel_role: None,
40 space_role: None,
41 })
42}
43
44/// Gate a repo operation at `required` access. The repo's channel is resolved
45/// from `repo.channel_id` and ownership is derived from the channel creator
46/// plus the space role and any direct collaborator grant.
47pub async fn authorize_repo(
48 svc: &RepoService,
49 actor_id: Uuid,
50 repo: &repo_entity::Model,
51 required: RepoAccess,
52) -> RepoResult<()> {
53 let actor = resolve_actor(svc, actor_id).await?;
54
55 let db = svc.db.reader();
56 let channel = channel_entity::Entity::find_by_id(repo.channel_id)
57 .one(&db)
58 .await?
59 .ok_or_else(|| RepoError::NotFound(format!("channel {}", repo.channel_id)))?;
60
61 let user_id = actor
62 .user_id
63 .expect("resolve_actor guarantees a user_id for authenticated actor");
64 let channel_role = (channel.created_by == user_id).then_some(ChannelRole::Owner);
65 let space_role = match channel.space_id {
66 Some(space_id) => {
67 let space = space_entity::Entity::find_by_id(space_id)
68 .one(&db)
69 .await?
70 .ok_or_else(|| RepoError::NotFound(format!("space {space_id}")))?;
71 permission::space_role_for(&db, &space, user_id).await?
72 }
73 None => None,
74 };
75 let collaborator = permission::collaborator_access(&db, repo.id, user_id).await?;
76
77 permission::require(
78 &actor,
79 required,
80 channel_role,
81 space_role,
82 collaborator,
83 !repo.is_private,
84 )
85 .map_err(map_permission_error)
86}
87
88/// Load a repo strictly by id, without the channel-containment check that the
89/// lifecycle helpers enforce (for features that receive only a `repo_id`).
90pub async fn load_repo_by_id(svc: &RepoService, repo_id: Uuid) -> RepoResult<repo_entity::Model> {
91 let db = svc.db.reader();
92 repo_entity::Entity::find_by_id(repo_id)
93 .one(&db)
94 .await?
95 .ok_or_else(|| RepoError::NotFound(format!("repo {repo_id}")))
96}
97
98/// Gate creating a resource (fork/generate target) inside `channel_id`: the
99/// actor must be the channel creator, a member of the channel's space, or the
100/// space owner. Blocks forking into an arbitrary channel the caller cannot
101/// host repositories in.
102pub async fn authorize_channel_resource(
103 svc: &RepoService,
104 actor_id: Uuid,
105 channel_id: Uuid,
106) -> RepoResult<()> {
107 let actor = resolve_actor(svc, actor_id).await?;
108 let db = svc.db.reader();
109
110 let channel = channel_entity::Entity::find_by_id(channel_id)
111 .one(&db)
112 .await?
113 .ok_or_else(|| RepoError::NotFound(format!("channel {channel_id}")))?;
114 if actor.is_site_admin || channel.created_by == actor_id {
115 return Ok(());
116 }
117 let Some(space_id) = channel.space_id else {
118 return Err(RepoError::Forbidden(
119 "cannot create repositories in this channel".into(),
120 ));
121 };
122 let space = space_entity::Entity::find_by_id(space_id)
123 .one(&db)
124 .await?
125 .ok_or_else(|| RepoError::NotFound(format!("space {space_id}")))?;
126 match permission::space_role_for(&db, &space, actor_id).await? {
127 // Owner / Admin / Member of the space may host a repo in its channels.
128 Some(_) => Ok(()),
129 None => Err(RepoError::Forbidden(
130 "not a member of the channel's space".into(),
131 )),
132 }
133}
134
135pub fn map_permission_error(e: permission::PermissionError) -> RepoError {
136 match e {
137 permission::PermissionError::Anonymous => {
138 RepoError::Unauthorized("authentication required".into())
139 }
140 permission::PermissionError::Insufficient => {
141 RepoError::Forbidden("insufficient permissions".into())
142 }
143 permission::PermissionError::Forbidden => {
144 RepoError::Forbidden("access forbidden".into())
145 }
146 }
147}
148
149/// Scope a repo listing to what `actor_id` may read. Returns the visible
150/// channel ids (channels created by the actor or belonging to spaces the actor
151/// belongs to) plus repo ids where the actor holds a direct collaborator
152/// grant. An empty result for a nil actor means "public repos only".
153pub async fn visible_repo_scope(
154 svc: &RepoService,
155 actor_id: Uuid,
156) -> RepoResult<(Vec<Uuid>, Vec<Uuid>)> {
157 let db = svc.db.reader();
158 if actor_id.is_nil() {
159 return Ok((Vec::new(), Vec::new()));
160 }
161
162 // Spaces the actor can reach: owned spaces, spaces where they hold an
163 // active membership, or spaces whose role grants `manage_members`.
164 let mut space_ids: Vec<Uuid> = Vec::new();
165 {
166 let owned: Vec<Uuid> = space_entity::Entity::find()
167 .filter(space_entity::Column::OwnerId.eq(actor_id))
168 .all(&db)
169 .await?
170 .into_iter()
171 .map(|s| s.id)
172 .collect();
173 let member: Vec<Uuid> = member_entity::Entity::find()
174 .filter(member_entity::Column::UserId.eq(actor_id))
175 .filter(member_entity::Column::Status.eq(member_entity::MemberStatus::Active))
176 .all(&db)
177 .await?
178 .into_iter()
179 .map(|m| m.space_id)
180 .collect();
181 let mut role_rows: Vec<Uuid> = Vec::new();
182 {
183 let roles: Vec<Uuid> = role_entity::Entity::find()
184 .filter(role_entity::Column::UserId.eq(actor_id))
185 .all(&db)
186 .await?
187 .into_iter()
188 .map(|m| m.role_id)
189 .collect();
190 if !roles.is_empty() {
191 let admin_rids: Vec<Uuid> = perm_entity::Entity::find()
192 .filter(perm_entity::Column::RoleId.is_in(roles.clone()))
193 .filter(perm_entity::Column::Permission.eq(permission::SPACE_ADMIN_PERMISSION))
194 .all(&db)
195 .await?
196 .into_iter()
197 .map(|p| p.role_id)
198 .collect();
199 if !admin_rids.is_empty() {
200 role_rows = role_entity::Entity::find()
201 .filter(role_entity::Column::RoleId.is_in(admin_rids))
202 .all(&db)
203 .await?
204 .into_iter()
205 .map(|m| m.space_id)
206 .collect();
207 }
208 }
209 }
210 space_ids.extend(owned);
211 space_ids.extend(member);
212 space_ids.extend(role_rows);
213 }
214 space_ids.sort_unstable();
215 space_ids.dedup();
216
217 // Channels the actor can reach: created by them, or bound to visible spaces.
218 let mut channel_ids: Vec<Uuid> = Vec::new();
219 {
220 let created: Vec<Uuid> = channel_entity::Entity::find()
221 .filter(channel_entity::Column::CreatedBy.eq(actor_id))
222 .all(&db)
223 .await?
224 .into_iter()
225 .map(|c| c.id)
226 .collect();
227 let via_spaces: Vec<Uuid> = if space_ids.is_empty() {
228 Vec::new()
229 } else {
230 channel_entity::Entity::find()
231 .filter(channel_entity::Column::SpaceId.is_in(space_ids))
232 .all(&db)
233 .await?
234 .into_iter()
235 .map(|c| c.id)
236 .collect()
237 };
238 channel_ids.extend(created);
239 channel_ids.extend(via_spaces);
240 }
241 channel_ids.sort_unstable();
242 channel_ids.dedup();
243
244 // Direct collaborator grants on specific repos.
245 let mut collab_repo_ids: Vec<Uuid> = Vec::new();
246 {
247 let rows: Vec<Uuid> = collab_entity::Entity::find()
248 .filter(collab_entity::Column::UserId.eq(actor_id))
249 .all(&db)
250 .await?
251 .into_iter()
252 .map(|r| r.repo_id)
253 .collect();
254 collab_repo_ids.extend(rows);
255 }
256 collab_repo_ids.sort_unstable();
257 collab_repo_ids.dedup();
258
259 Ok((channel_ids, collab_repo_ids))
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::permission::PermissionError;
266
267 #[test]
268 fn permission_error_mapping() {
269 assert!(matches!(
270 map_permission_error(PermissionError::Anonymous),
271 RepoError::Unauthorized(_)
272 ));
273 assert!(matches!(
274 map_permission_error(PermissionError::Insufficient),
275 RepoError::Forbidden(_)
276 ));
277 assert!(matches!(
278 map_permission_error(PermissionError::Forbidden),
279 RepoError::Forbidden(_)
280 ));
281 }
282}
No newline at end of file
Modified lib/repo/features/mod.rs View lib/repo/features/mod.rs
@@ -1,5 +1,6 @@
11pub use crate::service::RepoService;
22
3pub mod authz;
34pub mod archive;
45pub mod branch;
56pub mod branch_protection;
Modified lib/repo/handler/commit_status/create.rs View lib/repo/handler/commit_status/create.rs
@@ -24,6 +24,6 @@
2424) -> RepoResult<AppResponseData<CommitStatusView>> {
2525 let view = svc
2626 .create_commit_status(claims.sub, repo_id, &sha, input.into_inner())
27 .await;
27 .await?;
2828 Ok(AppResponseData::ok(view).with_code(201))
2929}
Modified lib/repo/permission.rs View lib/repo/permission.rs
@@ -1,5 +1,14 @@
1use db::conn::sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter};
2use db::repo::repo_collaborations;
3use db::space::{space_member_roles, space_members, space_role_permissions, spaces};
4use uuid::Uuid;
5
16use crate::actor::{ActorContext, ChannelRole, RepoAccess, SpaceRole, TokenScope};
27
8/// A space member holding a role that grants `manage_members` is treated as a
9/// space admin by `space_role_for` (mirrors `lib/space/features/authz`).
10pub const SPACE_ADMIN_PERMISSION: &str = "manage_members";
11
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
413pub enum PermissionError {
514 Anonymous,
@@ -26,6 +35,11 @@
2635 return RepoAccess::Admin;
2736 }
2837
38 // NOTE: an actor with any Repo* scope short-circuits to the scope-based
39 // ladder, so the role/channel branches below are only reachable by actors
40 // with an EMPTY scope set (the pattern dvcs/gitauth uses). HTTP-side
41 // callers must not build the actor with `for_user()` (which grants
42 // RepoRead) or the role branches are bypassed.
2943 if actor.has_scope(TokenScope::RepoAdmin) {
3044 return RepoAccess::Admin;
3145 }
@@ -45,10 +59,10 @@
4559 if matches!(space_role, Some(SpaceRole::Owner | SpaceRole::Admin)) {
4660 return RepoAccess::Admin;
4761 }
48 if matches!(
49 channel_role,
50 Some(ChannelRole::Owner | ChannelRole::Moderator)
51 ) {
62 if matches!(channel_role, Some(ChannelRole::Owner)) {
63 return RepoAccess::Admin;
64 }
65 if matches!(channel_role, Some(ChannelRole::Moderator)) {
5266 return RepoAccess::Write;
5367 }
5468 if let Some(mode) = collaborator_mode {
@@ -66,6 +80,72 @@
6680 RepoAccess::None
6781}
6882
83/// Resolve the actor's role within a space, if any. `None` for non-members;
84/// `Some(Owner)` when the user owns the space; otherwise `Member`/`Admin`
85/// based on active membership + role permissions. Only DB errors are produced.
86pub async fn space_role_for(
87 db: &impl ConnectionTrait,
88 space: &spaces::Model,
89 user_id: Uuid,
90) -> Result<Option<SpaceRole>, db::conn::SqlxError> {
91 if space.owner_id == user_id {
92 return Ok(Some(SpaceRole::Owner));
93 }
94 let Some(member) = space_members::Entity::find_by_id((space.id, user_id))
95 .one(db)
96 .await?
97 else {
98 return Ok(None);
99 };
100 if member.status != space_members::MemberStatus::Active {
101 return Ok(None);
102 }
103 let role_ids: Vec<Uuid> = space_member_roles::Entity::find()
104 .filter(space_member_roles::Column::SpaceId.eq(space.id))
105 .filter(space_member_roles::Column::UserId.eq(user_id))
106 .all(db)
107 .await?
108 .into_iter()
109 .map(|m| m.role_id)
110 .collect();
111 let is_admin = if role_ids.is_empty() {
112 false
113 } else {
114 let perms = space_role_permissions::Entity::find()
115 .filter(space_role_permissions::Column::RoleId.is_in(role_ids))
116 .all(db)
117 .await?;
118 perms.iter().any(|p| p.permission == SPACE_ADMIN_PERMISSION)
119 };
120 Ok(Some(if is_admin {
121 SpaceRole::Admin
122 } else {
123 SpaceRole::Member
124 }))
125}
126
127/// Direct per-user grant on a repo via `repo_collaborations`. `None` when the
128/// user has no collaboration row. Only DB errors are produced.
129pub async fn collaborator_access(
130 db: &impl ConnectionTrait,
131 repo_id: Uuid,
132 user_id: Uuid,
133) -> Result<Option<RepoAccess>, db::conn::SqlxError> {
134 let Some(collab) = repo_collaborations::Entity::find()
135 .filter(repo_collaborations::Column::RepoId.eq(repo_id))
136 .filter(repo_collaborations::Column::UserId.eq(user_id))
137 .one(db)
138 .await?
139 else {
140 return Ok(None);
141 };
142 Ok(Some(match collab.mode {
143 repo_collaborations::CollaborationMode::Read => RepoAccess::Read,
144 repo_collaborations::CollaborationMode::Write => RepoAccess::Write,
145 repo_collaborations::CollaborationMode::Admin => RepoAccess::Admin,
146 }))
147}
148
69149pub fn require(
70150 actor: &ActorContext,
71151 required: RepoAccess,
@@ -108,3 +188,109 @@
108188 }
109189 Err(PermissionError::Insufficient)
110190}
191
192#[cfg(test)]
193mod tests {
194 use std::collections::HashSet;
195
196 use uuid::Uuid;
197
198 use super::*;
199 use crate::actor::{ChannelRole, RepoAccess, SpaceRole};
200
201 fn actor(user_id: Option<Uuid>, is_site_admin: bool) -> ActorContext {
202 ActorContext {
203 user_id,
204 scopes: HashSet::new(),
205 is_site_admin,
206 channel_role: None,
207 space_role: None,
208 }
209 }
210
211 #[test]
212 fn site_admin_is_admin() {
213 let a = actor(Some(Uuid::now_v7()), true);
214 assert_eq!(effective_access(&a, None, None, None, false), RepoAccess::Admin);
215 }
216
217 #[test]
218 fn channel_owner_is_admin() {
219 let a = actor(Some(Uuid::now_v7()), false);
220 assert_eq!(
221 effective_access(&a, Some(ChannelRole::Owner), None, None, true),
222 RepoAccess::Admin
223 );
224 }
225
226 #[test]
227 fn channel_moderator_is_write() {
228 let a = actor(Some(Uuid::now_v7()), false);
229 assert_eq!(
230 effective_access(&a, Some(ChannelRole::Moderator), None, None, false),
231 RepoAccess::Write
232 );
233 }
234
235 #[test]
236 fn space_owner_and_admin_are_admin() {
237 let a = actor(Some(Uuid::now_v7()), false);
238 assert_eq!(
239 effective_access(&a, None, Some(SpaceRole::Owner), None, false),
240 RepoAccess::Admin
241 );
242 assert_eq!(
243 effective_access(&a, None, Some(SpaceRole::Admin), None, false),
244 RepoAccess::Admin
245 );
246 }
247
248 #[test]
249 fn space_member_is_read() {
250 let a = actor(Some(Uuid::now_v7()), false);
251 assert_eq!(
252 effective_access(&a, None, Some(SpaceRole::Member), None, false),
253 RepoAccess::Read
254 );
255 }
256
257 #[test]
258 fn public_repo_readable_for_authenticated_outsider() {
259 let a = actor(Some(Uuid::now_v7()), false);
260 assert_eq!(effective_access(&a, None, None, None, true), RepoAccess::Read);
261 }
262
263 #[test]
264 fn private_repo_none_for_outsider() {
265 let a = actor(Some(Uuid::now_v7()), false);
266 assert_eq!(effective_access(&a, None, None, None, false), RepoAccess::None);
267 }
268
269 #[test]
270 fn collaborator_modes_apply() {
271 let a = actor(Some(Uuid::now_v7()), false);
272 assert_eq!(
273 effective_access(&a, None, None, Some(RepoAccess::Write), false),
274 RepoAccess::Write
275 );
276 assert_eq!(
277 effective_access(&a, None, None, Some(RepoAccess::Read), false),
278 RepoAccess::Read
279 );
280 }
281
282 #[test]
283 fn require_write_gating() {
284 let owner = actor(Some(Uuid::now_v7()), false);
285 assert!(require(&owner, RepoAccess::Write, Some(ChannelRole::Owner), None, None, false).is_ok());
286
287 let moderator = actor(Some(Uuid::now_v7()), false);
288 assert!(require(&moderator, RepoAccess::Admin, Some(ChannelRole::Moderator), None, None, false).is_err());
289
290 let outsider = actor(Some(Uuid::now_v7()), false);
291 assert!(require(&outsider, RepoAccess::Write, None, None, None, true).is_err());
292
293 let anon = actor(None, false);
294 assert!(require(&anon, RepoAccess::Read, None, None, None, true).is_ok());
295 }
296}
No newline at end of file