feat(git): authorize clone and push with scoped CI job tokens
ZhenYi committed
Modified lib/git/src/share/access.rs lib/git/src/share/access.rs +59 −0 View lib/git/src/share/access.rs
@@ -107,11 +107,56 @@
107107 auth: &BasicAuth,
108108 ctx: &GitContext,
109109) -> Result<Uuid, GitError> {
110 if let Some(token) = auth
111 .token
112 .as_deref()
113 .filter(|token| token.starts_with("ci_"))
114 {
115 return verify_ci_job_token(svc, token, ctx, true).await;
116 }
110117 let uid = resolve_caller_for_write(svc, auth).await?;
111118 authorize_repo_access(svc, uid, ctx, true, false).await?;
112119 Ok(uid)
113120}
114121
122async fn verify_ci_job_token(
123 svc: &GitService,
124 token: &str,
125 ctx: &GitContext,
126 write: bool,
127) -> Result<Uuid, GitError> {
128 let (id, secret) = token
129 .strip_prefix("ci_")
130 .and_then(|value| value.split_once('.'))
131 .ok_or(GitError::Unauthorized)?;
132 let id = Uuid::parse_str(id).map_err(|_| GitError::Unauthorized)?;
133 let row = models::ci::job_token::Entity::find_by_id(id)
134 .one(svc.db_write())
135 .await?
136 .ok_or(GitError::Unauthorized)?;
137 let hash = sha256_hex(secret.as_bytes());
138 if !constant_time_eq(hash.as_bytes(), row.token_hash.as_bytes())
139 || row.expires_at <= time::OffsetDateTime::now_utc()
140 {
141 return Err(GitError::Unauthorized);
142 }
143 if row.repo_id != ctx.model.id
144 || (write && !row.contents_write)
145 || (!write && !row.contents_read)
146 {
147 return Err(GitError::Forbidden);
148 }
149 let job = models::ci::workflow_job::Entity::find_by_id(row.job_id)
150 .one(svc.db_write())
151 .await?
152 .ok_or(GitError::Unauthorized)?;
153 if !job.state.is_active() || job.lease_token.as_deref() != Some(row.lease_token.as_str()) {
154 return Err(GitError::Unauthorized);
155 }
156 authorize_repo_access(svc, row.actor_id, ctx, write, false).await?;
157 Ok(row.actor_id)
158}
159
115160/// HTTP read-path auth: `Ok(None)` only for anonymous reads on public
116161/// repos; otherwise returns `Unauthorized` / `Forbidden` / `Some(uid)`.
117162pub async fn allow_repo_read(
@@ -119,6 +164,13 @@
119164 auth: &BasicAuth,
120165 ctx: &GitContext,
121166) -> Result<Option<Uuid>, GitError> {
167 if let Some(token) = auth
168 .token
169 .as_deref()
170 .filter(|token| token.starts_with("ci_"))
171 {
172 return verify_ci_job_token(svc, token, ctx, false).await.map(Some);
173 }
122174 let Some(token) = &auth.token else {
123175 if ctx.model.is_private {
124176 return Err(GitError::Unauthorized);
@@ -140,6 +192,13 @@
140192 auth: &BasicAuth,
141193 ctx: &GitContext,
142194) -> Result<Uuid, GitError> {
195 if auth
196 .token
197 .as_deref()
198 .is_some_and(|token| token.starts_with("ci_"))
199 {
200 return Err(GitError::Forbidden);
201 }
143202 let uid = resolve_caller_for_write(svc, auth).await?;
144203 authorize_repo_access(svc, uid, ctx, true, true).await?;
145204 Ok(uid)