1use 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
27const DEFAULT_ACCESS_LEVEL: u8 = 20;
30const DEFAULT_TTL_SECONDS: i64 = 900;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct GitlabConfig {
36 #[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct RoleConfig {
69 pub target: String,
71 #[serde(default)]
72 pub resource: Resource,
73 pub resource_id: String,
76 pub scopes: Vec<String>,
79 #[serde(default = "default_access_level")]
80 pub access_level: u8,
81 #[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 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 fn nearest_expiry_date(now: DateTime<Utc>) -> String {
124 (now + Duration::days(1)).format("%Y-%m-%d").to_string()
125 }
126
127 fn lease_expiry(now: DateTime<Utc>, ttl_seconds: i64) -> DateTime<Utc> {
129 now + Duration::seconds(ttl_seconds)
130 }
131
132 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 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 token_id_hash: String::new(),
330 engine_mount: MOUNT.to_string(),
331 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 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 "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 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 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 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 #[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 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 #[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 assert!(
524 doc.caveats.iter().any(|c| c.contains("DATE granularity")),
525 "doc() must warn about date-only expiry"
526 );
527 }
528}