Skip to main content

secrets_engine_gitlab/
lib.rs

1//! GitLab project and group access tokens — mintable and revocable through the
2//! API, but with expiry that GitLab only accepts as a *date*. There is no such
3//! thing as a fifteen-minute GitLab token, so this engine keeps two clocks: it
4//! asks GitLab for the nearest possible date as a backstop, and holds the real
5//! deadline in the lease, where the reaper can enforce it to the second.
6//!
7//! See `docs/delegation/gitlab.md` for the mechanism and
8//! `docs/delegation/setup/gitlab.md` for the operator walkthrough.
9
10use async_trait::async_trait;
11use chrono::{DateTime, Duration, Utc};
12use secrets_core::engine::{
13    CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
14    SecretsEngine, TtlDoc,
15};
16use secrets_core::lease::Lease;
17use secrets_core::mount::ConfigRoleStore;
18use secrets_core::storage::StorageBackend;
19use serde::{Deserialize, Serialize};
20use serde_json::json;
21use uuid::Uuid;
22
23const STORE: ConfigRoleStore = ConfigRoleStore::new("gitlab/config/", "gitlab/roles/");
24const MOUNT: &str = "gitlab/creds/";
25const DEFAULT_API: &str = "https://gitlab.com/api/v4";
26
27/// Reporter. Deliberately lower than GitLab's own default of 40 (Maintainer),
28/// which is far more authority than a consumer usually needs.
29const DEFAULT_ACCESS_LEVEL: u8 = 20;
30const DEFAULT_TTL_SECONDS: i64 = 900;
31
32/// How the server authenticates to GitLab. `private_token` is an Owner-level
33/// PAT (or an admin PAT for instance-wide work) and is never read back out.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct GitlabConfig {
36    /// Override for self-managed, e.g. `https://gitlab.example.com/api/v4`.
37    #[serde(default = "default_api")]
38    pub base_url: String,
39    pub private_token: String,
40}
41
42fn default_api() -> String {
43    DEFAULT_API.to_string()
44}
45
46/// Whether a role mints a project- or group-scoped token. Group tokens reach
47/// every project in the group, so prefer `Project` unless the consumer really
48/// needs the breadth.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50#[serde(rename_all = "lowercase")]
51pub enum Resource {
52    #[default]
53    Project,
54    Group,
55}
56
57impl Resource {
58    fn api_segment(self) -> &'static str {
59        match self {
60            Self::Project => "projects",
61            Self::Group => "groups",
62        }
63    }
64}
65
66/// What one consumer may mint.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct RoleConfig {
69    /// Which `gitlab/config/{name}` document to authenticate with.
70    pub target: String,
71    #[serde(default)]
72    pub resource: Resource,
73    /// Numeric project or group id. GitLab also accepts a URL-encoded path,
74    /// but ids do not break when a project is renamed or moved.
75    pub resource_id: String,
76    /// GitLab rejects a token with no scopes, so this must be non-empty.
77    /// `read_repository` for cloning, `read_api` for read-only API access.
78    pub scopes: Vec<String>,
79    #[serde(default = "default_access_level")]
80    pub access_level: u8,
81    /// The deadline the reaper enforces. GitLab cannot express anything this
82    /// short — see the module docs.
83    #[serde(default = "default_ttl_seconds")]
84    pub default_ttl_seconds: i64,
85}
86
87fn default_access_level() -> u8 {
88    DEFAULT_ACCESS_LEVEL
89}
90
91fn default_ttl_seconds() -> i64 {
92    DEFAULT_TTL_SECONDS
93}
94
95#[derive(Debug, Deserialize)]
96struct AccessTokenResponse {
97    /// Revocation addresses the token by id, not by its secret value.
98    id: u64,
99    token: String,
100    #[serde(default)]
101    expires_at: Option<String>,
102}
103
104#[derive(Default)]
105pub struct GitlabEngine {
106    http: reqwest::Client,
107}
108
109impl GitlabEngine {
110    pub fn new() -> Self {
111        Self {
112            http: reqwest::Client::builder()
113                .user_agent("secrets-server")
114                .build()
115                .unwrap_or_default(),
116        }
117    }
118
119    /// The soonest expiry GitLab will accept. `expires_at` is a date, and a
120    /// token expires at midnight UTC on it, so "tomorrow" is the floor — the
121    /// resulting token lives between 24 and 48 hours depending on when we ask.
122    /// This is only a backstop for a lease record we somehow lose.
123    fn nearest_expiry_date(now: DateTime<Utc>) -> String {
124        (now + Duration::days(1)).format("%Y-%m-%d").to_string()
125    }
126
127    /// The deadline that actually governs the credential.
128    fn lease_expiry(now: DateTime<Utc>, ttl_seconds: i64) -> DateTime<Utc> {
129        now + Duration::seconds(ttl_seconds)
130    }
131
132    /// Token names show up in GitLab's UI and audit log, so they carry the role
133    /// they were minted for plus enough entropy to stay unique.
134    fn token_name(role: &str) -> String {
135        let suffix = Uuid::new_v4().simple().to_string();
136        format!("secrets-{role}-{}", &suffix[..8])
137    }
138
139    fn access_level_name(level: u8) -> &'static str {
140        match level {
141            10 => "guest",
142            20 => "reporter",
143            30 => "developer",
144            40 => "maintainer",
145            50 => "owner",
146            _ => "unknown",
147        }
148    }
149
150    fn scope_description(role: &RoleConfig) -> Vec<String> {
151        let mut scoped = vec![format!(
152            "{}:{}",
153            match role.resource {
154                Resource::Project => "project",
155                Resource::Group => "group",
156            },
157            role.resource_id
158        )];
159        scoped.extend(role.scopes.iter().map(|s| format!("scope:{s}")));
160        scoped.push(format!(
161            "access_level:{}",
162            Self::access_level_name(role.access_level)
163        ));
164        scoped
165    }
166
167    fn tokens_url(base_url: &str, resource: Resource, resource_id: &str) -> String {
168        format!(
169            "{}/{}/{}/access_tokens",
170            base_url.trim_end_matches('/'),
171            resource.api_segment(),
172            resource_id
173        )
174    }
175}
176
177#[async_trait]
178impl SecretsEngine for GitlabEngine {
179    fn doc(&self) -> EngineDoc {
180        EngineDoc {
181            provider: "GitLab".to_string(),
182            mechanism: "project or group access tokens, minted per request with the \
183                        role's scopes and membership level"
184                .to_string(),
185            shape: CredentialShape::MintAndRevoke,
186            revocable: true,
187            revoke_effect: "DELETE /{projects|groups}/{id}/access_tokens/{token_id} \
188                            using the configured root PAT — the credential stops \
189                            working immediately. GitLab purges its own record of a \
190                            revoked token after 30 days."
191                .to_string(),
192            ttl: TtlDoc::range(
193                1,
194                365 * 24 * 3600,
195                "The lease TTL is enforced by our reaper and can be as short as you \
196                 like. GitLab's own expires_at is a DATE, so the token it issues is \
197                 additionally capped at midnight UTC tomorrow — a backstop, not the \
198                 real deadline. There is no way to make GitLab itself expire a token \
199                 in minutes.",
200            ),
201            scoping: "per role: one project or one group, a list of token scopes, and \
202                      a membership access level that bounds authority within those \
203                      scopes. Group tokens reach every project in the group."
204                .to_string(),
205            root_credential: "a GitLab PAT with the `api` scope at \
206                              gitlab/config/{target}. It must be Owner on the target \
207                              group or project — or an instance admin for user-level \
208                              tokens — so it is a broad blast radius. Prefer an \
209                              Owner PAT scoped to one group over an admin token."
210                .to_string(),
211            paths: vec![
212                PathDoc::new(
213                    "gitlab/config/{target}",
214                    &["POST", "GET", "DELETE"],
215                    "sudo",
216                    "register the instance URL and root PAT. GET reports only whether \
217                     it is configured — the PAT is never returned.",
218                ),
219                PathDoc::new(
220                    "gitlab/roles/{role}",
221                    &["POST", "GET", "DELETE"],
222                    "create / read / sudo",
223                    "define one consumer's project or group, scopes, access level and TTL",
224                ),
225                PathDoc::new(
226                    "gitlab/creds/{role}",
227                    &["GET"],
228                    "read",
229                    "mint an access token and open a lease",
230                ),
231                PathDoc::new("gitlab/help", &["GET"], "authenticated", "this document"),
232            ],
233            docs_url: Some("docs/delegation/gitlab.md".to_string()),
234            caveats: vec![
235                "GitLab's expires_at has DATE granularity — a token expires at \
236                 midnight UTC, and the soonest it accepts is tomorrow. Sub-day expiry \
237                 does not exist at the provider, so the lease is the only tight clock."
238                    .to_string(),
239                "expires_at has been mandatory since GitLab 16.0, and omitting it \
240                 yields 365 days. This engine always sends the nearest date so a lost \
241                 lease record cannot leave a year-long credential behind."
242                    .to_string(),
243                "The root PAT needs the `api` scope and Owner (or admin) authority, \
244                 which is far more reach than any token it mints. Rotate it on a \
245                 schedule and scope it to one group where possible."
246                    .to_string(),
247                "GitLab has a token rotation endpoint, but rotating an ALREADY-REVOKED \
248                 token triggers family-wide revocation as a reuse-detection measure. \
249                 Never blindly retry a rotation — re-read state first. This engine \
250                 mints and revokes rather than rotating, precisely to avoid that."
251                    .to_string(),
252                "Deploy tokens and deploy keys cannot call the GitLab API at all \
253                 (repository and registry only), so they are not offered here."
254                    .to_string(),
255                "CI job tokens and GitLab ID tokens exist only inside a running \
256                 pipeline and cannot be minted from outside."
257                    .to_string(),
258            ],
259        }
260    }
261
262    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
263        STORE.handle_read::<RoleConfig>(storage, path).await
264    }
265
266    async fn write(
267        &self,
268        storage: &dyn StorageBackend,
269        path: &str,
270        data: serde_json::Value,
271    ) -> EngineResult<()> {
272        STORE.handle_write::<GitlabConfig, RoleConfig>(storage, path, data).await
273    }
274
275    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
276        STORE.handle_delete(storage, path).await
277    }
278
279    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
280        STORE.handle_list(storage, prefix).await
281    }
282
283    async fn generate(
284        &self,
285        storage: &dyn StorageBackend,
286        role_name: &str,
287    ) -> EngineResult<GeneratedCredential> {
288        let role: RoleConfig = STORE.require_role(storage, role_name).await?;
289        let config: GitlabConfig = STORE.require_config(storage, &role.target).await?;
290
291        // GitLab rejects a scopeless token with an opaque 400, so say what is
292        // actually wrong with the role.
293        if role.scopes.is_empty() {
294            return Err(EngineError::InvalidRequest(format!(
295                "gitlab/roles/{role_name} has no scopes — GitLab requires at least \
296                 one, e.g. [\"read_repository\"]"
297            )));
298        }
299
300        let now = Utc::now();
301        let url = Self::tokens_url(&config.base_url, role.resource, &role.resource_id);
302        let response = self
303            .http
304            .post(&url)
305            .header("PRIVATE-TOKEN", &config.private_token)
306            .json(&json!({
307                "name": Self::token_name(role_name),
308                "scopes": role.scopes,
309                "access_level": role.access_level,
310                "expires_at": Self::nearest_expiry_date(now),
311            }))
312            .send()
313            .await
314            .map_err(|e| EngineError::Provider(format!("GitLab request failed: {e}")))?;
315
316        let status = response.status();
317        let text = response.text().await.unwrap_or_default();
318        if !status.is_success() {
319            return Err(EngineError::Provider(format!(
320                "GitLab returned {status} for {url}: {text}"
321            )));
322        }
323        let token: AccessTokenResponse = serde_json::from_str(&text)
324            .map_err(|e| EngineError::Provider(format!("unexpected GitLab response: {e}")))?;
325
326        let lease = Lease {
327            id: Uuid::new_v4(),
328            // Set by the HTTP handler, which knows the requesting token.
329            token_id_hash: String::new(),
330            engine_mount: MOUNT.to_string(),
331            // Revocation needs the token id and the root PAT, so keep the id and
332            // enough context to load the config again. The token value itself is
333            // not needed and is deliberately not stored.
334            internal_data: json!({
335                "target": role.target,
336                "resource": role.resource,
337                "resource_id": role.resource_id,
338                "token_id": token.id,
339            }),
340            issued_at: now,
341            // Our clock, not GitLab's date. GitLab cannot expire a token in
342            // minutes, so the reaper is what makes this credential short-lived.
343            expires_at: Self::lease_expiry(now, role.default_ttl_seconds),
344        };
345
346        Ok(GeneratedCredential::new(
347            json!({
348                "token": token.token,
349                "git_clone_username": "oauth2",
350                // GitLab's own expiry, exposed so a caller can see that it is
351                // later than the lease and understand which one binds.
352                "provider_expires_at": token.expires_at,
353            }),
354            lease,
355            Self::scope_description(&role),
356        ))
357    }
358
359    async fn revoke(&self, storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
360        let target = lease.internal_data["target"]
361            .as_str()
362            .ok_or_else(|| EngineError::Other("lease missing 'target'".into()))?;
363        let resource_id = lease.internal_data["resource_id"]
364            .as_str()
365            .ok_or_else(|| EngineError::Other("lease missing 'resource_id'".into()))?;
366        let token_id = lease.internal_data["token_id"]
367            .as_u64()
368            .ok_or_else(|| EngineError::Other("lease missing 'token_id'".into()))?;
369        let resource: Resource = serde_json::from_value(lease.internal_data["resource"].clone())
370            .map_err(|e| EngineError::Other(format!("lease has an invalid 'resource': {e}")))?;
371
372        // The operator can delete a config while leases against it are still
373        // open. Failing here would wedge the reaper on a lease it can never
374        // clear, so report it loudly and let the lease record go.
375        let Some(config) = STORE.load_config::<GitlabConfig>(storage, target).await? else {
376            tracing::warn!(
377                target,
378                token_id,
379                "gitlab/config was deleted before this lease expired — cannot revoke \
380                 the access token. Revoke it by hand in GitLab."
381            );
382            return Ok(());
383        };
384
385        let response = self
386            .http
387            .delete(format!(
388                "{}/{token_id}",
389                Self::tokens_url(&config.base_url, resource, resource_id)
390            ))
391            .header("PRIVATE-TOKEN", &config.private_token)
392            .send()
393            .await
394            .map_err(|e| EngineError::Provider(format!("GitLab revoke failed: {e}")))?;
395
396        // A token GitLab has already dropped is not an error — the reaper must
397        // be able to retry without tripping over its own success.
398        if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND {
399            Ok(())
400        } else {
401            Err(EngineError::Provider(format!(
402                "GitLab returned {} when revoking access token {token_id}",
403                response.status()
404            )))
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use chrono::TimeZone;
413
414    fn role(scopes: &[&str]) -> RoleConfig {
415        RoleConfig {
416            target: "acme".to_string(),
417            resource: Resource::Project,
418            resource_id: "42".to_string(),
419            scopes: scopes.iter().map(|s| s.to_string()).collect(),
420            access_level: DEFAULT_ACCESS_LEVEL,
421            default_ttl_seconds: DEFAULT_TTL_SECONDS,
422        }
423    }
424
425    #[test]
426    fn nearest_expiry_is_tomorrows_date() {
427        let now = Utc.with_ymd_and_hms(2026, 3, 4, 23, 50, 0).unwrap();
428        assert_eq!(GitlabEngine::nearest_expiry_date(now), "2026-03-05");
429
430        // Month and year boundaries are where naive date arithmetic breaks.
431        let eom = Utc.with_ymd_and_hms(2026, 12, 31, 9, 0, 0).unwrap();
432        assert_eq!(GitlabEngine::nearest_expiry_date(eom), "2027-01-01");
433    }
434
435    /// The whole point of this engine: the lease binds long before the token
436    /// GitLab issued would have expired on its own.
437    #[test]
438    fn lease_expiry_follows_our_ttl_not_gitlabs_date() {
439        let now = Utc.with_ymd_and_hms(2026, 3, 4, 9, 0, 0).unwrap();
440        let expiry = GitlabEngine::lease_expiry(now, DEFAULT_TTL_SECONDS);
441
442        assert_eq!(expiry, now + Duration::seconds(900));
443
444        // GitLab's backstop is midnight UTC on the date we asked for, which is
445        // many hours later than the deadline the reaper will enforce.
446        let provider_date = GitlabEngine::nearest_expiry_date(now);
447        let provider_expiry = DateTime::parse_from_rfc3339(&format!("{provider_date}T00:00:00Z"))
448            .unwrap()
449            .with_timezone(&Utc);
450        assert!(
451            expiry < provider_expiry,
452            "lease ({expiry}) must expire before GitLab's date backstop ({provider_expiry})"
453        );
454    }
455
456    #[test]
457    fn scope_description_names_the_resource_scopes_and_level() {
458        let scoped = GitlabEngine::scope_description(&role(&["read_repository", "read_api"]));
459        assert!(scoped.contains(&"project:42".to_string()));
460        assert!(scoped.contains(&"scope:read_repository".to_string()));
461        assert!(scoped.contains(&"scope:read_api".to_string()));
462        assert!(scoped.contains(&"access_level:reporter".to_string()));
463    }
464
465    #[test]
466    fn scope_description_distinguishes_groups_from_projects() {
467        let mut group_role = role(&["read_registry"]);
468        group_role.resource = Resource::Group;
469        group_role.resource_id = "7".to_string();
470        let scoped = GitlabEngine::scope_description(&group_role);
471        assert!(scoped.contains(&"group:7".to_string()));
472    }
473
474    #[test]
475    fn token_name_carries_the_role_and_is_unique() {
476        let first = GitlabEngine::token_name("report-service");
477        let second = GitlabEngine::token_name("report-service");
478        assert!(first.starts_with("secrets-report-service-"));
479        assert_ne!(first, second);
480    }
481
482    #[test]
483    fn tokens_url_matches_the_resource_kind() {
484        assert_eq!(
485            GitlabEngine::tokens_url("https://gitlab.com/api/v4/", Resource::Project, "42"),
486            "https://gitlab.com/api/v4/projects/42/access_tokens"
487        );
488        assert_eq!(
489            GitlabEngine::tokens_url(DEFAULT_API, Resource::Group, "7"),
490            "https://gitlab.com/api/v4/groups/7/access_tokens"
491        );
492    }
493
494    /// A role defaulting to Maintainer would hand consumers far more authority
495    /// than they need, so the default must stay below GitLab's own.
496    #[test]
497    fn default_access_level_is_narrower_than_gitlabs() {
498        assert_eq!(default_access_level(), 20);
499        assert_eq!(GitlabEngine::access_level_name(default_access_level()), "reporter");
500    }
501
502    #[test]
503    fn role_defaults_fill_in_from_minimal_json() {
504        let parsed: RoleConfig = serde_json::from_value(json!({
505            "target": "acme",
506            "resource_id": "42",
507            "scopes": ["read_repository"],
508        }))
509        .unwrap();
510        assert_eq!(parsed.resource, Resource::Project);
511        assert_eq!(parsed.access_level, DEFAULT_ACCESS_LEVEL);
512        assert_eq!(parsed.default_ttl_seconds, DEFAULT_TTL_SECONDS);
513    }
514
515    #[test]
516    fn doc_agrees_with_its_shape() {
517        let doc = GitlabEngine::new().doc();
518        assert_eq!(doc.shape, CredentialShape::MintAndRevoke);
519        assert_eq!(doc.revocable, doc.shape.revocable());
520        assert!(!doc.ttl.fixed);
521        // The date-granularity trap is the single most surprising thing about
522        // this provider; the docs must not omit it.
523        assert!(
524            doc.caveats.iter().any(|c| c.contains("DATE granularity")),
525            "doc() must warn about date-only expiry"
526        );
527    }
528}