Skip to content

Commit 2384fe3

Browse files
authored
feat(gitlab): authenticate CI job tokens with JOB-TOKEN and gitlab-ci-token (#1120)
1 parent 6ab1fbf commit 2384fe3

11 files changed

Lines changed: 134 additions & 20 deletions

File tree

‎docs/site/docs-en/ci/gitlab-ci.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ release:
4545
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
4646
```
4747

48+
## Using `CI_JOB_TOKEN`
49+
50+
The examples above pass the job token as `GITLAB_TOKEN: $CI_JOB_TOKEN`. FerrFlow recognises it because its value equals `CI_JOB_TOKEN`, and authenticates the way GitLab expects for a job token: API calls carry a `JOB-TOKEN` header and git pushes as `gitlab-ci-token`. The same applies when the job token is passed as `FERRFLOW_TOKEN`. Any other token (project, group or personal access token) is sent as `PRIVATE-TOKEN` and pushes as `oauth2`.
51+
52+
What a job token may do is set per project under **Settings > CI/CD > Job token permissions**. Pushing the release commit and tags needs **Allow Git push requests to the repository**. When GitLab refuses one of the API calls FerrFlow makes with a job token, use a project access token with the `api` scope instead.
53+
4854
## Using a deploy token
4955

5056
If `CI_JOB_TOKEN` doesn't have permission to push tags, create a project deploy token with `write_repository` access and store it as a CI variable:

‎docs/site/docs-fr/ci/gitlab-ci.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ release:
4545
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
4646
```
4747

48+
## Utiliser `CI_JOB_TOKEN`
49+
50+
Les exemples ci-dessus passent le token du job via `GITLAB_TOKEN: $CI_JOB_TOKEN`. FerrFlow le reconnaît parce que sa valeur est égale à `CI_JOB_TOKEN`, et s'authentifie comme GitLab l'attend pour un token de job : les appels d'API portent un en-tête `JOB-TOKEN` et git pousse en tant que `gitlab-ci-token`. C'est aussi le cas quand le token du job est passé dans `FERRFLOW_TOKEN`. Tout autre token (token d'accès de projet, de groupe ou personnel) est envoyé en `PRIVATE-TOKEN` et pousse en tant que `oauth2`.
51+
52+
Ce qu'un token de job a le droit de faire se règle par projet dans **Settings > CI/CD > Job token permissions**. Pousser le commit et les tags de release nécessite **Allow Git push requests to the repository**. Quand GitLab refuse un des appels d'API que FerrFlow fait avec un token de job, utilisez plutôt un token d'accès de projet avec le scope `api`.
53+
4854
## Utiliser un deploy token
4955

5056
Si `CI_JOB_TOKEN` n'a pas les permissions pour pousser des tags, créez un deploy token de projet avec l'accès `write_repository` et stockez-le comme variable CI :

‎src/bot_token.rs‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -324,9 +324,7 @@ fn configure_bot_git_identity_in(repo_dir: &std::path::Path) {
324324
#[cfg(test)]
325325
mod tests {
326326
use super::*;
327-
use std::sync::Mutex;
328-
329-
static ENV_LOCK: Mutex<()> = Mutex::new(());
327+
use crate::test_utils::ENV_LOCK;
330328

331329
fn with_env<F: FnOnce()>(vars: &[(&str, Option<&str>)], f: F) {
332330
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

‎src/forge/gitlab.rs‎

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,42 @@ use crate::error_code::{self, ErrorCodeExt};
77
const PER_PAGE: u32 = 100;
88
const MAX_PAGES: u32 = 100;
99

10+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11+
pub enum GitLabToken {
12+
Private,
13+
Job,
14+
}
15+
16+
impl GitLabToken {
17+
pub fn of(token: &str) -> Self {
18+
Self::matching(token, std::env::var("CI_JOB_TOKEN").ok().as_deref())
19+
}
20+
21+
fn matching(token: &str, ci_job_token: Option<&str>) -> Self {
22+
match ci_job_token {
23+
Some(job) if !job.is_empty() && job == token => Self::Job,
24+
_ => Self::Private,
25+
}
26+
}
27+
28+
pub fn header(self) -> &'static str {
29+
match self {
30+
Self::Private => "PRIVATE-TOKEN",
31+
Self::Job => "JOB-TOKEN",
32+
}
33+
}
34+
35+
pub fn git_username(self) -> &'static str {
36+
match self {
37+
Self::Private => "oauth2",
38+
Self::Job => "gitlab-ci-token",
39+
}
40+
}
41+
}
42+
1043
pub struct GitLabForge {
1144
pub token: String,
45+
pub token_kind: GitLabToken,
1246
pub slug: String,
1347
pub api_base: String,
1448
pub agent: ureq::Agent,
@@ -26,7 +60,7 @@ impl GitLabForge {
2660
let body: serde_json::Value = self
2761
.agent
2862
.get(&url)
29-
.header("PRIVATE-TOKEN", &self.token)
63+
.header(self.token_kind.header(), &self.token)
3064
.header("User-Agent", "ferrflow")
3165
.call()
3266
.with_context(|| format!("Failed to list {what}"))?
@@ -80,7 +114,7 @@ impl Forge for GitLabForge {
80114
let response: serde_json::Value = self
81115
.agent
82116
.post(&url)
83-
.header("PRIVATE-TOKEN", &self.token)
117+
.header(self.token_kind.header(), &self.token)
84118
.header("User-Agent", "ferrflow")
85119
.send_json(payload)
86120
.with_context(|| format!("Failed to create GitLab release for {tag}"))
@@ -123,7 +157,7 @@ impl Forge for GitLabForge {
123157
let response: serde_json::Value = self
124158
.agent
125159
.post(&url)
126-
.header("PRIVATE-TOKEN", &self.token)
160+
.header(self.token_kind.header(), &self.token)
127161
.header("User-Agent", "ferrflow")
128162
.send_json(payload)
129163
.with_context(|| format!("Failed to create MR from {head} to {base}"))
@@ -159,7 +193,7 @@ impl Forge for GitLabForge {
159193
let result = self
160194
.agent
161195
.put(&url)
162-
.header("PRIVATE-TOKEN", &self.token)
196+
.header(self.token_kind.header(), &self.token)
163197
.header("User-Agent", "ferrflow")
164198
.send_json(payload);
165199

@@ -174,7 +208,7 @@ impl Forge for GitLabForge {
174208

175209
self.agent
176210
.put(&url)
177-
.header("PRIVATE-TOKEN", &self.token)
211+
.header(self.token_kind.header(), &self.token)
178212
.header("User-Agent", "ferrflow")
179213
.send_json(payload)
180214
.with_context(|| format!("Failed to merge MR !{}", mr.id))
@@ -219,7 +253,7 @@ impl Forge for GitLabForge {
219253
);
220254
self.agent
221255
.post(&url)
222-
.header("PRIVATE-TOKEN", &self.token)
256+
.header(self.token_kind.header(), &self.token)
223257
.header("User-Agent", "ferrflow")
224258
.send_json(serde_json::json!({ "body": body }))
225259
.with_context(|| "Failed to create MR note")?;
@@ -236,7 +270,7 @@ impl Forge for GitLabForge {
236270
);
237271
self.agent
238272
.put(&url)
239-
.header("PRIVATE-TOKEN", &self.token)
273+
.header(self.token_kind.header(), &self.token)
240274
.header("User-Agent", "ferrflow")
241275
.send_json(serde_json::json!({ "body": body }))
242276
.with_context(|| "Failed to update MR note")?;
@@ -252,7 +286,7 @@ impl Forge for GitLabForge {
252286
let response: serde_json::Value = self
253287
.agent
254288
.get(&url)
255-
.header("PRIVATE-TOKEN", &self.token)
289+
.header(self.token_kind.header(), &self.token)
256290
.header("User-Agent", "ferrflow")
257291
.call()
258292
.with_context(|| format!("Failed to list open MRs for {head}"))
@@ -273,7 +307,7 @@ impl Forge for GitLabForge {
273307
let url = format!("{}/projects/{project}/merge_requests/{id}", self.api_base);
274308
self.agent
275309
.put(&url)
276-
.header("PRIVATE-TOKEN", &self.token)
310+
.header(self.token_kind.header(), &self.token)
277311
.header("User-Agent", "ferrflow")
278312
.send_json(serde_json::json!({ "title": title, "description": body }))
279313
.with_context(|| format!("Failed to update MR !{id}"))
@@ -290,10 +324,40 @@ impl Forge for GitLabForge {
290324
mod tests {
291325
use super::*;
292326

327+
#[test]
328+
fn a_token_equal_to_ci_job_token_is_a_job_token() {
329+
assert_eq!(
330+
GitLabToken::matching("job-abc", Some("job-abc")),
331+
GitLabToken::Job
332+
);
333+
}
334+
335+
#[test]
336+
fn any_other_token_stays_a_private_token() {
337+
assert_eq!(
338+
GitLabToken::matching("glpat-abc", Some("job-abc")),
339+
GitLabToken::Private
340+
);
341+
assert_eq!(
342+
GitLabToken::matching("glpat-abc", None),
343+
GitLabToken::Private
344+
);
345+
assert_eq!(GitLabToken::matching("", Some("")), GitLabToken::Private);
346+
}
347+
348+
#[test]
349+
fn job_tokens_use_the_job_token_header_and_the_ci_git_user() {
350+
assert_eq!(GitLabToken::Job.header(), "JOB-TOKEN");
351+
assert_eq!(GitLabToken::Job.git_username(), "gitlab-ci-token");
352+
assert_eq!(GitLabToken::Private.header(), "PRIVATE-TOKEN");
353+
assert_eq!(GitLabToken::Private.git_username(), "oauth2");
354+
}
355+
293356
#[test]
294357
fn encoded_project_id_simple() {
295358
let forge = GitLabForge {
296359
token: String::new(),
360+
token_kind: GitLabToken::Private,
297361
slug: "owner/repo".to_string(),
298362
api_base: "https://gitlab.com/api/v4".to_string(),
299363
agent: ureq::Agent::new_with_defaults(),
@@ -305,6 +369,7 @@ mod tests {
305369
fn encoded_project_id_subgroup() {
306370
let forge = GitLabForge {
307371
token: String::new(),
372+
token_kind: GitLabToken::Private,
308373
slug: "group/subgroup/repo".to_string(),
309374
api_base: "https://gitlab.com/api/v4".to_string(),
310375
agent: ureq::Agent::new_with_defaults(),
@@ -316,6 +381,7 @@ mod tests {
316381
fn mr_noun_returns_mr() {
317382
let forge = GitLabForge {
318383
token: String::new(),
384+
token_kind: GitLabToken::Private,
319385
slug: "owner/repo".to_string(),
320386
api_base: "https://gitlab.com/api/v4".to_string(),
321387
agent: ureq::Agent::new_with_defaults(),
@@ -327,6 +393,7 @@ mod tests {
327393
fn release_noun_returns_gitlab_release() {
328394
let forge = GitLabForge {
329395
token: String::new(),
396+
token_kind: GitLabToken::Private,
330397
slug: "owner/repo".to_string(),
331398
api_base: "https://gitlab.com/api/v4".to_string(),
332399
agent: ureq::Agent::new_with_defaults(),
@@ -338,6 +405,7 @@ mod tests {
338405
fn find_draft_release_always_none() {
339406
let forge = GitLabForge {
340407
token: String::new(),
408+
token_kind: GitLabToken::Private,
341409
slug: "owner/repo".to_string(),
342410
api_base: "https://gitlab.com/api/v4".to_string(),
343411
agent: ureq::Agent::new_with_defaults(),
@@ -349,6 +417,7 @@ mod tests {
349417
fn publish_release_noop() {
350418
let forge = GitLabForge {
351419
token: String::new(),
420+
token_kind: GitLabToken::Private,
352421
slug: "owner/repo".to_string(),
353422
api_base: "https://gitlab.com/api/v4".to_string(),
354423
agent: ureq::Agent::new_with_defaults(),
@@ -389,6 +458,7 @@ mod tests {
389458
fn api_base_self_hosted() {
390459
let forge = GitLabForge {
391460
token: String::new(),
461+
token_kind: GitLabToken::Private,
392462
slug: "team/project".to_string(),
393463
api_base: "https://gitlab.internal/api/v4".to_string(),
394464
agent: ureq::Agent::new_with_defaults(),

‎src/forge/mod.rs‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ pub fn build_forge(kind: ForgeKind, token: String, slug: String, host: String) -
292292
ForgeKind::Gitlab => {
293293
let api_base = format!("https://{host}/api/v4");
294294
Box::new(gitlab::GitLabForge {
295+
token_kind: gitlab::GitLabToken::of(&token),
295296
token,
296297
slug,
297298
api_base,
@@ -329,9 +330,7 @@ pub fn build_forge(kind: ForgeKind, token: String, slug: String, host: String) -
329330
#[cfg(test)]
330331
mod tests {
331332
use super::*;
332-
use std::sync::Mutex;
333-
334-
static ENV_LOCK: Mutex<()> = Mutex::new(());
333+
use crate::test_utils::ENV_LOCK;
335334

336335
#[test]
337336
fn detect_github_https() {

‎src/git/auth.rs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::process::Command;
22

33
use super::repo::Repository;
4+
use crate::forge::gitlab::GitLabToken;
45

56
#[cfg(test)]
67
pub(super) fn extract_url_password(url: &str) -> Option<(String, String)> {
@@ -34,15 +35,16 @@ fn is_gitlab(url: &str) -> bool {
3435
pub(super) fn token_for_url(url: &str) -> Option<(String, String)> {
3536
if let Ok(token) = std::env::var("FERRFLOW_TOKEN") {
3637
let user = if is_gitlab(url) {
37-
"oauth2"
38+
GitLabToken::of(&token).git_username()
3839
} else {
3940
"x-access-token"
4041
};
4142
return Some((user.to_string(), token));
4243
}
4344
if is_gitlab(url) {
4445
if let Ok(token) = std::env::var("GITLAB_TOKEN") {
45-
return Some(("oauth2".to_string(), token));
46+
let user = GitLabToken::of(&token).git_username();
47+
return Some((user.to_string(), token));
4648
}
4749
} else if let Ok(token) = std::env::var("GITHUB_TOKEN") {
4850
return Some(("x-access-token".to_string(), token));

‎src/git/tests.rs‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -988,6 +988,37 @@ fn token_for_url_picks_gitlab_user_for_gitlab_urls() {
988988
assert_eq!(user, "oauth2");
989989
}
990990

991+
#[test]
992+
fn a_ci_job_token_pushes_as_gitlab_ci_token() {
993+
let _guard = EnvGuard::new()
994+
.unset("FERRFLOW_TOKEN")
995+
.set("CI_JOB_TOKEN", "job_secret")
996+
.set("GITLAB_TOKEN", "job_secret");
997+
assert_eq!(
998+
token_for_url("https://gitlab.com/group/project.git"),
999+
Some(("gitlab-ci-token".to_string(), "job_secret".to_string()))
1000+
);
1001+
}
1002+
1003+
#[test]
1004+
fn a_ci_job_token_passed_as_ferrflow_token_pushes_as_gitlab_ci_token() {
1005+
let _guard = EnvGuard::new()
1006+
.set("CI_JOB_TOKEN", "job_secret")
1007+
.set("FERRFLOW_TOKEN", "job_secret");
1008+
let (user, _) = token_for_url("https://gitlab.com/group/project.git").expect("token");
1009+
assert_eq!(user, "gitlab-ci-token");
1010+
}
1011+
1012+
#[test]
1013+
fn a_personal_token_in_a_ci_job_still_pushes_as_oauth2() {
1014+
let _guard = EnvGuard::new()
1015+
.unset("FERRFLOW_TOKEN")
1016+
.set("CI_JOB_TOKEN", "job_secret")
1017+
.set("GITLAB_TOKEN", "glpat_secret");
1018+
let (user, _) = token_for_url("https://gitlab.com/group/project.git").expect("token");
1019+
assert_eq!(user, "oauth2");
1020+
}
1021+
9911022
#[test]
9921023
fn token_for_url_falls_back_to_provider_env() {
9931024
let _guard = EnvGuard::new()
@@ -1273,7 +1304,7 @@ fn configure_git_command_skips_helper_without_token() {
12731304
assert!(args.is_empty(), "no flags should be added, got {args:?}");
12741305
}
12751306

1276-
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1307+
use crate::test_utils::ENV_LOCK;
12771308

12781309
struct EnvGuard {
12791310
_lock: std::sync::MutexGuard<'static, ()>,

‎src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ pub mod test_utils {
3737
use std::sync::Mutex;
3838

3939
pub static CWD_LOCK: Mutex<()> = Mutex::new(());
40+
pub static ENV_LOCK: Mutex<()> = Mutex::new(());
4041

4142
pub fn with_cwd<F: FnOnce() -> anyhow::Result<()>>(
4243
dir: &std::path::Path,

‎src/main.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ mod test_utils {
7373
use std::sync::Mutex;
7474

7575
pub static CWD_LOCK: Mutex<()> = Mutex::new(());
76+
pub static ENV_LOCK: Mutex<()> = Mutex::new(());
7677

7778
pub fn with_cwd<F: FnOnce() -> anyhow::Result<()>>(
7879
dir: &std::path::Path,

‎src/publishers/npm.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ mod tests {
331331
}
332332
}
333333

334-
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
334+
use crate::test_utils::ENV_LOCK;
335335

336336
fn with_token<T>(f: impl FnOnce() -> T) -> T {
337337
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());

0 commit comments

Comments
 (0)