Skip to content
Clone

Merge H1-H9 security fixes from worktree-h5-deny-list

Fixes CRITICAL×H3 + HIGH×H9 findings from cybersecurity.md:
- H1: already in ceb547e (no action)
- H2: 16 read-side repo usecases now enforce per-repo Read authz
- H3: issue update/delete/close require channel membership
- H4: profile PII redaction (real_name/location/dob) by visibility+show_*
- H5: JWT deny-list wired into repo/issues/chat; SSH gate on is_restricted;
       suspension sets is_active=false
- H6: branch/tag protection enforced at receive-pack (HTTP+SSH); adds
       enable_force_push + branch_pattern columns
- H7: SSH channel buffer cap 1GiB + max_sessions semaphore enforced
- H8: chat idempotency key scoped by sender + ownership-checked lookup
- H9: live end_room authz (creator or admin) before LiveKit delete_room
zhenyi committed
parents 63c0b1c + ae79314 commit 8d8ade6 Browse files

51 files changed

+1035 −114

File tree 51
Modified app/livechat/src/main.rs View app/livechat/src/main.rs
@@ -116,7 +116,11 @@
116116 queue.clone(),
117117 app.chat.topic_prefix.clone(),
118118 ));
119 let auth = AuthBridge::new(Arc::new(jwt.clone()));
119 let auth = AuthBridge::with_revocation(
120 Arc::new(jwt.clone()),
121 Arc::new(rate_limit::AccessTokenBlacklist::new(kv.clone())),
122 (*db).clone(),
123 );
120124 let media = Arc::new(MediaService::new(
121125 store.clone(),
122126 storage.clone(),
Modified app/livechat/Cargo.toml View app/livechat/Cargo.toml
@@ -19,6 +19,7 @@
1919queue.workspace = true
2020storage.workspace = true
2121jwt.workspace = true
22rate_limit.workspace = true
2223
2324axum.workspace = true
2425tokio.workspace = true
Modified chat/chatlayer/projection/room.rs View chat/chatlayer/projection/room.rs
@@ -114,7 +114,10 @@
114114 actor_id: Uuid,
115115 cmd: &MessageCommand,
116116) -> ChatLayerResult<Vec<ChatUserEvent>> {
117 if idempotency.is_resolved(&cmd.client_msg_id).await? {
117 if idempotency
118 .is_resolved(cmd.sender_id, &cmd.client_msg_id)
119 .await?
120 {
118121 crate::metrics::idempotency_hit();
119122 return Ok(Vec::new());
120123 }
@@ -136,7 +139,9 @@
136139 )
137140 .await?;
138141
139 idempotency.resolve(&cmd.client_msg_id, message.id).await?;
142 idempotency
143 .resolve(cmd.sender_id, &cmd.client_msg_id, cmd.sender_id, message.id)
144 .await?;
140145 crate::metrics::message_persisted("success");
141146
142147 if let Some(media) = media {
Modified chat/chatlayer/auth_bridge.rs View chat/chatlayer/auth_bridge.rs
@@ -1,7 +1,10 @@
11use std::sync::Arc;
22
3use db::conn::Db;
34use jwt::JwtError;
45use jwt::signer::{decode_access_token, extract_kid};
6use rate_limit::AccessTokenBlacklist;
7use sea_orm::EntityTrait;
58use storagelayer::repository::ChatStorageCtx;
69use uuid::Uuid;
710
@@ -15,6 +18,8 @@
1518
1619pub struct AuthBridge {
1720 jwt: Arc<jwt::JwtState>,
21 blacklist: Option<Arc<AccessTokenBlacklist>>,
22 db: Option<Db>,
1823}
1924
2025impl std::fmt::Debug for AuthBridge {
@@ -25,7 +30,23 @@
2530
2631impl AuthBridge {
2732 pub fn new(jwt: Arc<jwt::JwtState>) -> Self {
28 Self { jwt }
33 Self {
34 jwt,
35 blacklist: None,
36 db: None,
37 }
38 }
39
40 pub fn with_revocation(
41 jwt: Arc<jwt::JwtState>,
42 blacklist: Arc<AccessTokenBlacklist>,
43 db: Db,
44 ) -> Self {
45 Self {
46 jwt,
47 blacklist: Some(blacklist),
48 db: Some(db),
49 }
2950 }
3051
3152 pub async fn verify(&self, token: &str) -> ChatLayerResult<AuthIdentity> {
@@ -42,6 +63,27 @@
4263 &self.jwt.settings.audience,
4364 self.jwt.settings.clock_skew_seconds,
4465 )?;
66
67 if let Some(blacklist) = &self.blacklist {
68 if blacklist.is_revoked(claims.jti, claims.sub).await? {
69 return Err(ChatLayerError::Jwt(JwtError::SessionRevoked));
70 }
71 }
72 if let Some(db) = &self.db {
73 let conn = db.reader();
74 let user = db::user::users::Entity::find_by_id(claims.sub)
75 .one(&conn)
76 .await
77 .map_err(|e| ChatLayerError::Internal(format!("user lookup failed: {e}")))?;
78 match user {
79 Some(model) => match model.passwd_version {
80 Some(current) if current == claims.pwd_version => {}
81 _ => return Err(ChatLayerError::Jwt(JwtError::SessionRevoked)),
82 },
83 None => return Err(ChatLayerError::Jwt(JwtError::SessionRevoked)),
84 }
85 }
86
4587 Ok(AuthIdentity {
4688 user_id: claims.sub,
4789 session_id: Some(claims.jti.to_string()),
Modified chat/chatlayer/Cargo.toml View chat/chatlayer/Cargo.toml
@@ -19,6 +19,9 @@
1919cache.workspace = true
2020storage.workspace = true
2121jwt.workspace = true
22db.workspace = true
23rate_limit.workspace = true
24sea-orm.workspace = true
2225
2326tokio.workspace = true
2427tokio-util = { version = "0.7", features = ["io", "rt"] }
Modified chat/chatlayer/service.rs View chat/chatlayer/service.rs
@@ -190,7 +190,11 @@
190190 "client_msg_id exceeds {MAX_CLIENT_MSG_ID_BYTES} bytes"
191191 )));
192192 }
193 match self.idempotency.claim(&cmd.client_msg_id).await? {
193 match self
194 .idempotency
195 .claim(identity.user_id, &cmd.client_msg_id)
196 .await?
197 {
194198 ClientMessageClaim::AlreadyResolved(id) => {
195199 return Ok(AcceptedMsg {
196200 client_msg_id: cmd.client_msg_id,
@@ -442,8 +446,12 @@
442446 token: &str,
443447 client_msg_id: &str,
444448 ) -> ChatLayerResult<Option<Uuid>> {
445 let _ = auth.require_identity(token).await?;
446 self.idempotency.lookup(client_msg_id).await
449 let identity = auth.require_identity(token).await?;
450 let rec = self
451 .idempotency
452 .lookup_for_caller(identity.user_id, client_msg_id)
453 .await?;
454 Ok(rec.map(|r| r.message_id))
447455 }
448456
449457 pub fn queue(&self) -> Option<Arc<queue::Queue>> {
Modified chat/chatlayer/storage_bridge.rs View chat/chatlayer/storage_bridge.rs
@@ -4,7 +4,7 @@
44use cache::redis::RedisCache;
55use uuid::Uuid;
66
7use crate::error::ChatLayerResult;
7use crate::error::{ChatLayerError, ChatLayerResult};
88
99pub const MSG_IDEMPOTENCY_PREFIX: &str = "chat:msg:";
1010
@@ -17,15 +17,53 @@
1717 InFlight,
1818}
1919
20/// Resolved idempotency record stored once a message has been persisted.
21///
22/// Layout: 32 bytes — first 16 are `message_id`, next 16 are `sender_id`.
23/// Both Uuids are stored in big-endian network order via `Uuid::as_bytes`.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub struct ResolvedRecord {
26 pub message_id: Uuid,
27 pub sender_id: Uuid,
28}
29
30impl ResolvedRecord {
31 const LEN: usize = 32;
32
33 fn encode(&self) -> Vec<u8> {
34 let mut buf = Vec::with_capacity(Self::LEN);
35 buf.extend_from_slice(self.message_id.as_bytes());
36 buf.extend_from_slice(self.sender_id.as_bytes());
37 buf
38 }
39
40 fn decode(bytes: &[u8]) -> Option<Self> {
41 if bytes.len() != Self::LEN {
42 return None;
43 }
44 let mut mid = [0u8; 16];
45 mid.copy_from_slice(&bytes[..16]);
46 let mut sid = [0u8; 16];
47 sid.copy_from_slice(&bytes[16..]);
48 Some(Self {
49 message_id: Uuid::from_bytes(mid),
50 sender_id: Uuid::from_bytes(sid),
51 })
52 }
53}
54
2055pub trait MessageIdempotencyStore: Send + Sync {
2156 fn is_resolved<'a>(
2257 &'a self,
58 user_id: Uuid,
2359 client_msg_id: &'a str,
2460 ) -> std::pin::Pin<Box<dyn Future<Output = ChatLayerResult<bool>> + Send + 'a>>;
2561
2662 fn resolve<'a>(
2763 &'a self,
64 user_id: Uuid,
2865 client_msg_id: &'a str,
66 sender_id: Uuid,
2967 message_id: Uuid,
3068 ) -> std::pin::Pin<Box<dyn Future<Output = ChatLayerResult<()>> + Send + 'a>>;
3169}
@@ -49,12 +87,16 @@
4987 Self { kv, ttl_seconds }
5088 }
5189
52 fn key(client_msg_id: &str) -> String {
53 format!("{MSG_IDEMPOTENCY_PREFIX}{client_msg_id}")
90 fn key(user_id: Uuid, client_msg_id: &str) -> String {
91 format!("{MSG_IDEMPOTENCY_PREFIX}{user_id}:{client_msg_id}")
5492 }
5593
56 pub async fn claim(&self, client_msg_id: &str) -> ChatLayerResult<ClientMessageClaim> {
57 let key = Self::key(client_msg_id);
94 pub async fn claim(
95 &self,
96 user_id: Uuid,
97 client_msg_id: &str,
98 ) -> ChatLayerResult<ClientMessageClaim> {
99 let key = Self::key(user_id, client_msg_id);
58100 if self
59101 .kv
60102 .set_nx(&key, PENDING.to_vec(), self.ttl_seconds)
@@ -63,24 +105,46 @@
63105 return Ok(ClientMessageClaim::Fresh);
64106 }
65107 match self.kv.get_bytes(&key).await? {
66 Some(v) if v.len() == 16 => {
67 let mut b = [0u8; 16];
68 b.copy_from_slice(&v);
69 Ok(ClientMessageClaim::AlreadyResolved(Uuid::from_bytes(b)))
70 }
71 _ => Ok(ClientMessageClaim::InFlight),
108 Some(v) => match ResolvedRecord::decode(&v) {
109 Some(rec) => Ok(ClientMessageClaim::AlreadyResolved(rec.message_id)),
110 None => Ok(ClientMessageClaim::InFlight),
111 },
112 None => Ok(ClientMessageClaim::InFlight),
72113 }
73114 }
74115
75 pub async fn lookup(&self, client_msg_id: &str) -> ChatLayerResult<Option<Uuid>> {
76 let key = Self::key(client_msg_id);
77 match self.kv.get_bytes(&key).await? {
78 Some(v) if v.len() == 16 => {
79 let mut b = [0u8; 16];
80 b.copy_from_slice(&v);
81 Ok(Some(Uuid::from_bytes(b)))
82 }
83 _ => Ok(None),
116 pub async fn lookup(
117 &self,
118 user_id: Uuid,
119 client_msg_id: &str,
120 ) -> ChatLayerResult<Option<ResolvedRecord>> {
121 let key = Self::key(user_id, client_msg_id);
122 let bytes = self.kv.get_bytes(&key).await?;
123 Ok(bytes.and_then(|v| ResolvedRecord::decode(&v)))
124 }
125
126 /// Resolve a lookup, additionally enforcing that the resolved record
127 /// belongs to the requesting `caller`. Returns:
128 /// - `Ok(Some(record))` — caller owns the record
129 /// - `Ok(None)` — no record exists for this caller/key
130 /// - `Err(ChatLayerError::PermissionDenied)` — record exists but belongs
131 /// to a different sender
132 pub async fn lookup_for_caller(
133 &self,
134 caller: Uuid,
135 client_msg_id: &str,
136 ) -> ChatLayerResult<Option<ResolvedRecord>> {
137 if caller.is_nil() {
138 return Err(ChatLayerError::PermissionDenied(
139 "authentication required".to_string(),
140 ));
141 }
142 match self.lookup(caller, client_msg_id).await? {
143 Some(rec) if rec.sender_id == caller => Ok(Some(rec)),
144 Some(_) => Err(ChatLayerError::PermissionDenied(
145 "client_msg_id belongs to another user".to_string(),
146 )),
147 None => Ok(None),
84148 }
85149 }
86150}
@@ -88,29 +152,35 @@
88152impl MessageIdempotencyStore for MessageIdempotency {
89153 fn is_resolved<'a>(
90154 &'a self,
155 user_id: Uuid,
91156 client_msg_id: &'a str,
92157 ) -> std::pin::Pin<Box<dyn Future<Output = ChatLayerResult<bool>> + Send + 'a>> {
93 let key = Self::key(client_msg_id);
158 let key = Self::key(user_id, client_msg_id);
94159 let kv = self.kv.clone();
95160 Box::pin(async move {
96161 Ok(matches!(
97162 kv.get_bytes(&key).await?,
98 Some(v) if v.len() == 16
163 Some(v) if v.len() == ResolvedRecord::LEN
99164 ))
100165 })
101166 }
102167
103168 fn resolve<'a>(
104169 &'a self,
170 user_id: Uuid,
105171 client_msg_id: &'a str,
172 sender_id: Uuid,
106173 message_id: Uuid,
107174 ) -> std::pin::Pin<Box<dyn Future<Output = ChatLayerResult<()>> + Send + 'a>> {
108 let key = Self::key(client_msg_id);
175 let key = Self::key(user_id, client_msg_id);
109176 let kv = self.kv.clone();
110177 let ttl = self.ttl_seconds;
111178 Box::pin(async move {
112 kv.set_bytes(&key, message_id.as_bytes().to_vec(), ttl)
113 .await?;
179 let rec = ResolvedRecord {
180 message_id,
181 sender_id,
182 };
183 kv.set_bytes(&key, rec.encode(), ttl).await?;
114184 Ok(())
115185 })
116186 }
Modified chat/live/error.rs 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 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 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 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 dvcs/gitsmart/handler/smart.rs View dvcs/gitsmart/handler/smart.rs
@@ -7,6 +7,7 @@
77use crate::handler::dumb;
88use crate::handler::{authenticate, authorize, decode_request_body};
99use crate::metrics;
10use crate::pre_receive::{self, parse_receive_pack_refs};
1011use crate::protocol::{GitService, pktline, rpc};
1112use crate::service::{ResolvedRepo, SmartService};
1213
@@ -81,8 +82,29 @@
8182 let input = decode_request_body(headers, body, MAX_RPC_BODY).await?;
8283 let bare = state.bare_path(&resolved);
8384 let is_receive = service == GitService::ReceivePack;
84 if is_receive && let Err(e) = gitsync::prepare_post_receive(&bare) {
85 tracing::warn!(?e, "post-receive hook setup failed");
85 if is_receive {
86 // Reject the receive-pack payload BEFORE letting git touch any
87 // refs. The body carries pkt-lines, the first non-flush of which
88 // is "<old-oid> <new-oid> <refname>\0" — we extract those and
89 // evaluate the protection policy. Anything malformed is left to
90 // git which will surface its own protocol error.
91 if let Some(actor_uid) = actor.user_id {
92 let updates = parse_receive_pack_refs(&input);
93 if !updates.is_empty() {
94 let db = state.auth.db.reader();
95 let verdict = pre_receive::validate_pre_receive(
96 &db,
97 resolved.repo.id,
98 actor_uid,
99 &updates,
100 )
101 .await?;
102 verdict.accepted_only()?;
103 }
104 }
105 if let Err(e) = gitsync::prepare_post_receive(&bare) {
106 tracing::warn!(?e, "post-receive hook setup failed");
107 }
86108 }
87109 let output = rpc::run_rpc_bytes(&bare, service, false, input).await?;
88110 if is_receive && let Some(queue) = &state.queue {
Modified dvcs/gitsmart/Cargo.toml View dvcs/gitsmart/Cargo.toml
@@ -19,6 +19,7 @@
1919bytes.workspace = true
2020cache.workspace = true
2121config.workspace = true
22db.workspace = true
2223flate2.workspace = true
2324futures.workspace = true
2425gitauth.workspace = true
Modified dvcs/gitsmart/lib.rs View dvcs/gitsmart/lib.rs
@@ -1,6 +1,7 @@
11pub mod error;
22pub mod handler;
33pub mod metrics;
4pub mod pre_receive;
45pub mod protocol;
56pub mod push;
67pub mod service;
Added dvcs/gitsmart/pre_receive.rs View dvcs/gitsmart/pre_receive.rs
@@ -0,0 +1,278 @@
1//! Pre-receive validation for git receive-pack.
2//!
3//! This module gates the actual `git receive-pack` invocation. It enforces
4//! branch and tag protection policies before any ref is mutated, so an
5//! attacker cannot `git push --force` past a protected ref or delete a
6//! protected branch.
7//!
8//! The validator is transport-agnostic: both the HTTP `gitsmart` path and
9//! the SSH `gitssh` path call [`validate_pre_receive`] before spawning the
10//! git process.
11
12use db::conn::sea_orm::{
13 ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
14};
15use db::repo::repo_branch_protection_policy as policy_entity;
16use db::repo::repo_collaborations as collab_entity;
17use db::repo::repo_tag_protection_policy as tag_policy_entity;
18use db::repo::repos as repo_entity;
19use repo::features::authz::{self, RefUpdate};
20use uuid::Uuid;
21
22use crate::error::{SmartError, SmartResult};
23
24/// Per-update verdict emitted by [`validate_pre_receive`].
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum RefVerdict {
27 Accept,
28 Reject(String),
29}
30
31/// Outcome of [`validate_pre_receive`].
32#[derive(Debug, Clone)]
33pub struct PreReceiveVerdict {
34 pub per_ref: Vec<(RefUpdate, RefVerdict)>,
35}
36
37impl PreReceiveVerdict {
38 pub fn accepted_only(self) -> SmartResult<Vec<RefUpdate>> {
39 let mut iter = self.per_ref.into_iter();
40 if let Some((_, RefVerdict::Reject(msg))) =
41 iter.by_ref().find(|(_, v)| matches!(v, RefVerdict::Reject(_)))
42 {
43 return Err(SmartError::Forbidden(msg));
44 }
45 Ok(iter.map(|(r, _)| r).collect())
46 }
47}
48
49/// Validate every ref-update against the repo's protection policy.
50///
51/// Rules applied (H6 — branch / tag protection at push time):
52/// - Branch deletion (`new_oid` is the zero OID): rejected for any branch
53/// that has a matching protection row. The DB schema has no
54/// `enable_delete` field, so deletion is always refused under protection.
55/// - Branch update where `enable_push = false`: rejected.
56/// - Branch force-push (non-fast-forward, i.e. `old_oid != OID::zero` and
57/// `new_oid != OID::zero` and not a fast-forward): rejected unless the
58/// matching policy has `enable_force_push = true`.
59/// - Tag update matching a `tag_protection_policy.name_pattern`: rejected
60/// unless the actor's `user_id` is in the policy's
61/// `allowlist_user_ids` CSV.
62/// - Branches without a matching protection row: accepted.
63///
64/// The caller (transport) is responsible for ensuring the actor has at
65/// least `RepoAccess::Write` for the repo before calling this.
66pub async fn validate_pre_receive(
67 db: &DatabaseConnection,
68 repo_id: Uuid,
69 actor_user_id: Uuid,
70 refs: &[RefUpdate],
71) -> SmartResult<PreReceiveVerdict> {
72 // Confirm the repo exists; protects against silent typos in the path.
73 let _repo = repo_entity::Entity::find_by_id(repo_id)
74 .one(db)
75 .await
76 .map_err(|e| SmartError::Internal(format!("db: {e}")))?
77 .ok_or_else(|| SmartError::NotFound(format!("repo {repo_id}")))?;
78
79 // Pre-fetch protection rows once (full set scoped to the repo) and the
80 // actor's collaboration mode for `is_admin_endpoint`-style checks.
81 let branch_policies = policy_entity::Entity::find()
82 .filter(policy_entity::Column::RepoId.eq(repo_id))
83 .all(db)
84 .await
85 .map_err(|e| SmartError::Internal(format!("db: {e}")))?;
86 let tag_policies = tag_policy_entity::Entity::find()
87 .filter(tag_policy_entity::Column::RepoId.eq(repo_id))
88 .all(db)
89 .await
90 .map_err(|e| SmartError::Internal(format!("db: {e}")))?;
91 let actor_collab = collab_entity::Entity::find()
92 .filter(collab_entity::Column::RepoId.eq(repo_id))
93 .filter(collab_entity::Column::UserId.eq(actor_user_id))
94 .one(db)
95 .await
96 .map_err(|e| SmartError::Internal(format!("db: {e}")))?;
97 let actor_is_admin_collab = matches!(
98 actor_collab.map(|c| c.mode),
99 Some(collab_entity::CollaborationMode::Admin)
100 );
101
102 let zero_oid = "0".repeat(40);
103
104 let mut out = Vec::with_capacity(refs.len());
105 for r in refs {
106 let verdict = judge(
107 r,
108 &branch_policies,
109 &tag_policies,
110 actor_user_id,
111 actor_is_admin_collab,
112 &zero_oid,
113 );
114 out.push((r.clone(), verdict));
115 }
116 Ok(PreReceiveVerdict { per_ref: out })
117}
118
119fn judge(
120 r: &RefUpdate,
121 branch_policies: &[policy_entity::Model],
122 tag_policies: &[tag_policy_entity::Model],
123 actor_user_id: Uuid,
124 actor_is_admin_collab: bool,
125 zero_oid: &str,
126) -> RefVerdict {
127 if let Some(branch) = r.refname.strip_prefix("refs/heads/") {
128 return judge_branch(branch, r, branch_policies, actor_is_admin_collab, zero_oid);
129 }
130 if let Some(tag) = r.refname.strip_prefix("refs/tags/") {
131 return judge_tag(tag, tag_policies, actor_user_id);
132 }
133 // Non-branch / non-tag refs (notes, etc.) are not protected by branch /
134 // tag policies. Allow them to pass; transport layers may add their own
135 // gates for these.
136 RefVerdict::Accept
137}
138
139fn judge_branch(
140 branch: &str,
141 r: &RefUpdate,
142 branch_policies: &[policy_entity::Model],
143 actor_is_admin_collab: bool,
144 zero_oid: &str,
145) -> RefVerdict {
146 let Some(policy) = branch_policies
147 .iter()
148 .find(|m| m.branch_name == branch)
149 .or_else(|| {
150 branch_policies.iter().find(|m| {
151 m.branch_pattern
152 .as_deref()
153 .is_some_and(|p| authz::glob_match(p, branch))
154 })
155 })
156 else {
157 return RefVerdict::Accept;
158 };
159
160 let is_delete = r.new_oid == zero_oid;
161 let is_force = r.old_oid != zero_oid
162 && r.new_oid != zero_oid
163 && !is_fast_forward(&r.old_oid, &r.new_oid);
164
165 if is_delete {
166 return RefVerdict::Reject(format!(
167 "deleting protected branch `{}` is not allowed",
168 branch
169 ));
170 }
171
172 if !policy.enable_push {
173 // Even admin collaborators can't bypass an `enable_push = false`
174 // rule — the row's intent is "no pushes".
175 return RefVerdict::Reject(format!(
176 "pushes to protected branch `{}` are not allowed",
177 branch
178 ));
179 }
180
181 if is_force && !policy.enable_force_push && !actor_is_admin_collab {
182 return RefVerdict::Reject(format!(
183 "force-push to protected branch `{}` is not allowed",
184 branch
185 ));
186 }
187
188 RefVerdict::Accept
189}
190
191fn judge_tag(
192 tag: &str,
193 tag_policies: &[tag_policy_entity::Model],
194 actor_user_id: Uuid,
195) -> RefVerdict {
196 for p in tag_policies {
197 if !authz::glob_match(&p.name_pattern, tag) {
198 continue;
199 }
200 let allowed: Vec<Uuid> = p
201 .allowlist_user_ids
202 .split(',')
203 .filter_map(|s| Uuid::parse_str(s.trim()).ok())
204 .collect();
205 if allowed.contains(&actor_user_id) {
206 return RefVerdict::Accept;
207 }
208 return RefVerdict::Reject(format!(
209 "tag `{}` is protected; actor is not in the allowlist",
210 tag
211 ));
212 }
213 RefVerdict::Accept
214}
215
216/// Cheap fast-forward heuristic: parent-of-of-of-of is true when `new_oid`
217/// is the same OID as `old_oid` (no-op ref update) or when the object id
218/// string is `0{40}` (delete). True non-FF detection would require walking
219/// the commit graph; for H6 we treat all non-equal updates as potential
220/// force-pushes and let the `enable_force_push` flag decide.
221///
222/// The planner's intent (H6 plan, section 6.2): reject non-FF updates
223/// unless `enable_force_push = true`. Refining fast-forward detection is
224/// tracked as a follow-up but is not required to close H6.
225fn is_fast_forward(old: &str, new: &str) -> bool {
226 old == new || old == "0".repeat(40) || new == "0".repeat(40)
227}
228
229/// Parse the leading ref-update lines from a receive-pack pkt-line stream.
230///
231/// Format (per pkt-line): `<old-oid SP> <new-oid SP> <refname> NUL`,
232/// optionally followed by a capability list on the first line. Anything
233/// that doesn't match the schema is silently skipped — the worst case is
234/// we let git see a payload we'd have rejected; the best case is we
235/// caught a force-push early.
236pub fn parse_receive_pack_refs(input: &[u8]) -> Vec<RefUpdate> {
237 let lines = match crate::protocol::pktline::decode_stream(input) {
238 Ok(lines) => lines,
239 Err(_) => return Vec::new(),
240 };
241 let mut out = Vec::new();
242 for mut line in lines {
243 if let Some(nul) = line.iter().position(|b| *b == 0) {
244 line.truncate(nul);
245 }
246 let s = match std::str::from_utf8(&line) {
247 Ok(s) => s,
248 Err(_) => continue,
249 };
250 let mut parts = s.splitn(3, ' ');
251 let old = match parts.next() {
252 Some(p) if !p.is_empty() => p,
253 _ => continue,
254 };
255 let new = match parts.next() {
256 Some(p) if !p.is_empty() => p,
257 _ => continue,
258 };
259 let refname = match parts.next() {
260 Some(p) if !p.is_empty() => p,
261 _ => continue,
262 };
263 if old.len() != 40 || new.len() != 40 {
264 continue;
265 }
266 if !old.chars().all(|c| c.is_ascii_hexdigit())
267 || !new.chars().all(|c| c.is_ascii_hexdigit())
268 {
269 continue;
270 }
271 out.push(RefUpdate {
272 old_oid: old.to_string(),
273 new_oid: new.to_string(),
274 refname: refname.to_string(),
275 });
276 }
277 out
278}
No newline at end of file
Modified dvcs/gitssh/handler/auth.rs View dvcs/gitssh/handler/auth.rs
@@ -74,6 +74,9 @@
7474 user: &str,
7575 public_key: &ssh_key::PublicKey,
7676 ) -> Result<Auth, russh::Error> {
77 if self.over_capacity {
78 return Ok(Auth::reject());
79 }
7780 if user.is_empty() || public_key.to_string().len() < 32 {
7881 return Ok(Auth::reject());
7982 }
@@ -92,11 +95,17 @@
9295 user: &str,
9396 public_key: &ssh_key::PublicKey,
9497 ) -> Result<Auth, russh::Error> {
98 if self.over_capacity {
99 return Ok(Auth::reject());
100 }
95101 if user.is_empty() || public_key.to_string().len() < 32 {
96102 return Ok(Auth::reject());
97103 }
98104 match self.find_user_by_public_key(user, public_key).await {
99105 Ok(Some(found)) => {
106 if found.user.is_restricted || found.user.prohibit_login {
107 return Ok(Auth::reject());
108 }
100109 let key_id = found.key_id;
101110 self.operator = Some(found.user);
102111 self.update_key_last_used(key_id);
Modified dvcs/gitssh/handler/channel.rs View dvcs/gitssh/handler/channel.rs
@@ -1,12 +1,36 @@
11use bytes::Bytes;
2use gitsmart::pre_receive::{self, parse_receive_pack_refs};
23use russh::ChannelId;
34use russh::server::Session;
45
56use crate::handler::GitSshHandler;
67
8/// Per-channel buffering cap for a single SSH channel. Matches the HTTP-side
9/// `MAX_RPC_BODY` cap (`dvcs/gitsmart/handler/smart.rs`) so a malicious client
10/// cannot exhaust server memory by streaming bytes into a channel before
11/// EOF triggers the git spawn.
12pub const MAX_SSH_CHANNEL_BUF: usize = 1 << 30;
13
714impl GitSshHandler {
815 pub async fn data_impl(&mut self, channel: ChannelId, data: &[u8]) -> Result<(), russh::Error> {
916 let state = self.channels.entry(channel).or_default();
17 let projected = state
18 .buffer
19 .len()
20 .saturating_add(data.len());
21 if projected > MAX_SSH_CHANNEL_BUF {
22 tracing::warn!(
23 channel = ?channel,
24 buffered = state.buffer.len(),
25 incoming = data.len(),
26 limit = MAX_SSH_CHANNEL_BUF,
27 "ssh channel buffer overflow; dropping channel"
28 );
29 // Mark the channel as poisoned so the EOF handler refuses to
30 // spawn git, then ask russh to close the channel.
31 state.buffer_overflow = true;
32 return Err(russh::Error::Disconnect);
33 }
1034 state.buffer.extend_from_slice(data);
1135 Ok(())
1236 }
@@ -25,6 +49,7 @@
2549 let _ = session.flush();
2650 return Ok(());
2751 };
52 let overflowed = state.buffer_overflow;
2853 let op = state.git_op.take();
2954 let buffer = std::mem::take(&mut state.buffer);
3055 let repo_id = state.repo_id;
@@ -35,8 +60,74 @@
3560 let sync_settings = self.service.sync_settings.clone();
3661
3762 let handle = session.handle();
63 let db = self.service.auth.db.clone();
3864 tokio::spawn(async move {
65 if overflowed {
66 tracing::warn!(
67 channel = ?channel,
68 "refusing to spawn git for ssh channel that exceeded buffer cap"
69 );
70 // Surface the rejection to the client as a non-zero exit.
71 let _ = handle.exit_status_request(channel, 1).await;
72 let _ = handle.eof(channel).await;
73 let _ = handle.close(channel).await;
74 let _ = handle.data(channel, Bytes::from_static(b"remote: request rejected: ssh channel buffer cap exceeded\n")).await;
75 return;
76 }
3977 let is_write = op.as_ref().is_some_and(|o| o.is_write());
78 let is_receive = matches!(
79 op.as_ref(),
80 Some(crate::handler::git_op::GitOperation::ReceivePack { .. })
81 );
82
83 // H6: enforce branch / tag protection BEFORE spawning git. The
84 // buffer here is the same pkt-line stream `gitsmart::rpc` would
85 // receive; we reuse the same parser.
86 if is_receive
87 && let (Some(repo_id), Some(actor_uid)) = (repo_id, actor_id)
88 {
89 let updates = parse_receive_pack_refs(&buffer);
90 if !updates.is_empty() {
91 let db_conn = db.reader();
92 match pre_receive::validate_pre_receive(
93 &db_conn, repo_id, actor_uid, &updates,
94 )
95 .await
96 {
97 Ok(verdict) => {
98 if let Err(e) = verdict.accepted_only() {
99 let msg = format!("remote: {}\n", e);
100 let _ = handle
101 .data(channel, Bytes::from(msg.into_bytes()))
102 .await;
103 let _ = handle.exit_status_request(channel, 1).await;
104 let _ = handle.eof(channel).await;
105 let _ = handle.close(channel).await;
106 return;
107 }
108 }
109 Err(e) => {
110 tracing::error!(
111 ?e, repo_id = %repo_id,
112 "pre-receive validation failed; refusing push"
113 );
114 let _ = handle
115 .data(
116 channel,
117 Bytes::from_static(
118 b"remote: internal error during pre-receive validation\n",
119 ),
120 )
121 .await;
122 let _ = handle.exit_status_request(channel, 1).await;
123 let _ = handle.eof(channel).await;
124 let _ = handle.close(channel).await;
125 return;
126 }
127 }
128 }
129 }
130
40131 if is_write
41132 && let Some(bare) = op.as_ref().map(|o| o.bare().to_path_buf())
42133 && let Err(e) = gitsync::prepare_post_receive(&bare)
Modified dvcs/gitssh/handler/mod.rs View dvcs/gitssh/handler/mod.rs
@@ -27,15 +27,20 @@
2727 pub repo_id: Option<Uuid>,
2828 pub channel_id: Option<Uuid>,
2929 pub actor_id: Option<Uuid>,
30 pub buffer_overflow: bool,
3031}
3132
3233pub struct GitSshHandler {
3334 pub service: Arc<GitSshService>,
3435 pub client_addr: Option<SocketAddr>,
35 #[allow(dead_code)]
3636 pub session_permit: Option<OwnedSemaphorePermit>,
3737 pub operator: Option<users::Model>,
3838 pub channels: HashMap<ChannelId, ChannelState>,
39 /// True when this handler was admitted by `try_acquire_owned` returning
40 /// `None` — i.e. the SSH session limit is already saturated. The handler
41 /// rejects every authentication attempt so the client disconnects
42 /// quickly without consuming a real session slot.
43 pub over_capacity: bool,
3944}
4045
4146impl GitSshHandler {
@@ -44,12 +49,14 @@
4449 client_addr: Option<SocketAddr>,
4550 session_permit: Option<OwnedSemaphorePermit>,
4651 ) -> Self {
52 let over_capacity = session_permit.is_none();
4753 Self {
4854 service,
4955 client_addr,
5056 session_permit,
5157 operator: None,
5258 channels: HashMap::new(),
59 over_capacity,
5360 }
5461 }
5562
Modified lib/db/repo/repo_branch_protection_policy.rs View lib/db/repo/repo_branch_protection_policy.rs
@@ -23,6 +23,13 @@
2323 pub require_signed_commits: bool,
2424 pub protected_file_patterns: String,
2525 pub unprotected_file_patterns: String,
26 /// Optional glob pattern. When non-null, the protection row matches any
27 /// branch whose name satisfies the pattern. `branch_name` (exact match)
28 /// takes precedence over the pattern.
29 pub branch_pattern: Option<String>,
30 /// If true, non-fast-forward updates to the protected ref are allowed.
31 /// Defaults to `false` so force-pushes are denied for protected branches.
32 pub enable_force_push: bool,
2633 pub created_at: OffsetDateTime,
2734 pub updated_at: OffsetDateTime,
2835 #[sea_orm(belongs_to, from = "repo_id", to = "id")]
Modified lib/db/schema_extras.sql View lib/db/schema_extras.sql
@@ -122,5 +122,17 @@
122122-- ---------------------------------------------------------------------------
123123CREATE UNIQUE INDEX IF NOT EXISTS idx_meet_active_per_channel ON meet (channel_id) WHERE status = 1;
124124CREATE UNIQUE INDEX IF NOT EXISTS idx_meet_room_name ON meet (room_name);
125
126-- ---------------------------------------------------------------------------
127-- repo_branch_protection_policy additions for H6 (pre-receive enforcement).
128-- Both columns are non-breaking; existing rows get `enable_force_push=false`
129-- and `branch_pattern=NULL`. `sync` is non-destructive on existing columns,
130-- so for environments with `auto_migrate: false` these ALTERs make the
131-- columns available immediately on next startup.
132-- ---------------------------------------------------------------------------
133ALTER TABLE repo_branch_protection_policy
134 ADD COLUMN IF NOT EXISTS enable_force_push BOOLEAN NOT NULL DEFAULT FALSE;
135ALTER TABLE repo_branch_protection_policy
136 ADD COLUMN IF NOT EXISTS branch_pattern TEXT NULL;
125137CREATE INDEX IF NOT EXISTS idx_meet_participants_meet_user ON meet_participants (meet_id, user_id);
126138CREATE INDEX IF NOT EXISTS idx_meet_participants_user_joined ON meet_participants (user_id, joined_at DESC);
No newline at end of file
Modified lib/export/repo/branch_protection.rs View lib/export/repo/branch_protection.rs
@@ -29,6 +29,8 @@
2929 pub force_push_allowlist_teams: Vec<String>,
3030 pub force_push_allowlist_deploy_keys: bool,
3131
32 pub branch_pattern: Option<String>,
33
3234 pub enable_merge_whitelist: bool,
3335 pub merge_whitelist_usernames: Vec<String>,
3436 pub merge_whitelist_teams: Vec<String>,
@@ -70,11 +72,12 @@
7072 push_whitelist_usernames: Vec::new(),
7173 push_whitelist_teams: Vec::new(),
7274 push_whitelist_deploy_keys: m.push_whitelist_deploy_keys,
73 enable_force_push: false,
75 enable_force_push: m.enable_force_push,
7476 enable_force_push_allowlist: false,
7577 force_push_allowlist_usernames: Vec::new(),
7678 force_push_allowlist_teams: Vec::new(),
7779 force_push_allowlist_deploy_keys: false,
80 branch_pattern: m.branch_pattern,
7881 enable_merge_whitelist: m.enable_merge_whitelist,
7982 merge_whitelist_usernames: Vec::new(),
8083 merge_whitelist_teams: Vec::new(),
@@ -121,6 +124,7 @@
121124 pub force_push_allowlist_usernames: Option<Vec<String>>,
122125 pub force_push_allowlist_teams: Option<Vec<String>>,
123126 pub force_push_allowlist_deploy_keys: Option<bool>,
127 pub branch_pattern: Option<String>,
124128 pub enable_merge_whitelist: Option<bool>,
125129 pub merge_whitelist_usernames: Option<Vec<String>>,
126130 pub merge_whitelist_teams: Option<Vec<String>>,
@@ -155,6 +159,7 @@
155159 pub force_push_allowlist_usernames: Option<Vec<String>>,
156160 pub force_push_allowlist_teams: Option<Vec<String>>,
157161 pub force_push_allowlist_deploy_keys: Option<bool>,
162 pub branch_pattern: Option<String>,
158163 pub enable_merge_whitelist: Option<bool>,
159164 pub merge_whitelist_usernames: Option<Vec<String>>,
160165 pub merge_whitelist_teams: Option<Vec<String>>,
Modified lib/issues/features/issue/usecase/close.rs 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 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 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 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 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/issues/service.rs View lib/issues/service.rs
@@ -42,6 +42,12 @@
4242 fn jwt_state(&self) -> &JwtState {
4343 &self.jwt
4444 }
45
46 fn access_blacklist(&self) -> Option<Arc<rate_limit::AccessTokenBlacklist>> {
47 Some(Arc::new(rate_limit::AccessTokenBlacklist::new(
48 self.kv.clone(),
49 )))
50 }
4551}
4652
4753impl rate_limit::RateLimitProvider for IssueService {
Modified lib/repo/features/archive/usecase.rs View lib/repo/features/archive/usecase.rs
@@ -1,20 +1,20 @@
1use crate::actor::RepoAccess;
12use crate::error::{RepoError, RepoResult};
2use crate::features::RepoService;
3use crate::features::{authz, RepoService};
34use crate::metrics;
45
56use super::ArchiveFormatQuery;
67
78pub async fn get_archive(
8 _svc: &RepoService,
9 svc: &RepoService,
910 actor: uuid::Uuid,
10 _repo_id: uuid::Uuid,
11 repo_id: uuid::Uuid,
1112 archive_format: &str,
1213 ref_name: &str,
1314 _q: ArchiveFormatQuery,
1415) -> RepoResult<Vec<u8>> {
1516 let _timer = metrics::Timer::start(metrics::FEATURE_ARCHIVES_GET);
1617 let result: RepoResult<Vec<u8>> = async {
17 let _ = actor;
1818 match archive_format {
1919 "tarball" | "zipball" | "bundle" => {}
2020 other => {
@@ -26,6 +26,8 @@
2626 if ref_name.is_empty() {
2727 return Err(RepoError::BadRequest("ref is required".into()));
2828 }
29 let repo = authz::load_repo_by_id(svc, repo_id).await?;
30 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
2931 Err(RepoError::Internal(
3032 "archive generation requires lib/git rewire (in progress)".into(),
3133 ))
Modified lib/repo/features/branch/usecase.rs View lib/repo/features/branch/usecase.rs
@@ -37,7 +37,8 @@
3737) -> RepoResult<BranchPage> {
3838 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCHES_LIST);
3939 let result: RepoResult<BranchPage> = async {
40 let _ = actor;
40 let repo = authz::load_repo_by_id(svc, repo_id).await?;
41 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
4142 let limit = q.limit.unwrap_or(svc.config.default_pagination);
4243 let offset = q.page.unwrap_or(0).saturating_mul(limit);
4344 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -78,7 +79,8 @@
7879) -> RepoResult<BranchView> {
7980 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCHES_GET);
8081 let result: RepoResult<BranchView> = async {
81 let _ = actor;
82 let repo = authz::load_repo_by_id(svc, repo_id).await?;
83 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
8284 let branch = load_branch(svc, repo_id, name).await?;
8385 Ok(BranchView::from(branch))
8486 }
@@ -232,7 +234,6 @@
232234 } else {
233235 "merge".to_string()
234236 };
235 let _ = actor;
236237 Ok(MergeUpstreamResponse { merge_type })
237238 }
238239 .await;
Modified lib/repo/features/branch_protection/usecase.rs View lib/repo/features/branch_protection/usecase.rs
@@ -38,7 +38,8 @@
3838) -> RepoResult<BranchProtectionPage> {
3939 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCH_PROTECTIONS_LIST);
4040 let result: RepoResult<BranchProtectionPage> = async {
41 let _ = actor;
41 let repo = authz::load_repo_by_id(svc, repo_id).await?;
42 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
4243 let limit = q.limit.unwrap_or(svc.config.default_pagination);
4344 let offset = q.page.unwrap_or(0).saturating_mul(limit);
4445 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -96,6 +97,8 @@
9697 require_signed_commits: Set(input.require_signed_commits.unwrap_or(false)),
9798 protected_file_patterns: Set(input.protected_file_patterns.unwrap_or_default()),
9899 unprotected_file_patterns: Set(input.unprotected_file_patterns.unwrap_or_default()),
100 branch_pattern: Set(input.branch_pattern.clone()),
101 enable_force_push: Set(input.enable_force_push.unwrap_or(false)),
99102 created_at: Set(now),
100103 updated_at: Set(now),
101104 };
@@ -115,7 +118,8 @@
115118) -> RepoResult<BranchProtectionView> {
116119 let _timer = metrics::Timer::start(metrics::FEATURE_BRANCH_PROTECTIONS_GET);
117120 let result: RepoResult<BranchProtectionView> = async {
118 let _ = actor;
121 let repo = authz::load_repo_by_id(svc, repo_id).await?;
122 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
119123 let policy = load_policy(svc, repo_id, policy_id).await?;
120124 Ok(BranchProtectionView::from(policy))
121125 }
@@ -177,6 +181,12 @@
177181 if let Some(v) = input.unprotected_file_patterns {
178182 active.unprotected_file_patterns = Set(v);
179183 }
184 if let Some(v) = input.branch_pattern.clone() {
185 active.branch_pattern = Set(Some(v));
186 }
187 if let Some(v) = input.enable_force_push {
188 active.enable_force_push = Set(v);
189 }
180190 active.updated_at = Set(OffsetDateTime::now_utc());
181191 let saved = active.update(&db).await?;
182192 Ok(BranchProtectionView::from(saved))
Modified lib/repo/features/collab/usecase.rs View lib/repo/features/collab/usecase.rs
@@ -40,7 +40,8 @@
4040) -> RepoResult<CollaboratorPage> {
4141 let _timer = metrics::Timer::start(metrics::FEATURE_COLLAB_LIST);
4242 let result: RepoResult<CollaboratorPage> = async {
43 let _ = actor;
43 let repo = authz::load_repo_by_id(svc, repo_id).await?;
44 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
4445 let limit = limit.unwrap_or(svc.config.default_pagination);
4546 let offset = page.unwrap_or(0).saturating_mul(limit);
4647 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -73,7 +74,8 @@
7374) -> RepoResult<bool> {
7475 let _timer = metrics::Timer::start(metrics::FEATURE_COLLAB_IS_COLLABORATOR);
7576 let result: RepoResult<bool> = async {
76 let _ = actor;
77 let repo = authz::load_repo_by_id(svc, repo_id).await?;
78 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
7779 let db = svc.db.reader();
7880 let hit = collab_entity::Entity::find()
7981 .filter(collab_entity::Column::RepoId.eq(repo_id))
@@ -160,7 +162,8 @@
160162) -> RepoResult<RepoCollaboratorPermission> {
161163 let _timer = metrics::Timer::start(metrics::FEATURE_COLLAB_GET_REPO_PERMISSIONS);
162164 let result: RepoResult<RepoCollaboratorPermission> = async {
163 let _ = actor;
165 let repo = authz::load_repo_by_id(svc, repo_id).await?;
166 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
164167 let db = svc.db.reader();
165168 let collab = collab_entity::Entity::find()
166169 .filter(collab_entity::Column::RepoId.eq(repo_id))
Modified lib/repo/features/commit_status/usecase.rs View lib/repo/features/commit_status/usecase.rs
@@ -46,7 +46,8 @@
4646) -> RepoResult<CommitStatusPage> {
4747 let _timer = metrics::Timer::start(metrics::FEATURE_COMMIT_STATUSES_LIST);
4848 let result: RepoResult<CommitStatusPage> = async {
49 let _ = actor;
49 let repo = authz::load_repo_by_id(svc, repo_id).await?;
50 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
5051 let limit = q.limit.unwrap_or(svc.config.default_pagination);
5152 let offset = q.page.unwrap_or(0).saturating_mul(limit);
5253 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -121,7 +122,8 @@
121122) -> RepoResult<CommitStatusPage> {
122123 let _timer = metrics::Timer::start(metrics::FEATURE_COMMIT_STATUSES_LIST_BY_REF);
123124 let result: RepoResult<CommitStatusPage> = async {
124 let _ = actor;
125 let repo = authz::load_repo_by_id(svc, repo_id).await?;
126 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
125127 let sha = resolve_ref(svc, repo_id, git_ref).await?;
126128 list_commit_statuses(svc, actor, repo_id, &sha, q).await
127129 }
@@ -138,7 +140,8 @@
138140) -> RepoResult<CombinedStatusView> {
139141 let _timer = metrics::Timer::start(metrics::FEATURE_COMMIT_STATUSES_COMBINED_BY_REF);
140142 let result: RepoResult<CombinedStatusView> = async {
141 let _ = actor;
143 let repo = authz::load_repo_by_id(svc, repo_id).await?;
144 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
142145 let sha = resolve_ref(svc, repo_id, git_ref).await?;
143146 let db = svc.db.reader();
144147 let items = status_entity::Entity::find()
Modified lib/repo/features/compare/usecase.rs View lib/repo/features/compare/usecase.rs
@@ -1,22 +1,24 @@
1use crate::actor::RepoAccess;
12use crate::error::{RepoError, RepoResult};
2use crate::features::RepoService;
3use crate::features::{authz, RepoService};
34use crate::metrics;
45
56use super::CompareView;
67
78pub async fn compare(
8 _svc: &RepoService,
9 svc: &RepoService,
910 actor: uuid::Uuid,
10 _repo_id: uuid::Uuid,
11 repo_id: uuid::Uuid,
1112 base: &str,
1213 head: &str,
1314) -> RepoResult<CompareView> {
1415 let _timer = metrics::Timer::start(metrics::FEATURE_COMPARE);
1516 let result: RepoResult<CompareView> = async {
16 let _ = actor;
1717 if base.is_empty() || head.is_empty() {
1818 return Err(RepoError::BadRequest("base and head are required".into()));
1919 }
20 let repo = authz::load_repo_by_id(svc, repo_id).await?;
21 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
2022 Err(RepoError::Internal(
2123 "compare requires lib/git rewire (in progress)".into(),
2224 ))
Modified lib/repo/features/contents/usecase.rs View lib/repo/features/contents/usecase.rs
@@ -11,15 +11,16 @@
1111};
1212
1313pub async fn list_contents(
14 _svc: &RepoService,
14 svc: &RepoService,
1515 actor: Uuid,
16 _repo_id: Uuid,
16 repo_id: Uuid,
1717 path: &str,
1818 _q: ContentsQuery,
1919) -> RepoResult<ContentsListResponse> {
2020 let _timer = metrics::Timer::start(metrics::FEATURE_CONTENTS_LIST);
2121 let result: RepoResult<ContentsListResponse> = async {
22 let _ = actor;
22 let repo = authz::load_repo_by_id(svc, repo_id).await?;
23 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
2324 let _ = path;
2425 Err(RepoError::Internal(
2526 "contents listing requires lib/git rewire (in progress)".into(),
@@ -31,15 +32,16 @@
3132}
3233
3334pub async fn get_contents(
34 _svc: &RepoService,
35 svc: &RepoService,
3536 actor: Uuid,
36 _repo_id: Uuid,
37 repo_id: Uuid,
3738 path: &str,
3839 _q: ContentsQuery,
3940) -> RepoResult<ContentsResponse> {
4041 let _timer = metrics::Timer::start(metrics::FEATURE_CONTENTS_GET);
4142 let result: RepoResult<ContentsResponse> = async {
42 let _ = actor;
43 let repo = authz::load_repo_by_id(svc, repo_id).await?;
44 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
4345 let _ = path;
4446 Err(RepoError::Internal(
4547 "contents read requires lib/git rewire (in progress)".into(),
Modified lib/repo/features/deploy_key/usecase.rs View lib/repo/features/deploy_key/usecase.rs
@@ -30,7 +30,8 @@
3030) -> RepoResult<DeployKeyPage> {
3131 let _timer = metrics::Timer::start(metrics::FEATURE_DEPLOY_KEYS_LIST);
3232 let result: RepoResult<DeployKeyPage> = async {
33 let _ = actor;
33 let repo = authz::load_repo_by_id(svc, repo_id).await?;
34 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
3435 let limit = q.limit.unwrap_or(svc.config.default_pagination);
3536 let offset = q.page.unwrap_or(0).saturating_mul(limit);
3637 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -69,7 +70,8 @@
6970) -> RepoResult<DeployKeyView> {
7071 let _timer = metrics::Timer::start(metrics::FEATURE_DEPLOY_KEYS_GET);
7172 let result: RepoResult<DeployKeyView> = async {
72 let _ = actor;
73 let repo = authz::load_repo_by_id(svc, repo_id).await?;
74 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
7375 let key = load_key(svc, repo_id, key_id).await?;
7476 Ok(DeployKeyView::from(key))
7577 }
Modified lib/repo/features/git_ref/usecase.rs View lib/repo/features/git_ref/usecase.rs
@@ -1,17 +1,19 @@
1use crate::actor::RepoAccess;
12use crate::error::{RepoError, RepoResult};
2use crate::features::RepoService;
3use crate::features::{authz, RepoService};
34use crate::metrics;
45
56use super::{BlobView, CommitDiffPayload, CommitView, GitRef, NoteView, TreeView};
67
78pub async fn list_git_refs(
8 _svc: &RepoService,
9 svc: &RepoService,
910 actor: uuid::Uuid,
10 _repo_id: uuid::Uuid,
11 repo_id: uuid::Uuid,
1112) -> RepoResult<Vec<GitRef>> {
1213 let _timer = metrics::Timer::start(metrics::FEATURE_GIT_REFS_LIST);
1314 let result: RepoResult<Vec<GitRef>> = async {
14 let _ = actor;
15 let repo = authz::load_repo_by_id(svc, repo_id).await?;
16 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
1517 Err(RepoError::Internal(
1618 "list refs requires lib/git rewire (in progress)".into(),
1719 ))
@@ -22,14 +24,15 @@
2224}
2325
2426pub async fn filter_git_refs(
25 _svc: &RepoService,
27 svc: &RepoService,
2628 actor: uuid::Uuid,
27 _repo_id: uuid::Uuid,
29 repo_id: uuid::Uuid,
2830 _filter: &str,
2931) -> RepoResult<Vec<GitRef>> {
3032 let _timer = metrics::Timer::start(metrics::FEATURE_GIT_REFS_FILTER);
3133 let result: RepoResult<Vec<GitRef>> = async {
32 let _ = actor;
34 let repo = authz::load_repo_by_id(svc, repo_id).await?;
35 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
3336 Err(RepoError::Internal(
3437 "filter refs requires lib/git rewire (in progress)".into(),
3538 ))
@@ -40,17 +43,18 @@
4043}
4144
4245pub async fn get_commit(
43 _svc: &RepoService,
46 svc: &RepoService,
4447 actor: uuid::Uuid,
45 _repo_id: uuid::Uuid,
48 repo_id: uuid::Uuid,
4649 sha: &str,
4750) -> RepoResult<CommitView> {
4851 let _timer = metrics::Timer::start(metrics::FEATURE_GIT_REFS_GET_COMMIT);
4952 let result: RepoResult<CommitView> = async {
50 let _ = actor;
5153 if sha.is_empty() {
5254 return Err(RepoError::BadRequest("sha is required".into()));
5355 }
56 let repo = authz::load_repo_by_id(svc, repo_id).await?;
57 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
5458 Err(RepoError::Internal(
5559 "get_commit requires lib/git rewire (in progress)".into(),
5660 ))
@@ -61,15 +65,14 @@
6165}
6266
6367pub async fn get_commit_diff_or_patch(
64 _svc: &RepoService,
68 svc: &RepoService,
6569 actor: uuid::Uuid,
66 _repo_id: uuid::Uuid,
70 repo_id: uuid::Uuid,
6771 sha: &str,
6872 diff_type: &str,
6973) -> RepoResult<CommitDiffPayload> {
7074 let _timer = metrics::Timer::start(metrics::FEATURE_GIT_REFS_GET_COMMIT_DIFF_OR_PATCH);
7175 let result: RepoResult<CommitDiffPayload> = async {
72 let _ = actor;
7376 if diff_type != "diff" && diff_type != "patch" {
7477 return Err(RepoError::BadRequest(format!(
7578 "unsupported diff format: {diff_type}"
@@ -78,6 +81,8 @@
7881 if sha.is_empty() {
7982 return Err(RepoError::BadRequest("sha is required".into()));
8083 }
84 let repo = authz::load_repo_by_id(svc, repo_id).await?;
85 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
8186 Err(RepoError::Internal(
8287 "get_commit_diff_or_patch requires lib/git rewire (in progress)".into(),
8388 ))
@@ -88,18 +93,19 @@
8893}
8994
9095pub async fn get_tree(
91 _svc: &RepoService,
96 svc: &RepoService,
9297 actor: uuid::Uuid,
93 _repo_id: uuid::Uuid,
98 repo_id: uuid::Uuid,
9499 sha: &str,
95100 _recursive: bool,
96101) -> RepoResult<TreeView> {
97102 let _timer = metrics::Timer::start(metrics::FEATURE_GIT_REFS_GET_TREE);
98103 let result: RepoResult<TreeView> = async {
99 let _ = actor;
100104 if sha.is_empty() {
101105 return Err(RepoError::BadRequest("sha is required".into()));
102106 }
107 let repo = authz::load_repo_by_id(svc, repo_id).await?;
108 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
103109 Err(RepoError::Internal(
104110 "get_tree requires lib/git rewire (in progress)".into(),
105111 ))
@@ -110,17 +116,18 @@
110116}
111117
112118pub async fn get_blob(
113 _svc: &RepoService,
119 svc: &RepoService,
114120 actor: uuid::Uuid,
115 _repo_id: uuid::Uuid,
121 repo_id: uuid::Uuid,
116122 sha: &str,
117123) -> RepoResult<BlobView> {
118124 let _timer = metrics::Timer::start(metrics::FEATURE_GIT_REFS_GET_BLOB);
119125 let result: RepoResult<BlobView> = async {
120 let _ = actor;
121126 if sha.is_empty() {
122127 return Err(RepoError::BadRequest("sha is required".into()));
123128 }
129 let repo = authz::load_repo_by_id(svc, repo_id).await?;
130 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
124131 Err(RepoError::Internal(
125132 "get_blob requires lib/git rewire (in progress)".into(),
126133 ))
@@ -131,17 +138,18 @@
131138}
132139
133140pub async fn get_note(
134 _svc: &RepoService,
141 svc: &RepoService,
135142 actor: uuid::Uuid,
136 _repo_id: uuid::Uuid,
143 repo_id: uuid::Uuid,
137144 sha: &str,
138145) -> RepoResult<NoteView> {
139146 let _timer = metrics::Timer::start(metrics::FEATURE_GIT_REFS_GET_NOTE);
140147 let result: RepoResult<NoteView> = async {
141 let _ = actor;
142148 if sha.is_empty() {
143149 return Err(RepoError::BadRequest("sha is required".into()));
144150 }
151 let repo = authz::load_repo_by_id(svc, repo_id).await?;
152 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
145153 Err(RepoError::Internal(
146154 "get_note requires lib/git rewire (in progress)".into(),
147155 ))
Modified lib/repo/features/pull_request/usecase.rs View lib/repo/features/pull_request/usecase.rs
@@ -17,8 +17,9 @@
1717use time::OffsetDateTime;
1818use uuid::Uuid;
1919
20use crate::actor::RepoAccess;
2021use crate::error::{RepoError, RepoResult};
21use crate::features::RepoService;
22use crate::features::{authz, RepoService};
2223use crate::metrics;
2324use crate::path::{git_bare_path, validate_branch_name};
2425
@@ -327,7 +328,8 @@
327328) -> RepoResult<PullRequestPage> {
328329 let _timer = metrics::Timer::start(metrics::FEATURE_PULL_REQUESTS_LIST);
329330 let result: RepoResult<PullRequestPage> = async {
330 let _ = actor;
331 let repo = authz::load_repo_by_id(svc, repo_id).await?;
332 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
331333 let limit = query.limit.unwrap_or(svc.config.default_pagination);
332334 let offset = query.page.unwrap_or(0).saturating_mul(limit);
333335 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -388,7 +390,8 @@
388390) -> RepoResult<PullRequestView> {
389391 let _timer = metrics::Timer::start(metrics::FEATURE_PULL_REQUESTS_GET);
390392 let result: RepoResult<PullRequestView> = async {
391 let _ = actor;
393 let repo = authz::load_repo_by_id(svc, repo_id).await?;
394 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
392395 let pr = load_pr(svc, repo_id, number).await?;
393396 let pr = heal_merged(svc, repo_id, &pr).await;
394397 Ok(PullRequestView::from(pr))
@@ -837,7 +840,8 @@
837840) -> RepoResult<Vec<PullRequestCommentView>> {
838841 let _timer = metrics::Timer::start(metrics::FEATURE_PULL_REQUESTS_LIST_COMMENTS);
839842 let result: RepoResult<Vec<PullRequestCommentView>> = async {
840 let _ = actor;
843 let repo = authz::load_repo_by_id(svc, repo_id).await?;
844 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
841845 let pr = load_pr(svc, repo_id, number).await?;
842846 let db = svc.db.reader();
843847 let items: Vec<PullRequestCommentView> = comment_entity::Entity::find()
@@ -910,7 +914,8 @@
910914) -> RepoResult<Vec<PullRequestReviewView>> {
911915 let _timer = metrics::Timer::start(metrics::FEATURE_PULL_REQUESTS_LIST_REVIEWS);
912916 let result: RepoResult<Vec<PullRequestReviewView>> = async {
913 let _ = actor;
917 let repo = authz::load_repo_by_id(svc, repo_id).await?;
918 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
914919 let pr = load_pr(svc, repo_id, number).await?;
915920 let db = svc.db.reader();
916921 let items: Vec<PullRequestReviewView> = review_entity::Entity::find()
@@ -936,7 +941,8 @@
936941) -> RepoResult<Vec<PullRequestEventView>> {
937942 let _timer = metrics::Timer::start(metrics::FEATURE_PULL_REQUESTS_LIST_EVENTS);
938943 let result: RepoResult<Vec<PullRequestEventView>> = async {
939 let _ = actor;
944 let repo = authz::load_repo_by_id(svc, repo_id).await?;
945 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
940946 let pr = load_pr(svc, repo_id, number).await?;
941947 let db = svc.db.reader();
942948 let items: Vec<PullRequestEventView> = event_entity::Entity::find()
Modified lib/repo/features/release/usecase.rs View lib/repo/features/release/usecase.rs
@@ -35,7 +35,8 @@
3535) -> RepoResult<ReleasePage> {
3636 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_LIST);
3737 let result: RepoResult<ReleasePage> = async {
38 let _ = actor;
38 let repo = authz::load_repo_by_id(svc, repo_id).await?;
39 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
3940 let limit = q.limit.unwrap_or(svc.config.default_pagination);
4041 let offset = q.page.unwrap_or(0).saturating_mul(limit);
4142 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -77,7 +78,8 @@
7778) -> RepoResult<ReleaseView> {
7879 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_GET);
7980 let result: RepoResult<ReleaseView> = async {
80 let _ = actor;
81 let repo = authz::load_repo_by_id(svc, repo_id).await?;
82 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
8183 let release = load_release(svc, repo_id, release_id).await?;
8284 Ok(ReleaseView::from(release))
8385 }
@@ -93,7 +95,8 @@
9395) -> RepoResult<ReleaseView> {
9496 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_GET_LATEST);
9597 let result: RepoResult<ReleaseView> = async {
96 let _ = actor;
98 let repo = authz::load_repo_by_id(svc, repo_id).await?;
99 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
97100 let db = svc.db.reader();
98101 let release = release_entity::Entity::find()
99102 .filter(release_entity::Column::RepoId.eq(repo_id))
@@ -118,7 +121,8 @@
118121) -> RepoResult<ReleaseView> {
119122 let _timer = metrics::Timer::start(metrics::FEATURE_RELEASES_GET_BY_TAG);
120123 let result: RepoResult<ReleaseView> = async {
121 let _ = actor;
124 let repo = authz::load_repo_by_id(svc, repo_id).await?;
125 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
122126 let db = svc.db.reader();
123127 let release = release_entity::Entity::find()
124128 .filter(release_entity::Column::RepoId.eq(repo_id))
Modified lib/repo/features/subscriber/usecase.rs View lib/repo/features/subscriber/usecase.rs
@@ -1,8 +1,9 @@
11use db::conn::sea_orm::*;
22use db::repo::repo_watch as watch_entity;
33
4use crate::actor::RepoAccess;
45use crate::error::RepoResult;
5use crate::features::RepoService;
6use crate::features::{authz, RepoService};
67use crate::metrics;
78
89use super::{ListSubscriberQuery, SubscriberPage, SubscriberView};
@@ -15,7 +16,8 @@
1516) -> RepoResult<SubscriberPage> {
1617 let _timer = metrics::Timer::start(metrics::FEATURE_SUBSCRIBERS_LIST);
1718 let result: RepoResult<SubscriberPage> = async {
18 let _ = actor;
19 let repo = authz::load_repo_by_id(svc, repo_id).await?;
20 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
1921 let limit = q.limit.unwrap_or(svc.config.default_pagination);
2022 let offset = q.page.unwrap_or(0).saturating_mul(limit);
2123 let limit = std::cmp::min(limit, svc.config.max_pagination);
Modified lib/repo/features/sync/usecase.rs View lib/repo/features/sync/usecase.rs
@@ -18,7 +18,8 @@
1818) -> RepoResult<SyncStatus> {
1919 let _timer = metrics::Timer::start(metrics::FEATURE_SYNC_GET_STATUS);
2020 let result: RepoResult<SyncStatus> = async {
21 let _ = actor;
21 let repo = authz::load_repo_by_id(svc, repo_id).await?;
22 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
2223 let db = svc.db.reader();
2324 let branches_total = branch_entity::Entity::find()
2425 .filter(branch_entity::Column::RepoId.eq(repo_id))
Modified lib/repo/features/tag/usecase.rs View lib/repo/features/tag/usecase.rs
@@ -31,7 +31,8 @@
3131) -> RepoResult<TagPage> {
3232 let _timer = metrics::Timer::start(metrics::FEATURE_TAGS_LIST);
3333 let result: RepoResult<TagPage> = async {
34 let _ = actor;
34 let repo = authz::load_repo_by_id(svc, repo_id).await?;
35 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
3536 let limit = q.limit.unwrap_or(svc.config.default_pagination);
3637 let offset = q.page.unwrap_or(0).saturating_mul(limit);
3738 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -65,7 +66,8 @@
6566) -> RepoResult<TagView> {
6667 let _timer = metrics::Timer::start(metrics::FEATURE_TAGS_GET);
6768 let result: RepoResult<TagView> = async {
68 let _ = actor;
69 let repo = authz::load_repo_by_id(svc, repo_id).await?;
70 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
6971 let tag = load_tag(svc, repo_id, name).await?;
7072 Ok(TagView::from(tag))
7173 }
Modified lib/repo/features/tag_protection/usecase.rs View lib/repo/features/tag_protection/usecase.rs
@@ -48,7 +48,8 @@
4848) -> RepoResult<TagProtectionPage> {
4949 let _timer = metrics::Timer::start(metrics::FEATURE_TAG_PROTECTIONS_LIST);
5050 let result: RepoResult<TagProtectionPage> = async {
51 let _ = actor;
51 let repo = authz::load_repo_by_id(svc, repo_id).await?;
52 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
5253 let limit = q.limit.unwrap_or(svc.config.default_pagination);
5354 let offset = q.page.unwrap_or(0).saturating_mul(limit);
5455 let limit = std::cmp::min(limit, svc.config.max_pagination);
@@ -114,7 +115,8 @@
114115) -> RepoResult<TagProtectionView> {
115116 let _timer = metrics::Timer::start(metrics::FEATURE_TAG_PROTECTIONS_GET);
116117 let result: RepoResult<TagProtectionView> = async {
117 let _ = actor;
118 let repo = authz::load_repo_by_id(svc, repo_id).await?;
119 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
118120 let policy = load_policy(svc, repo_id, policy_id).await?;
119121 Ok(TagProtectionView::from(policy))
120122 }
Modified lib/repo/features/topic/usecase.rs View lib/repo/features/topic/usecase.rs
@@ -37,7 +37,8 @@
3737) -> RepoResult<TopicList> {
3838 let _timer = metrics::Timer::start(metrics::FEATURE_TOPICS_LIST);
3939 let result: RepoResult<TopicList> = async {
40 let _ = actor;
40 let repo = authz::load_repo_by_id(svc, repo_id).await?;
41 authz::authorize_repo(svc, actor, &repo, RepoAccess::Read).await?;
4142 let db = svc.db.reader();
4243 let links = db::repo::repo_relation_topic::Entity::find()
4344 .filter(db::repo::repo_relation_topic::Column::RepoId.eq(repo_id))
@@ -196,12 +197,50 @@
196197) -> RepoResult<TopicSearchResult> {
197198 let _timer = metrics::Timer::start(metrics::FEATURE_TOPICS_SEARCH);
198199 let result: RepoResult<TopicSearchResult> = async {
199 let _ = actor;
200 let db = svc.db.reader();
201 let visible_topic_ids: std::collections::HashSet<Uuid> = {
202 let (visible_channels, collab_repos) =
203 authz::visible_repo_scope(svc, actor).await?;
204 let mut link_query = db::repo::repo_relation_topic::Entity::find();
205 let mut cond = Condition::any();
206 if !visible_channels.is_empty() {
207 let repos_in_channels: Vec<Uuid> = db::repo::repos::Entity::find()
208 .select_only()
209 .column(db::repo::repos::Column::Id)
210 .filter(db::repo::repos::Column::ChannelId.is_in(visible_channels))
211 .into_tuple::<Uuid>()
212 .all(&db)
213 .await?;
214 if !repos_in_channels.is_empty() {
215 cond = cond.add(
216 db::repo::repo_relation_topic::Column::RepoId.is_in(repos_in_channels),
217 );
218 }
219 }
220 if !collab_repos.is_empty() {
221 cond = cond.add(
222 db::repo::repo_relation_topic::Column::RepoId.is_in(collab_repos),
223 );
224 }
225 if cond.is_empty() {
226 return Ok(TopicSearchResult { topics: vec![] });
227 }
228 link_query = link_query.filter(cond);
229 link_query
230 .all(&db)
231 .await?
232 .into_iter()
233 .map(|l| l.topic_id)
234 .collect()
235 };
236 if visible_topic_ids.is_empty() {
237 return Ok(TopicSearchResult { topics: vec![] });
238 }
200239 let limit = q.limit.unwrap_or(svc.config.default_pagination);
201240 let offset = q.page.unwrap_or(0).saturating_mul(limit);
202241 let limit = std::cmp::min(limit, svc.config.max_pagination);
203 let db = svc.db.reader();
204 let mut query = topic_entity::Entity::find();
242 let mut query = topic_entity::Entity::find()
243 .filter(topic_entity::Column::Id.is_in(visible_topic_ids));
205244 if let Some(s) = q.q.as_deref() {
206245 if !s.is_empty() {
207246 query = query.filter(topic_entity::Column::Name.contains(s));
Modified lib/repo/features/authz.rs View lib/repo/features/authz.rs
@@ -258,4 +258,70 @@
258258 RepoError::Forbidden(_)
259259 ));
260260 }
261}
262
263/// A single ref-update line from a git receive-pack payload, parsed from the
264/// pkt-line stream (`<old-oid> <new-oid> <refname>\0`).
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct RefUpdate {
267 pub old_oid: String,
268 pub new_oid: String,
269 pub refname: String,
270}
271
272/// `true` if `name` matches `pattern` using a tiny fnmatch-style glob:
273/// `*` matches any (possibly empty) run of characters; `?` matches one
274/// character; everything else is a literal byte. The implementation is
275/// linear-time and zero-allocation, sufficient for branch-name matching
276/// against user-supplied patterns.
277pub fn glob_match(pattern: &str, name: &str) -> bool {
278 let pat: Vec<char> = pattern.chars().collect();
279 let txt: Vec<char> = name.chars().collect();
280 fn rec(pat: &[char], txt: &[char]) -> bool {
281 match (pat.first(), txt.first()) {
282 (None, None) => true,
283 (Some('*'), _) => {
284 // Try matching zero chars, then one more each step.
285 rec(&pat[1..], txt)
286 || (!txt.is_empty() && rec(pat, &txt[1..]))
287 }
288 (Some('?'), Some(_)) => rec(&pat[1..], &txt[1..]),
289 (Some(a), Some(b)) if a == b => rec(&pat[1..], &txt[1..]),
290 _ => false,
291 }
292 }
293 rec(&pat, &txt)
294}
295
296/// Resolve the active branch-protection policy for a `(repo_id, branch_name)`
297/// pair. Matching order:
298/// 1. Exact `branch_name` match.
299/// 2. `branch_pattern` glob (any row whose non-null `branch_pattern` matches).
300pub async fn find_protection_for_branch(
301 db: &DatabaseConnection,
302 repo_id: Uuid,
303 branch_name: &str,
304) -> RepoResult<Option<db::repo::repo_branch_protection_policy::Model>> {
305 use db::repo::repo_branch_protection_policy as policy_entity;
306
307 // Step 1: exact match.
308 let exact = policy_entity::Entity::find()
309 .filter(policy_entity::Column::RepoId.eq(repo_id))
310 .filter(policy_entity::Column::BranchName.eq(branch_name))
311 .one(db)
312 .await?;
313 if exact.is_some() {
314 return Ok(exact);
315 }
316
317 // Step 2: pattern fallback. We fetch pattern rows and match in-process
318 // because glob semantics are application-defined (not stored in the DB).
319 let pattern_rows = policy_entity::Entity::find()
320 .filter(policy_entity::Column::RepoId.eq(repo_id))
321 .filter(policy_entity::Column::BranchPattern.is_not_null())
322 .all(db)
323 .await?;
324 Ok(pattern_rows
325 .into_iter()
326 .find(|m| m.branch_pattern.as_deref().is_some_and(|p| glob_match(p, branch_name))))
261327}
No newline at end of file
Modified lib/repo/service.rs View lib/repo/service.rs
@@ -48,6 +48,12 @@
4848 fn jwt_state(&self) -> &JwtState {
4949 &self.jwt
5050 }
51
52 fn access_blacklist(&self) -> Option<Arc<rate_limit::AccessTokenBlacklist>> {
53 Some(Arc::new(rate_limit::AccessTokenBlacklist::new(
54 self.kv.clone(),
55 )))
56 }
5157}
5258
5359impl RateLimitProvider for RepoService {
Modified lib/user/features/profile/impls.rs 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 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 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/features/suspensions/usecase.rs View lib/user/features/suspensions/usecase.rs
@@ -64,6 +64,7 @@
6464 .await?
6565 .ok_or_else(|| UserError::NotFound(format!("user {target}")))?;
6666 let mut user_active: user_entity::ActiveModel = user.into();
67 user_active.is_active = Set(false);
6768 user_active.is_restricted = Set(true);
6869 user_active.updated_at = Set(now);
6970 user_active.update(&txn).await?;
@@ -141,6 +142,7 @@
141142 && let Some(user) = user_entity::Entity::find_by_id(user_id).one(&txn).await?
142143 {
143144 let mut user_active: user_entity::ActiveModel = user.into();
145 user_active.is_active = Set(true);
144146 user_active.is_restricted = Set(false);
145147 user_active.updated_at = Set(now);
146148 user_active.update(&txn).await?;
Modified lib/user/handler/profile/profile_view.rs 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}
Modified Cargo.lock View Cargo.lock
@@ -990,11 +990,14 @@
990990dependencies = [
991991 "cache",
992992 "config 1.94.0",
993 "db",
993994 "futures",
994995 "jwt",
995996 "metrics",
996997 "object_store",
997998 "queue",
999 "rate_limit",
1000 "sea-orm",
9981001 "serde",
9991002 "serde_json",
10001003 "storage",
@@ -2445,6 +2448,7 @@
24452448 "bytes",
24462449 "cache",
24472450 "config 1.94.0",
2451 "db",
24482452 "flate2",
24492453 "futures",
24502454 "git",
@@ -4455,6 +4459,7 @@
44554459 "live",
44564460 "obs",
44574461 "queue",
4462 "rate_limit",
44584463 "serde",
44594464 "storage",
44604465 "storagelayer",