Skip to content
Clone

feat: delegate ref protection to KenShell

Protection policies lived only in this service, so a rule configured in the
UI never reached the push path: KenShell kept no protected refs and pushes
were never rejected.

Route every read and write through KenShell's RefProtection RPCs. Whitelists
are edited read-modify-write against the full entry list, since the remote
side replaces wholesale. merge_requirements and the branch policy loader now
match patterns against KenShell rules instead of local rows.

Whitelist entries keep both subject kind and purpose: a grant is always
'who may bypass, for which gate', and flattening either dimension loses
information.
ZhenYi committed
parent 126b767 commit d614ae0 Browse files

7 files changed

+611 −607

File tree 7
Modified lib/infra/proto/proto/kenshell/v1/ref.proto View lib/infra/proto/proto/kenshell/v1/ref.proto
@@ -15,12 +15,15 @@
1515 rpc ListTags(ListTagsRequest) returns (ListTagsResponse);
1616 rpc ListRefs(ListRefsRequest) returns (ListRefsResponse);
1717 rpc GetRef(GetRefRequest) returns (GetRefResponse);
18 rpc ListBranchProtections(ListBranchProtectionsRequest)
19 returns (ListBranchProtectionsResponse);
20 rpc CreateBranchProtection(CreateBranchProtectionRequest)
21 returns (CreateBranchProtectionResponse);
22 rpc DeleteBranchProtection(DeleteBranchProtectionRequest)
23 returns (DeleteBranchProtectionResponse);
18 // 保护规则:分支与标签共用一套字段,用 kind 区分。
19 rpc ListRefProtections(ListRefProtectionsRequest)
20 returns (ListRefProtectionsResponse);
21 rpc CreateRefProtection(CreateRefProtectionRequest)
22 returns (CreateRefProtectionResponse);
23 rpc UpdateRefProtection(UpdateRefProtectionRequest)
24 returns (UpdateRefProtectionResponse);
25 rpc DeleteRefProtection(DeleteRefProtectionRequest)
26 returns (DeleteRefProtectionResponse);
2427}
2528
2629enum BranchSort {
@@ -56,14 +59,102 @@
5659 google.protobuf.Timestamp repository_updated_at = 8;
5760}
5861
59message BranchProtectionView {
62enum RefProtectionKind {
63 REF_PROTECTION_KIND_UNSPECIFIED = 0;
64 REF_PROTECTION_KIND_BRANCH = 1;
65 REF_PROTECTION_KIND_TAG = 2;
66}
67
68enum WhitelistKind {
69 WHITELIST_KIND_UNSPECIFIED = 0;
70 WHITELIST_KIND_USER = 1;
71 WHITELIST_KIND_GROUP = 2;
72 WHITELIST_KIND_DEPLOY_KEY = 3;
73}
74
75// 保护规则的全部策略字段,创建与更新共用。
76message RefProtectionRules {
77 bool enable_push = 1;
78 bool enable_push_whitelist = 2;
79 bool push_whitelist_deploy_keys = 3;
80 bool enable_merge_whitelist = 4;
81 int32 required_approvals = 5;
82 bool enable_approvals_whitelist = 6;
83 bool block_on_rejected_reviews = 7;
84 bool block_on_change_requested_reviews = 8;
85 bool block_on_outdated_branch = 9;
86 bool dismiss_stale_approvals = 10;
87 bool require_signed_commits = 11;
88 // 逗号分隔的 glob 列表。
89 string protected_file_patterns = 12;
90 string unprotected_file_patterns = 13;
91 bool enable_force_push = 14;
92 string required_status_contexts = 15;
93}
94
95message RefProtectionWhitelist {
6096 string id = 1;
61 string branch = 2;
62 string ref_name = 3;
63 google.protobuf.Timestamp created_at = 4;
64 google.protobuf.Timestamp updated_at = 5;
97 WhitelistKind kind = 2;
98 // 用途:push / merge / approval。
99 string purpose = 5;
100 int64 user_id = 3;
101 // 组织(空间)内的组;user 与 deploy_key 时为空。
102 string group_id = 4;
103}
104
105message RefProtectionView {
106 string id = 1;
107 RefProtectionKind kind = 2;
108 // glob 模式,如 main、release/*、v*。
109 string pattern = 3;
110 RefProtectionRules rules = 4;
111 repeated RefProtectionWhitelist whitelists = 5;
112 google.protobuf.Timestamp created_at = 6;
113 google.protobuf.Timestamp updated_at = 7;
114}
115
116message ListRefProtectionsRequest {
117 kenshell.common.v1.RepositoryRef repository = 1;
118 // 不传或 UNSPECIFIED 表示两者都返回。
119 RefProtectionKind kind = 2;
120}
121
122message ListRefProtectionsResponse {
123 repeated RefProtectionView protections = 1;
124}
125
126message CreateRefProtectionRequest {
127 kenshell.common.v1.RepositoryRef repository = 1;
128 RefProtectionKind kind = 2;
129 string pattern = 3;
130 RefProtectionRules rules = 4;
131 repeated RefProtectionWhitelist whitelists = 5;
132}
133
134message CreateRefProtectionResponse {
135 RefProtectionView protection = 1;
136}
137
138message UpdateRefProtectionRequest {
139 kenshell.common.v1.RepositoryRef repository = 1;
140 string id = 2;
141 // 省略则保持原值。
142 string pattern = 3;
143 RefProtectionRules rules = 4;
144 repeated RefProtectionWhitelist whitelists = 5;
145}
146
147message UpdateRefProtectionResponse {
148 RefProtectionView protection = 1;
149}
150
151message DeleteRefProtectionRequest {
152 kenshell.common.v1.RepositoryRef repository = 1;
153 string id = 2;
65154}
66155
156message DeleteRefProtectionResponse {}
157
67158message TagView {
68159 string name = 1;
69160 string target_sha = 2;
@@ -155,31 +246,4 @@
155246message GetRefResponse {
156247 RefView ref = 1;
157248 kenshell.common.v1.ReadMeta meta = 2;
158}
159
160message ListBranchProtectionsRequest {
161 kenshell.common.v1.RepositoryRef repository = 1;
162}
163
164message ListBranchProtectionsResponse {
165 repeated BranchProtectionView protections = 1;
166}
167
168message CreateBranchProtectionRequest {
169 kenshell.common.v1.RepositoryRef repository = 1;
170 // Accepts "main" or "refs/heads/main"; stored as the normalized full ref.
171 string branch = 2;
172}
173
174message CreateBranchProtectionResponse {
175 BranchProtectionView protection = 1;
176}
177
178message DeleteBranchProtectionRequest {
179 kenshell.common.v1.RepositoryRef repository = 1;
180 string id = 2;
181}
182
183message DeleteBranchProtectionResponse {
184 bool removed = 1;
185249}
Modified lib/service/git/src/handler/whitelists.rs View lib/service/git/src/handler/whitelists.rs
@@ -64,7 +64,7 @@
6464 handler::require_repo_admin(&service, uid, p.space_name.clone(), p.repo_name.clone())
6565 .await?;
6666 let out = service
67 .add_branch_whitelist(ctx.model.id, p.policy_id, cmd)
67 .add_branch_whitelist(&ctx, p.policy_id, cmd)
6868 .await
6969 .map_err(GitApiError::from)?;
7070 Ok(AppResponseData::ok(out))
@@ -89,7 +89,7 @@
8989 handler::require_repo_admin(&service, uid, p.space_name.clone(), p.repo_name.clone())
9090 .await?;
9191 service
92 .remove_branch_whitelist(ctx.model.id, p.policy_id, p.whitelist_id)
92 .remove_branch_whitelist(&ctx, p.policy_id, p.whitelist_id)
9393 .await
9494 .map_err(GitApiError::from)?;
9595 Ok(AppResponseData::empty())
@@ -114,7 +114,7 @@
114114 handler::require_repo_admin(&service, uid, p.space_name.clone(), p.repo_name.clone())
115115 .await?;
116116 let out = service
117 .list_branch_whitelists(ctx.model.id, p.policy_id)
117 .list_branch_whitelists(&ctx, p.policy_id)
118118 .await
119119 .map_err(GitApiError::from)?;
120120 Ok(AppResponseData::ok(out))
@@ -141,7 +141,7 @@
141141 handler::require_repo_admin(&service, uid, p.space_name.clone(), p.repo_name.clone())
142142 .await?;
143143 let out = service
144 .add_tag_whitelist(ctx.model.id, p.policy_id, cmd)
144 .add_tag_whitelist(&ctx, p.policy_id, cmd)
145145 .await
146146 .map_err(GitApiError::from)?;
147147 Ok(AppResponseData::ok(out))
@@ -166,7 +166,7 @@
166166 handler::require_repo_admin(&service, uid, p.space_name.clone(), p.repo_name.clone())
167167 .await?;
168168 service
169 .remove_tag_whitelist(ctx.model.id, p.policy_id, p.user_id)
169 .remove_tag_whitelist(&ctx, p.policy_id, p.user_id)
170170 .await
171171 .map_err(GitApiError::from)?;
172172 Ok(AppResponseData::empty())
@@ -191,7 +191,7 @@
191191 handler::require_repo_admin(&service, uid, p.space_name.clone(), p.repo_name.clone())
192192 .await?;
193193 let out = service
194 .list_tag_whitelists(ctx.model.id, p.policy_id)
194 .list_tag_whitelists(&ctx, p.policy_id)
195195 .await
196196 .map_err(GitApiError::from)?;
197197 Ok(AppResponseData::ok(out))
Modified lib/service/git/src/service/protection.rs View lib/service/git/src/service/protection.rs
@@ -1,16 +1,12 @@
11use globset::Glob;
2use models::repo::{
3 repo_branch_protection_policy, repo_tag_protection_policy, repo_tag_protection_whitelists,
4};
5use sea_orm::ActiveValue::Set;
6use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
2use proto::kenshell::v1 as pb;
73use serde::{Deserialize, Serialize};
84use time::OffsetDateTime;
95use uuid::Uuid;
106
117use crate::context::facade::GitContext;
128use crate::service::GitService;
13use crate::share::errors::{GitError, is_unique_violation};
9use crate::share::errors::{GitError, map_baby};
1410
1511#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
1612pub struct BranchPolicyDto {
@@ -36,29 +32,31 @@
3632 pub updated_at: OffsetDateTime,
3733}
3834
39impl From<repo_branch_protection_policy::Model> for BranchPolicyDto {
40 fn from(m: repo_branch_protection_policy::Model) -> Self {
35impl BranchPolicyDto {
36 /// 规则全部来自 KenShell 的保护视图。
37 fn from_view(view: pb::RefProtectionView, repo_id: Uuid) -> Self {
38 let rules = view.rules.unwrap_or_default();
4139 Self {
42 id: m.id,
43 repo_id: m.repo_id,
44 branch_pattern: m.branch_pattern,
45 enable_push: m.enable_push,
46 enable_push_whitelist: m.enable_push_whitelist,
47 push_whitelist_deploy_keys: m.push_whitelist_deploy_keys,
48 enable_merge_whitelist: m.enable_merge_whitelist,
49 required_approvals: m.required_approvals,
50 enable_approvals_whitelist: m.enable_approvals_whitelist,
51 block_on_rejected_reviews: m.block_on_rejected_reviews,
52 block_on_change_requested_reviews: m.block_on_change_requested_reviews,
53 block_on_outdated_branch: m.block_on_outdated_branch,
54 dismiss_stale_approvals: m.dismiss_stale_approvals,
55 require_signed_commits: m.require_signed_commits,
56 protected_file_patterns: m.protected_file_patterns,
57 unprotected_file_patterns: m.unprotected_file_patterns,
58 enable_force_push: m.enable_force_push,
59 required_status_contexts: m.required_status_contexts,
60 created_at: m.created_at,
61 updated_at: m.updated_at,
40 id: Uuid::parse_str(&view.id).unwrap_or_default(),
41 repo_id,
42 branch_pattern: view.pattern,
43 enable_push: rules.enable_push,
44 enable_push_whitelist: rules.enable_push_whitelist,
45 push_whitelist_deploy_keys: rules.push_whitelist_deploy_keys,
46 enable_merge_whitelist: rules.enable_merge_whitelist,
47 required_approvals: rules.required_approvals,
48 enable_approvals_whitelist: rules.enable_approvals_whitelist,
49 block_on_rejected_reviews: rules.block_on_rejected_reviews,
50 block_on_change_requested_reviews: rules.block_on_change_requested_reviews,
51 block_on_outdated_branch: rules.block_on_outdated_branch,
52 dismiss_stale_approvals: rules.dismiss_stale_approvals,
53 require_signed_commits: rules.require_signed_commits,
54 protected_file_patterns: rules.protected_file_patterns,
55 unprotected_file_patterns: rules.unprotected_file_patterns,
56 enable_force_push: rules.enable_force_push,
57 required_status_contexts: rules.required_status_contexts,
58 created_at: at(view.created_at),
59 updated_at: at(view.updated_at),
6260 }
6361 }
6462}
@@ -210,6 +208,59 @@
210208 }
211209}
212210
211fn at(value: Option<prost_types::Timestamp>) -> OffsetDateTime {
212 value
213 .and_then(|at| OffsetDateTime::from_unix_timestamp(at.seconds).ok())
214 .unwrap_or(OffsetDateTime::UNIX_EPOCH)
215}
216
217/// 把「三态 PATCH 标志」叠加到现有规则上。
218fn apply_flags(mut rules: pb::RefProtectionRules, flags: &BranchPolicyFlags) -> pb::RefProtectionRules {
219 if let Some(v) = flags.enable_push {
220 rules.enable_push = v;
221 }
222 if let Some(v) = flags.enable_push_whitelist {
223 rules.enable_push_whitelist = v;
224 }
225 if let Some(v) = flags.enable_merge_whitelist {
226 rules.enable_merge_whitelist = v;
227 }
228 if let Some(v) = flags.required_approvals {
229 rules.required_approvals = v;
230 }
231 if let Some(v) = flags.enable_approvals_whitelist {
232 rules.enable_approvals_whitelist = v;
233 }
234 if let Some(v) = flags.block_on_rejected_reviews {
235 rules.block_on_rejected_reviews = v;
236 }
237 if let Some(v) = flags.block_on_change_requested_reviews {
238 rules.block_on_change_requested_reviews = v;
239 }
240 if let Some(v) = flags.block_on_outdated_branch {
241 rules.block_on_outdated_branch = v;
242 }
243 if let Some(v) = flags.dismiss_stale_approvals {
244 rules.dismiss_stale_approvals = v;
245 }
246 if let Some(v) = flags.require_signed_commits {
247 rules.require_signed_commits = v;
248 }
249 if let Some(v) = &flags.protected_file_patterns {
250 rules.protected_file_patterns = v.clone();
251 }
252 if let Some(v) = &flags.unprotected_file_patterns {
253 rules.unprotected_file_patterns = v.clone();
254 }
255 if let Some(v) = flags.enable_force_push {
256 rules.enable_force_push = v;
257 }
258 if let Some(v) = &flags.required_status_contexts {
259 rules.required_status_contexts = v.clone();
260 }
261 rules
262}
263
213264fn validate_pattern(pattern: &str) -> Result<(), GitError> {
214265 let trimmed = pattern.trim();
215266 if trimmed.is_empty() || trimmed.len() > 255 || trimmed.chars().any(|c| c.is_control()) {
@@ -243,17 +294,64 @@
243294 Ok(())
244295}
245296
297#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
298pub struct TagPolicyDto {
299 pub id: Uuid,
300 pub repo_id: Uuid,
301 pub name_pattern: String,
302 pub created_at: OffsetDateTime,
303 pub updated_at: OffsetDateTime,
304}
305
306impl TagPolicyDto {
307 fn from_view(view: pb::RefProtectionView, repo_id: Uuid) -> Self {
308 Self {
309 id: Uuid::parse_str(&view.id).unwrap_or_default(),
310 repo_id,
311 name_pattern: view.pattern,
312 created_at: at(view.created_at),
313 updated_at: at(view.updated_at),
314 }
315 }
316}
317
318#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
319pub struct CreateTagPolicyCmd {
320 pub name_pattern: String,
321}
322
323#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
324pub struct UpdateTagPolicyCmd {
325 #[serde(default)]
326 pub name_pattern: Option<String>,
327}
328
329fn validate_tag_pattern(pattern: &str) -> Result<(), GitError> {
330 let trimmed = pattern.trim();
331 if trimmed.is_empty() || trimmed.len() > 255 || trimmed.chars().any(|c| c.is_control()) {
332 return Err(GitError::InvalidArgument(
333 "name_pattern must be 1-255 printable characters".to_string(),
334 ));
335 }
336 Glob::new(trimmed)
337 .map_err(|e| GitError::InvalidArgument(format!("invalid tag pattern: {e}")))?;
338 Ok(())
339}
340
246341impl GitService {
247342 pub async fn list_branch_policies(
248343 &self,
249344 ctx: &GitContext,
250345 ) -> Result<Vec<BranchPolicyDto>, GitError> {
251 let rows = repo_branch_protection_policy::Entity::find()
252 .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id))
253 .order_by_asc(repo_branch_protection_policy::Column::BranchPattern)
254 .all(self.db_read())
255 .await?;
256 Ok(rows.into_iter().map(BranchPolicyDto::from).collect())
346 let rows = self
347 .baby(ctx)
348 .list_ref_protections("branch")
349 .await
350 .map_err(map_baby)?;
351 Ok(rows
352 .into_iter()
353 .map(|view| BranchPolicyDto::from_view(view, ctx.model.id))
354 .collect())
257355 }
258356
259357 pub async fn get_branch_policy(
@@ -261,12 +359,15 @@
261359 ctx: &GitContext,
262360 policy_id: Uuid,
263361 ) -> Result<BranchPolicyDto, GitError> {
264 let row = repo_branch_protection_policy::Entity::find_by_id(policy_id)
265 .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id))
266 .one(self.db_read())
267 .await?
268 .ok_or(GitError::NotFound)?;
269 Ok(BranchPolicyDto::from(row))
362 let rows = self
363 .baby(ctx)
364 .list_ref_protections("branch")
365 .await
366 .map_err(map_baby)?;
367 rows.into_iter()
368 .find(|view| view.id == policy_id.to_string())
369 .map(|view| BranchPolicyDto::from_view(view, ctx.model.id))
370 .ok_or(GitError::NotFound)
270371 }
271372
272373 pub async fn create_branch_policy(
@@ -276,47 +377,16 @@
276377 ) -> Result<BranchPolicyDto, GitError> {
277378 validate_pattern(&cmd.branch_pattern)?;
278379 let flags = cmd.flags();
279 let required = flags.required_approvals.unwrap_or(0);
280 validate_approvals(required)?;
281 let protected = flags.protected_file_patterns.unwrap_or_default();
282 let unprotected = flags.unprotected_file_patterns.unwrap_or_default();
283 validate_patterns(&protected)?;
284 validate_patterns(&unprotected)?;
285 let now = OffsetDateTime::now_utc();
286 let row = repo_branch_protection_policy::ActiveModel {
287 id: Set(Uuid::now_v7()),
288 repo_id: Set(ctx.model.id),
289 branch_pattern: Set(cmd.branch_pattern.trim().to_string()),
290 enable_push: Set(flags.enable_push.unwrap_or(true)),
291 enable_push_whitelist: Set(flags.enable_push_whitelist.unwrap_or(false)),
292 push_whitelist_deploy_keys: Set(false),
293 enable_merge_whitelist: Set(flags.enable_merge_whitelist.unwrap_or(false)),
294 required_approvals: Set(required),
295 enable_approvals_whitelist: Set(flags.enable_approvals_whitelist.unwrap_or(false)),
296 block_on_rejected_reviews: Set(flags.block_on_rejected_reviews.unwrap_or(true)),
297 block_on_change_requested_reviews: Set(flags
298 .block_on_change_requested_reviews
299 .unwrap_or(true)),
300 block_on_outdated_branch: Set(flags.block_on_outdated_branch.unwrap_or(false)),
301 dismiss_stale_approvals: Set(flags.dismiss_stale_approvals.unwrap_or(true)),
302 require_signed_commits: Set(flags.require_signed_commits.unwrap_or(false)),
303 protected_file_patterns: Set(protected),
304 unprotected_file_patterns: Set(unprotected),
305 enable_force_push: Set(flags.enable_force_push.unwrap_or(false)),
306 required_status_contexts: Set(flags.required_status_contexts.unwrap_or_default()),
307 created_at: Set(now),
308 updated_at: Set(now),
309 };
310 let created = match row.insert(self.db_write()).await {
311 Ok(created) => created,
312 Err(error) if is_unique_violation(&error) => {
313 return Err(GitError::Conflict(
314 "a protection policy for this pattern already exists".to_string(),
315 ));
316 }
317 Err(error) => return Err(error.into()),
318 };
319 Ok(BranchPolicyDto::from(created))
380 validate_approvals(flags.required_approvals.unwrap_or(0))?;
381 validate_patterns(flags.protected_file_patterns.as_deref().unwrap_or_default())?;
382 validate_patterns(flags.unprotected_file_patterns.as_deref().unwrap_or_default())?;
383 let rules = apply_flags(pb::RefProtectionRules::default(), &flags);
384 let view = self
385 .baby(ctx)
386 .create_ref_protection("branch", cmd.branch_pattern.trim(), rules, Vec::new())
387 .await
388 .map_err(map_baby)?;
389 Ok(BranchPolicyDto::from_view(view, ctx.model.id))
320390 }
321391
322392 pub async fn update_branch_policy(
@@ -325,80 +395,42 @@
325395 policy_id: Uuid,
326396 cmd: UpdateBranchPolicyCmd,
327397 ) -> Result<BranchPolicyDto, GitError> {
328 let row = repo_branch_protection_policy::Entity::find_by_id(policy_id)
329 .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id))
330 .one(self.db_read())
331 .await?
332 .ok_or(GitError::NotFound)?;
333398 let flags = cmd.flags();
334 let mut model: repo_branch_protection_policy::ActiveModel = row.into();
335 if let Some(pattern) = cmd.branch_pattern.as_deref() {
336 validate_pattern(pattern)?;
337 model.branch_pattern = Set(pattern.trim().to_string());
338 }
339 if let Some(count) = flags.required_approvals {
340 validate_approvals(count)?;
341 model.required_approvals = Set(count);
342 }
343 if let Some(raw) = flags.protected_file_patterns.as_deref() {
344 validate_patterns(raw)?;
345 model.protected_file_patterns = Set(raw.to_string());
346 }
347 if let Some(raw) = flags.unprotected_file_patterns.as_deref() {
348 validate_patterns(raw)?;
349 model.unprotected_file_patterns = Set(raw.to_string());
399 if let Some(value) = flags.required_approvals {
400 validate_approvals(value)?;
350401 }
351 for (flag, value) in [
352 (&flags.enable_push, &mut model.enable_push),
353 (
354 &flags.enable_push_whitelist,
355 &mut model.enable_push_whitelist,
356 ),
357 (
358 &flags.enable_merge_whitelist,
359 &mut model.enable_merge_whitelist,
360 ),
361 (
362 &flags.enable_approvals_whitelist,
363 &mut model.enable_approvals_whitelist,
364 ),
365 (
366 &flags.block_on_rejected_reviews,
367 &mut model.block_on_rejected_reviews,
368 ),
369 (
370 &flags.block_on_change_requested_reviews,
371 &mut model.block_on_change_requested_reviews,
372 ),
373 (
374 &flags.block_on_outdated_branch,
375 &mut model.block_on_outdated_branch,
376 ),
377 (
378 &flags.dismiss_stale_approvals,
379 &mut model.dismiss_stale_approvals,
380 ),
381 (
382 &flags.require_signed_commits,
383 &mut model.require_signed_commits,
384 ),
385 (&flags.enable_force_push, &mut model.enable_force_push),
386 ] {
387 if let Some(value_flag) = flag {
388 *value = Set(*value_flag);
389 }
390 }
391 model.updated_at = Set(OffsetDateTime::now_utc());
392 let updated = match model.update(self.db_write()).await {
393 Ok(updated) => updated,
394 Err(error) if is_unique_violation(&error) => {
395 return Err(GitError::Conflict(
396 "a protection policy for this pattern already exists".to_string(),
397 ));
398 }
399 Err(error) => return Err(error.into()),
400 };
401 Ok(BranchPolicyDto::from(updated))
402 validate_patterns(flags.protected_file_patterns.as_deref().unwrap_or_default())?;
403 validate_patterns(flags.unprotected_file_patterns.as_deref().unwrap_or_default())?;
404 let rows = self
405 .baby(ctx)
406 .list_ref_protections("branch")
407 .await
408 .map_err(map_baby)?;
409 let current = rows
410 .into_iter()
411 .find(|view| view.id == policy_id.to_string())
412 .ok_or(GitError::NotFound)?;
413 let base = current.rules.clone().unwrap_or_default();
414 let rules = apply_flags(base, &flags);
415 let pattern = cmd
416 .branch_pattern
417 .as_deref()
418 .map(str::trim)
419 .filter(|value| !value.is_empty())
420 .unwrap_or(&current.pattern)
421 .to_string();
422 validate_pattern(&pattern)?;
423 let view = self
424 .baby(ctx)
425 .update_ref_protection(
426 &policy_id.to_string(),
427 &pattern,
428 rules,
429 current.whitelists.clone(),
430 )
431 .await
432 .map_err(map_baby)?;
433 Ok(BranchPolicyDto::from_view(view, ctx.model.id))
402434 }
403435
404436 pub async fn delete_branch_policy(
@@ -406,70 +438,23 @@
406438 ctx: &GitContext,
407439 policy_id: Uuid,
408440 ) -> Result<(), GitError> {
409 let deleted = repo_branch_protection_policy::Entity::delete_many()
410 .filter(repo_branch_protection_policy::Column::Id.eq(policy_id))
411 .filter(repo_branch_protection_policy::Column::RepoId.eq(ctx.model.id))
412 .exec(self.db_write())
413 .await?;
414 if deleted.rows_affected == 0 {
415 return Err(GitError::NotFound);
416 }
441 self.baby(ctx)
442 .delete_ref_protection(&policy_id.to_string())
443 .await
444 .map_err(map_baby)?;
417445 Ok(())
418446 }
419}
420
421#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
422pub struct TagPolicyDto {
423 pub id: Uuid,
424 pub repo_id: Uuid,
425 pub name_pattern: String,
426 pub created_at: OffsetDateTime,
427 pub updated_at: OffsetDateTime,
428}
429
430impl From<repo_tag_protection_policy::Model> for TagPolicyDto {
431 fn from(m: repo_tag_protection_policy::Model) -> Self {
432 Self {
433 id: m.id,
434 repo_id: m.repo_id,
435 name_pattern: m.name_pattern,
436 created_at: m.created_at,
437 updated_at: m.updated_at,
438 }
439 }
440}
441
442#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
443pub struct CreateTagPolicyCmd {
444 pub name_pattern: String,
445}
446
447#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
448pub struct UpdateTagPolicyCmd {
449 #[serde(default)]
450 pub name_pattern: Option<String>,
451}
452
453fn validate_tag_pattern(pattern: &str) -> Result<(), GitError> {
454 let trimmed = pattern.trim();
455 if trimmed.is_empty() || trimmed.len() > 255 || trimmed.chars().any(|c| c.is_control()) {
456 return Err(GitError::InvalidArgument(
457 "name_pattern must be 1-255 printable characters".to_string(),
458 ));
459 }
460 Glob::new(trimmed)
461 .map_err(|e| GitError::InvalidArgument(format!("invalid tag pattern: {e}")))?;
462 Ok(())
463}
464447
465impl GitService {
466448 pub async fn list_tag_policies(&self, ctx: &GitContext) -> Result<Vec<TagPolicyDto>, GitError> {
467 let rows = repo_tag_protection_policy::Entity::find()
468 .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id))
469 .order_by_asc(repo_tag_protection_policy::Column::NamePattern)
470 .all(self.db_read())
471 .await?;
472 Ok(rows.into_iter().map(TagPolicyDto::from).collect())
449 let rows = self
450 .baby(ctx)
451 .list_ref_protections("tag")
452 .await
453 .map_err(map_baby)?;
454 Ok(rows
455 .into_iter()
456 .map(|view| TagPolicyDto::from_view(view, ctx.model.id))
457 .collect())
473458 }
474459
475460 pub async fn get_tag_policy(
@@ -477,12 +462,15 @@
477462 ctx: &GitContext,
478463 policy_id: Uuid,
479464 ) -> Result<TagPolicyDto, GitError> {
480 let row = repo_tag_protection_policy::Entity::find_by_id(policy_id)
481 .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id))
482 .one(self.db_read())
483 .await?
484 .ok_or(GitError::NotFound)?;
485 Ok(TagPolicyDto::from(row))
465 let rows = self
466 .baby(ctx)
467 .list_ref_protections("tag")
468 .await
469 .map_err(map_baby)?;
470 rows.into_iter()
471 .find(|view| view.id == policy_id.to_string())
472 .map(|view| TagPolicyDto::from_view(view, ctx.model.id))
473 .ok_or(GitError::NotFound)
486474 }
487475
488476 pub async fn create_tag_policy(
@@ -491,24 +479,17 @@
491479 cmd: CreateTagPolicyCmd,
492480 ) -> Result<TagPolicyDto, GitError> {
493481 validate_tag_pattern(&cmd.name_pattern)?;
494 let now = OffsetDateTime::now_utc();
495 let row = repo_tag_protection_policy::ActiveModel {
496 id: Set(Uuid::now_v7()),
497 repo_id: Set(ctx.model.id),
498 name_pattern: Set(cmd.name_pattern.trim().to_string()),
499 created_at: Set(now),
500 updated_at: Set(now),
501 };
502 let created = match row.insert(self.db_write()).await {
503 Ok(created) => created,
504 Err(error) if is_unique_violation(&error) => {
505 return Err(GitError::Conflict(
506 "a tag protection policy for this pattern already exists".to_string(),
507 ));
508 }
509 Err(error) => return Err(error.into()),
510 };
511 Ok(TagPolicyDto::from(created))
482 let view = self
483 .baby(ctx)
484 .create_ref_protection(
485 "tag",
486 cmd.name_pattern.trim(),
487 pb::RefProtectionRules::default(),
488 Vec::new(),
489 )
490 .await
491 .map_err(map_baby)?;
492 Ok(TagPolicyDto::from_view(view, ctx.model.id))
512493 }
513494
514495 pub async fn update_tag_policy(
@@ -517,27 +498,34 @@
517498 policy_id: Uuid,
518499 cmd: UpdateTagPolicyCmd,
519500 ) -> Result<TagPolicyDto, GitError> {
520 let row = repo_tag_protection_policy::Entity::find_by_id(policy_id)
521 .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id))
522 .one(self.db_read())
523 .await?
501 let rows = self
502 .baby(ctx)
503 .list_ref_protections("tag")
504 .await
505 .map_err(map_baby)?;
506 let current = rows
507 .into_iter()
508 .find(|view| view.id == policy_id.to_string())
524509 .ok_or(GitError::NotFound)?;
525 let mut model: repo_tag_protection_policy::ActiveModel = row.into();
526 if let Some(pattern) = cmd.name_pattern.as_deref() {
527 validate_tag_pattern(pattern)?;
528 model.name_pattern = Set(pattern.trim().to_string());
529 }
530 model.updated_at = Set(OffsetDateTime::now_utc());
531 let updated = match model.update(self.db_write()).await {
532 Ok(updated) => updated,
533 Err(error) if is_unique_violation(&error) => {
534 return Err(GitError::Conflict(
535 "a tag protection policy for this pattern already exists".to_string(),
536 ));
537 }
538 Err(error) => return Err(error.into()),
539 };
540 Ok(TagPolicyDto::from(updated))
510 let pattern = cmd
511 .name_pattern
512 .as_deref()
513 .map(str::trim)
514 .filter(|value| !value.is_empty())
515 .unwrap_or(&current.pattern)
516 .to_string();
517 validate_tag_pattern(&pattern)?;
518 let view = self
519 .baby(ctx)
520 .update_ref_protection(
521 &policy_id.to_string(),
522 &pattern,
523 current.rules.clone().unwrap_or_default(),
524 current.whitelists.clone(),
525 )
526 .await
527 .map_err(map_baby)?;
528 Ok(TagPolicyDto::from_view(view, ctx.model.id))
541529 }
542530
543531 pub async fn delete_tag_policy(
@@ -545,18 +533,10 @@
545533 ctx: &GitContext,
546534 policy_id: Uuid,
547535 ) -> Result<(), GitError> {
548 repo_tag_protection_whitelists::Entity::delete_many()
549 .filter(repo_tag_protection_whitelists::Column::PolicyId.eq(policy_id))
550 .exec(self.db_write())
551 .await?;
552 let deleted = repo_tag_protection_policy::Entity::delete_many()
553 .filter(repo_tag_protection_policy::Column::Id.eq(policy_id))
554 .filter(repo_tag_protection_policy::Column::RepoId.eq(ctx.model.id))
555 .exec(self.db_write())
556 .await?;
557 if deleted.rows_affected == 0 {
558 return Err(GitError::NotFound);
559 }
536 self.baby(ctx)
537 .delete_ref_protection(&policy_id.to_string())
538 .await
539 .map_err(map_baby)?;
560540 Ok(())
561541 }
562542}
Modified lib/service/git/src/service/whitelists.rs View lib/service/git/src/service/whitelists.rs
@@ -1,15 +1,38 @@
1use models::repo::repo_branch_protection_whitelists::{self, WhitelistKind};
2use models::repo::{
3 repo_branch_protection_policy, repo_tag_protection_policy, repo_tag_protection_whitelists,
4};
5use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
1use proto::kenshell::v1 as pb;
62use serde::{Deserialize, Serialize};
73use time::OffsetDateTime;
84use utoipa::ToSchema;
95use uuid::Uuid;
106
7use crate::context::facade::GitContext;
118use crate::service::GitService;
12use crate::share::errors::GitError;
9use crate::share::errors::{GitError, map_baby};
10
11/// 白名单用途。与 KenShell 的 `purpose` 一一对应。
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
13pub enum WhitelistKind {
14 Push,
15 Merge,
16 Approval,
17}
18
19impl WhitelistKind {
20 fn purpose(self) -> &'static str {
21 match self {
22 Self::Push => "push",
23 Self::Merge => "merge",
24 Self::Approval => "approval",
25 }
26 }
27
28 fn from_purpose(value: &str) -> Self {
29 match value {
30 "merge" => Self::Merge,
31 "approval" => Self::Approval,
32 _ => Self::Push,
33 }
34 }
35}
1336
1437#[derive(Debug, Clone, Serialize, ToSchema)]
1538pub struct BranchWhitelistDto {
@@ -22,20 +45,6 @@
2245 pub created_at: OffsetDateTime,
2346}
2447
25impl From<repo_branch_protection_whitelists::Model> for BranchWhitelistDto {
26 fn from(m: repo_branch_protection_whitelists::Model) -> Self {
27 Self {
28 id: m.id,
29 policy_id: m.policy_id,
30 kind: m.kind,
31 user_id: m.user_id,
32 user: None,
33 group_id: m.group_id,
34 created_at: OffsetDateTime::now_utc(),
35 }
36 }
37}
38
3948#[derive(Debug, Clone, Deserialize, ToSchema)]
4049pub struct AddBranchWhitelistCmd {
4150 pub kind: WhitelistKind,
@@ -50,36 +59,12 @@
5059 pub user: Option<api::users::UserDto>,
5160}
5261
53impl From<repo_tag_protection_whitelists::Model> for TagWhitelistDto {
54 fn from(m: repo_tag_protection_whitelists::Model) -> Self {
55 Self {
56 policy_id: m.policy_id,
57 user_id: m.user_id,
58 user: None,
59 }
60 }
61}
62
6362#[derive(Debug, Clone, Deserialize, ToSchema)]
6463pub struct AddTagWhitelistCmd {
6564 pub user_id: i64,
6665}
6766
6867impl GitService {
69 async fn embed_whitelist_users(
70 &self,
71 mut rows: Vec<BranchWhitelistDto>,
72 ) -> Result<Vec<BranchWhitelistDto>, GitError> {
73 let uids: Vec<i64> = rows.iter().filter_map(|r| r.user_id).collect();
74 if !uids.is_empty() {
75 let map = self.user_dto_map(&uids).await?;
76 for row in rows.iter_mut() {
77 row.user = row.user_id.and_then(|u| map.get(&u).cloned());
78 }
79 }
80 Ok(rows)
81 }
82
8368 pub(crate) async fn user_dto_map(
8469 &self,
8570 uids: &[i64],
@@ -95,180 +80,224 @@
9580 Ok(map)
9681 }
9782
98 async fn ensure_branch_policy(
83 /// 取出单条保护规则;白名单以它为准做读改写。
84 async fn fetch_protection(
9985 &self,
86 ctx: &GitContext,
10087 policy_id: Uuid,
101 ) -> Result<repo_branch_protection_policy::Model, GitError> {
102 repo_branch_protection_policy::Entity::find_by_id(policy_id)
103 .one(self.db_read())
104 .await?
88 ref_kind: &str,
89 ) -> Result<pb::RefProtectionView, GitError> {
90 let rows = self
91 .baby(ctx)
92 .list_ref_protections(ref_kind)
93 .await
94 .map_err(map_baby)?;
95 rows.into_iter()
96 .find(|view| view.id == policy_id.to_string())
10597 .ok_or(GitError::NotFound)
10698 }
10799
108 async fn ensure_tag_policy(
100 /// 把整份白名单写回 KenShell(KenShell 侧按全量替换语义处理)。
101 async fn write_whitelists(
102 &self,
103 ctx: &GitContext,
104 current: &pb::RefProtectionView,
105 entries: Vec<pb::RefProtectionWhitelist>,
106 ) -> Result<pb::RefProtectionView, GitError> {
107 self.baby(ctx)
108 .update_ref_protection(
109 &current.id,
110 &current.pattern,
111 current.rules.clone().unwrap_or_default(),
112 entries,
113 )
114 .await
115 .map_err(map_baby)
116 }
117
118 fn entry_matches(
119 entry: &pb::RefProtectionWhitelist,
120 purpose: &str,
121 user_id: Option<i64>,
122 group_id: Option<Uuid>,
123 ) -> bool {
124 entry.purpose == purpose
125 && entry.user_id == user_id.unwrap_or_default()
126 && entry.group_id == group_id.map(|id| id.to_string()).unwrap_or_default()
127 }
128
129 async fn branch_dto(
109130 &self,
131 entry: &pb::RefProtectionWhitelist,
110132 policy_id: Uuid,
111 ) -> Result<repo_tag_protection_policy::Model, GitError> {
112 repo_tag_protection_policy::Entity::find_by_id(policy_id)
113 .one(self.db_read())
114 .await?
115 .ok_or(GitError::NotFound)
133 ) -> BranchWhitelistDto {
134 let user_id = (entry.user_id > 0).then_some(entry.user_id);
135 let user = match user_id {
136 Some(uid) => self
137 .user_dto_map(&[uid])
138 .await
139 .ok()
140 .and_then(|mut map| map.remove(&uid)),
141 None => None,
142 };
143 BranchWhitelistDto {
144 id: Uuid::parse_str(&entry.id).unwrap_or_default(),
145 policy_id,
146 kind: WhitelistKind::from_purpose(&entry.purpose),
147 user_id,
148 user,
149 group_id: Uuid::parse_str(entry.group_id.trim()).ok(),
150 created_at: OffsetDateTime::now_utc(),
151 }
116152 }
117153
118154 pub async fn add_branch_whitelist(
119155 &self,
120 repo_id: Uuid,
156 ctx: &GitContext,
121157 policy_id: Uuid,
122158 cmd: AddBranchWhitelistCmd,
123159 ) -> Result<BranchWhitelistDto, GitError> {
124 let policy = self.ensure_branch_policy(policy_id).await?;
125 if policy.repo_id != repo_id {
126 return Err(GitError::NotFound);
127 }
128160 if cmd.user_id.is_some() == cmd.group_id.is_some() {
129161 return Err(GitError::InvalidArgument(
130162 "exactly one of user_id or group_id must be set".to_string(),
131163 ));
132164 }
133 let existing = repo_branch_protection_whitelists::Entity::find()
134 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
135 .filter(repo_branch_protection_whitelists::Column::Kind.eq(cmd.kind))
136 .filter(repo_branch_protection_whitelists::Column::UserId.eq(cmd.user_id))
137 .filter(repo_branch_protection_whitelists::Column::GroupId.eq(cmd.group_id))
138 .one(self.db_read())
139 .await?;
140 if let Some(row) = existing {
141 return Ok(self
142 .embed_whitelist_users(vec![row.into()])
143 .await?
144 .into_iter()
145 .next()
146 .unwrap());
165 let current = self.fetch_protection(ctx, policy_id, "branch").await?;
166 let purpose = cmd.kind.purpose();
167 if let Some(existing) = current
168 .whitelists
169 .iter()
170 .find(|entry| Self::entry_matches(entry, purpose, cmd.user_id, cmd.group_id))
171 {
172 return Ok(self.branch_dto(existing, policy_id).await);
147173 }
148 let am = repo_branch_protection_whitelists::ActiveModel {
149 id: Set(Uuid::now_v7()),
150 policy_id: Set(policy_id),
151 kind: Set(cmd.kind),
152 user_id: Set(cmd.user_id),
153 group_id: Set(cmd.group_id),
154 };
155 let saved = am.insert(self.db_write()).await?;
156 Ok(self
157 .embed_whitelist_users(vec![saved.into()])
158 .await?
159 .into_iter()
160 .next()
161 .unwrap())
174 let mut entries = current.whitelists.clone();
175 entries.push(pb::RefProtectionWhitelist {
176 id: String::new(),
177 kind: if cmd.group_id.is_some() {
178 pb::WhitelistKind::Group as i32
179 } else {
180 pb::WhitelistKind::User as i32
181 },
182 purpose: purpose.to_string(),
183 user_id: cmd.user_id.unwrap_or_default(),
184 group_id: cmd.group_id.map(|id| id.to_string()).unwrap_or_default(),
185 });
186 let view = self.write_whitelists(ctx, &current, entries).await?;
187 let entry = view
188 .whitelists
189 .iter()
190 .find(|entry| Self::entry_matches(entry, purpose, cmd.user_id, cmd.group_id))
191 .ok_or(GitError::NotFound)?;
192 Ok(self.branch_dto(entry, policy_id).await)
162193 }
163194
164195 pub async fn remove_branch_whitelist(
165196 &self,
166 repo_id: Uuid,
197 ctx: &GitContext,
167198 policy_id: Uuid,
168199 whitelist_id: Uuid,
169200 ) -> Result<(), GitError> {
170 let policy = self.ensure_branch_policy(policy_id).await?;
171 if policy.repo_id != repo_id {
201 let current = self.fetch_protection(ctx, policy_id, "branch").await?;
202 let entries: Vec<_> = current
203 .whitelists
204 .iter()
205 .filter(|entry| entry.id != whitelist_id.to_string())
206 .cloned()
207 .collect();
208 if entries.len() == current.whitelists.len() {
172209 return Err(GitError::NotFound);
173210 }
174 repo_branch_protection_whitelists::Entity::delete_by_id(whitelist_id)
175 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
176 .exec(self.db_write())
177 .await?;
211 self.write_whitelists(ctx, &current, entries).await?;
178212 Ok(())
179213 }
180214
181215 pub async fn list_branch_whitelists(
182216 &self,
183 repo_id: Uuid,
217 ctx: &GitContext,
184218 policy_id: Uuid,
185219 ) -> Result<Vec<BranchWhitelistDto>, GitError> {
186 let policy = self.ensure_branch_policy(policy_id).await?;
187 if policy.repo_id != repo_id {
188 return Err(GitError::NotFound);
220 let current = self.fetch_protection(ctx, policy_id, "branch").await?;
221 let mut out = Vec::with_capacity(current.whitelists.len());
222 for entry in &current.whitelists {
223 out.push(self.branch_dto(entry, policy_id).await);
189224 }
190 let rows = repo_branch_protection_whitelists::Entity::find()
191 .filter(repo_branch_protection_whitelists::Column::PolicyId.eq(policy_id))
192 .all(self.db_read())
193 .await?;
194 self.embed_whitelist_users(rows.into_iter().map(Into::into).collect())
195 .await
225 Ok(out)
196226 }
197227
198228 pub async fn add_tag_whitelist(
199229 &self,
200 repo_id: Uuid,
230 ctx: &GitContext,
201231 policy_id: Uuid,
202232 cmd: AddTagWhitelistCmd,
203233 ) -> Result<TagWhitelistDto, GitError> {
204 let policy = self.ensure_tag_policy(policy_id).await?;
205 if policy.repo_id != repo_id {
206 return Err(GitError::NotFound);
207 }
208 let existing = repo_tag_protection_whitelists::Entity::find_by_id((policy_id, cmd.user_id))
209 .one(self.db_read())
210 .await?;
211 if let Some(row) = existing {
212 let mut dto: TagWhitelistDto = row.into();
213 dto.user = self
214 .user_dto_map(&[dto.user_id])
215 .await?
216 .get(&dto.user_id)
217 .cloned();
218 return Ok(dto);
234 let current = self.fetch_protection(ctx, policy_id, "tag").await?;
235 if !current
236 .whitelists
237 .iter()
238 .any(|entry| entry.user_id == cmd.user_id)
239 {
240 let mut entries = current.whitelists.clone();
241 entries.push(pb::RefProtectionWhitelist {
242 id: String::new(),
243 kind: pb::WhitelistKind::User as i32,
244 purpose: "push".to_string(),
245 user_id: cmd.user_id,
246 group_id: String::new(),
247 });
248 self.write_whitelists(ctx, &current, entries).await?;
219249 }
220 let am = repo_tag_protection_whitelists::ActiveModel {
221 policy_id: Set(policy_id),
222 user_id: Set(cmd.user_id),
223 };
224 let saved = TransactionTrait::transaction(self.db_write(), |txn| {
225 Box::pin(async move { am.insert(txn).await.map_err(GitError::from) })
250 let user = self
251 .user_dto_map(&[cmd.user_id])
252 .await
253 .ok()
254 .and_then(|mut map| map.remove(&cmd.user_id));
255 Ok(TagWhitelistDto {
256 policy_id,
257 user_id: cmd.user_id,
258 user,
226259 })
227 .await?;
228 let mut dto: TagWhitelistDto = saved.into();
229 dto.user = self
230 .user_dto_map(&[dto.user_id])
231 .await?
232 .get(&dto.user_id)
233 .cloned();
234 Ok(dto)
235260 }
236261
237262 pub async fn remove_tag_whitelist(
238263 &self,
239 repo_id: Uuid,
264 ctx: &GitContext,
240265 policy_id: Uuid,
241266 user_id: i64,
242267 ) -> Result<(), GitError> {
243 let policy = self.ensure_tag_policy(policy_id).await?;
244 if policy.repo_id != repo_id {
245 return Err(GitError::NotFound);
246 }
247 repo_tag_protection_whitelists::Entity::delete_by_id((policy_id, user_id))
248 .exec(self.db_write())
249 .await?;
268 let current = self.fetch_protection(ctx, policy_id, "tag").await?;
269 let entries: Vec<_> = current
270 .whitelists
271 .iter()
272 .filter(|entry| entry.user_id != user_id)
273 .cloned()
274 .collect();
275 self.write_whitelists(ctx, &current, entries).await?;
250276 Ok(())
251277 }
252278
253279 pub async fn list_tag_whitelists(
254280 &self,
255 repo_id: Uuid,
281 ctx: &GitContext,
256282 policy_id: Uuid,
257283 ) -> Result<Vec<TagWhitelistDto>, GitError> {
258 let policy = self.ensure_tag_policy(policy_id).await?;
259 if policy.repo_id != repo_id {
260 return Err(GitError::NotFound);
261 }
262 let rows = repo_tag_protection_whitelists::Entity::find()
263 .filter(repo_tag_protection_whitelists::Column::PolicyId.eq(policy_id))
264 .all(self.db_read())
265 .await?;
266 let mut out: Vec<TagWhitelistDto> = rows.into_iter().map(Into::into).collect();
267 let uids: Vec<i64> = out.iter().map(|t| t.user_id).collect();
268 let map = self.user_dto_map(&uids).await?;
269 for t in out.iter_mut() {
270 t.user = map.get(&t.user_id).cloned();
271 }
272 Ok(out)
284 let current = self.fetch_protection(ctx, policy_id, "tag").await?;
285 let uids: Vec<i64> = current
286 .whitelists
287 .iter()
288 .map(|entry| entry.user_id)
289 .filter(|uid| *uid > 0)
290 .collect();
291 let mut map = self.user_dto_map(&uids).await?;
292 Ok(current
293 .whitelists
294 .iter()
295 .filter(|entry| entry.user_id > 0)
296 .map(|entry| TagWhitelistDto {
297 policy_id,
298 user_id: entry.user_id,
299 user: map.remove(&entry.user_id),
300 })
301 .collect())
273302 }
274303}
Modified lib/service/git/src/share/policy.rs View lib/service/git/src/share/policy.rs
@@ -1,9 +1,4 @@
11use globset::{Glob, GlobMatcher};
2use models::repo::{
3 repo_branch_protection_policy, repo_branch_protection_whitelists, repo_tag_protection_policy,
4};
5use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use uuid::Uuid;
72
83use crate::share::errors::GitError;
94
@@ -33,108 +28,27 @@
3328 }
3429}
3530
31/// 分支保护模式,全部来自 KenShell 的保护规则。
3632pub async fn load_branch_policy(
37 db: &db::AppDatabase,
38 repo_id: Uuid,
33 svc: &crate::service::GitService,
34 ctx: &crate::context::facade::GitContext,
3935) -> Result<BranchPolicy, GitError> {
40 let rows = repo_branch_protection_policy::Entity::find()
41 .filter(repo_branch_protection_policy::Column::RepoId.eq(repo_id))
42 .all(db.read())
43 .await?;
44 BranchPolicy::from_patterns(rows.into_iter().map(|r| r.branch_pattern))
36 let rows = svc
37 .baby(ctx)
38 .list_ref_protections("branch")
39 .await
40 .map_err(crate::share::errors::map_baby)?;
41 BranchPolicy::from_patterns(rows.into_iter().map(|view| view.pattern))
4542}
4643
4744pub async fn load_tag_policy(
48 db: &db::AppDatabase,
49 repo_id: Uuid,
45 svc: &crate::service::GitService,
46 ctx: &crate::context::facade::GitContext,
5047) -> Result<BranchPolicy, GitError> {
51 let rows = repo_tag_protection_policy::Entity::find()
52 .filter(repo_tag_protection_policy::Column::RepoId.eq(repo_id))
53 .all(db.read())
54 .await?;
55 BranchPolicy::from_patterns(rows.into_iter().map(|r| r.name_pattern))
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum WhitelistSubject {
60 User(i64),
61 #[allow(dead_code)]
62 Group(Uuid),
63}
64
65#[derive(Debug, Default)]
66pub struct WhitelistSet {
67 pub push_allowed: Vec<i64>,
68 pub merge_allowed: Vec<i64>,
69 pub approval_allowed: Vec<i64>,
70}
71
72impl WhitelistSet {}
73
74pub async fn load_branch_whitelists(
75 db: &impl sea_orm::ConnectionTrait,
76 policy_ids: &[Uuid],
77) -> Result<WhitelistSet, GitError> {
78 if policy_ids.is_empty() {
79 return Ok(WhitelistSet::default());
80 }
81 let rows = repo_branch_protection_whitelists::Entity::find()
82 .filter(
83 repo_branch_protection_whitelists::Column::PolicyId.is_in(policy_ids.iter().copied()),
84 )
85 .all(db)
86 .await?;
87 let mut out = WhitelistSet::default();
88 for row in rows {
89 let Some(uid) = row.user_id else { continue };
90 match row.kind {
91 repo_branch_protection_whitelists::WhitelistKind::Push => out.push_allowed.push(uid),
92 repo_branch_protection_whitelists::WhitelistKind::Merge => out.merge_allowed.push(uid),
93 repo_branch_protection_whitelists::WhitelistKind::Approval => {
94 out.approval_allowed.push(uid)
95 }
96 }
97 }
98 Ok(out)
99}
100
101/// 按分支名匹配启用的保护政策(精确名优先,其次最长通配)。
102pub async fn match_branch_policies(
103 db: &impl sea_orm::ConnectionTrait,
104 repo_id: Uuid,
105 branch: &str,
106) -> Result<Vec<repo_branch_protection_policy::Model>, GitError> {
107 let rows = repo_branch_protection_policy::Entity::find()
108 .filter(repo_branch_protection_policy::Column::RepoId.eq(repo_id))
109 .all(db)
110 .await?;
111 let mut matched: Vec<repo_branch_protection_policy::Model> = Vec::new();
112 for row in rows {
113 if BranchPolicy::from_patterns([row.branch_pattern.clone()])?.is_match(branch) {
114 matched.push(row);
115 }
116 }
117 matched.sort_by_key(|row| {
118 (
119 row.branch_pattern != branch,
120 -(row.branch_pattern.len() as i32),
121 )
122 });
123 let exact: Option<String> = matched
124 .iter()
125 .find(|row| row.branch_pattern == branch)
126 .map(|row| row.branch_pattern.clone());
127 if let Some(pattern) = exact {
128 matched.retain(|row| row.branch_pattern == pattern);
129 }
130 Ok(matched)
131}
132
133/// 政策对应的白名单集合。
134pub async fn branch_policy_whitelists(
135 db: &impl sea_orm::ConnectionTrait,
136 policies: &[repo_branch_protection_policy::Model],
137) -> Result<WhitelistSet, GitError> {
138 let ids: Vec<Uuid> = policies.iter().map(|row| row.id).collect();
139 load_branch_whitelists(db, &ids).await
48 let rows = svc
49 .baby(ctx)
50 .list_ref_protections("tag")
51 .await
52 .map_err(crate::share::errors::map_baby)?;
53 BranchPolicy::from_patterns(rows.into_iter().map(|view| view.pattern))
14054}
Modified lib/service/git/src/facade.rs View lib/service/git/src/facade.rs
@@ -51,58 +51,68 @@
5151 .is_some_and(|space| space.id() == space_id))
5252 }
5353
54 /// 合并要求:规则全部来自 KenShell 的保护视图。
5455 pub async fn merge_requirements(
5556 &self,
56 repository: Uuid,
57 ctx: &GitContext,
5758 branch: &str,
5859 caller: i64,
5960 ) -> Result<Option<MergeRequirements>, GitError> {
60 let policies =
61 crate::share::policy::match_branch_policies(self.database.read(), repository, branch)
62 .await?;
63 if policies.is_empty() {
61 let rows = self
62 .baby(ctx)
63 .list_ref_protections("branch")
64 .await
65 .map_err(crate::share::errors::map_baby)?;
66 let matched: Vec<_> = rows
67 .into_iter()
68 .filter(|view| pattern_matches(&view.pattern, branch))
69 .collect();
70 if matched.is_empty() {
6471 return Ok(None);
6572 }
66 let whitelists =
67 crate::share::policy::branch_policy_whitelists(self.database.read(), &policies).await?;
73 let rules: Vec<proto::kenshell::v1::RefProtectionRules> = matched
74 .iter()
75 .map(|view| view.rules.clone().unwrap_or_default())
76 .collect();
77 let merge_allowed: Vec<i64> = matched
78 .iter()
79 .flat_map(|view| view.whitelists.iter())
80 .filter(|entry| entry.kind == proto::kenshell::v1::WhitelistKind::User as i32)
81 .map(|entry| entry.user_id)
82 .collect();
6883 Ok(Some(MergeRequirements {
69 required_approvals: policies
84 required_approvals: rules
7085 .iter()
71 .map(|policy| policy.required_approvals)
86 .map(|rule| rule.required_approvals)
7287 .max()
7388 .unwrap_or(0),
74 dismiss_stale_approvals: policies.iter().any(|policy| policy.dismiss_stale_approvals),
75 whitelist_bypass: policies.iter().any(|policy| policy.enable_merge_whitelist)
76 && whitelists.merge_allowed.contains(&caller),
77 block_on_rejected_reviews: policies
78 .iter()
79 .any(|policy| policy.block_on_rejected_reviews),
80 block_on_change_requested_reviews: policies
81 .iter()
82 .any(|policy| policy.block_on_change_requested_reviews),
83 block_on_outdated_branch: policies
89 dismiss_stale_approvals: rules.iter().any(|rule| rule.dismiss_stale_approvals),
90 whitelist_bypass: rules.iter().any(|rule| rule.enable_merge_whitelist)
91 && merge_allowed.contains(&caller),
92 block_on_rejected_reviews: rules.iter().any(|rule| rule.block_on_rejected_reviews),
93 block_on_change_requested_reviews: rules
8494 .iter()
85 .any(|policy| policy.block_on_outdated_branch),
86 require_signed_commits: policies.iter().any(|policy| policy.require_signed_commits),
87 required_status_contexts: policies
95 .any(|rule| rule.block_on_change_requested_reviews),
96 block_on_outdated_branch: rules.iter().any(|rule| rule.block_on_outdated_branch),
97 require_signed_commits: rules.iter().any(|rule| rule.require_signed_commits),
98 required_status_contexts: rules
8899 .iter()
89 .flat_map(|policy| {
90 policy
91 .required_status_contexts
100 .flat_map(|rule| {
101 rule.required_status_contexts
92102 .split(['\n', ','])
93103 .map(str::trim)
94104 .filter(|value| !value.is_empty())
95105 .map(str::to_owned)
96106 })
97107 .collect(),
98 protected_file_patterns: policies
108 protected_file_patterns: rules
99109 .iter()
100 .map(|policy| policy.protected_file_patterns.as_str())
110 .map(|rule| rule.protected_file_patterns.as_str())
101111 .collect::<Vec<_>>()
102112 .join(","),
103 unprotected_file_patterns: policies
113 unprotected_file_patterns: rules
104114 .iter()
105 .map(|policy| policy.unprotected_file_patterns.as_str())
115 .map(|rule| rule.unprotected_file_patterns.as_str())
106116 .collect::<Vec<_>>()
107117 .join(","),
108118 }))
@@ -133,6 +143,13 @@
133143 }
134144}
135145
146/// 用 glob 判断保护模式是否命中分支名。
147fn pattern_matches(pattern: &str, branch: &str) -> bool {
148 globset::Glob::new(pattern)
149 .map(|glob| glob.compile_matcher().is_match(branch))
150 .unwrap_or(false)
151}
152
136153pub struct MergeRequirements {
137154 pub required_approvals: i32,
138155 pub dismiss_stale_approvals: bool,
Modified lib/service/git/src/openapi.rs View lib/service/git/src/openapi.rs
@@ -125,9 +125,9 @@
125125 api::rest::NoData,
126126 crate::service::hooks::HookEventKind,
127127 models::common::watch_mode::WatchMode,
128 models::git::git_commit_status::CommitStatusState,
129 models::git::git_tags::TagType,
130 models::repo::repo_branch_protection_whitelists::WhitelistKind,
128 crate::service::commits::CommitStatusDtoState,
129 crate::rpcgit::tags::types::TagType,
130 crate::service::whitelists::WhitelistKind,
131131 crate::lfs::types::LfsLock,
132132 crate::lfs::types::LfsLockOwner,
133133 crate::lfs::types::LfsLockListResponse,