Vault compatibility — the compiled plugin is registered, mounted and exercised inside a real Vault server in CI:
| Vault | Status |
|---|---|
| 2.1.x | |
| 2.0.x | |
| 1.21.x | |
| latest GA |
Platforms — cross-compiled in CI and published on every release:
| OS | amd64 | arm64 |
|---|---|---|
A Vault secrets engine that issues GitLab project access tokens on demand:
- Tokens are minted through the GitLab API from a single parent token held by Vault
- Every token is a Vault lease: it is deleted in GitLab when the lease expires or is revoked — so TTLs shorter than GitLab's one-day minimum really are enforced
- Free-form requests (
token,dynamic/project_id/<id>/name/<name>) or predefined roles (token/<role>) - Scopes, access level (guest → maintainer, owner opt-in) and expiry, bounded by a mount-wide
max_ttl - Access gated by standard Vault ACLs
- Multiplexed, versioned plugin: register it in the catalog with
-versionand pin mounts to a release
Forked from splunk/vault-plugin-secrets-gitlab and kept in sync with upstream.
- GitLab 14.1 or later (project access tokens with access level)
- Self-managed instances on Free and above, or GitLab SaaS Premium and above
- A parent token of a user with Maintainer (or higher) permission on the target projects
- Ideally, lift the API rate limit for that user — see allow specific users to bypass authenticated request rate limiting
-
Download the asset matching your Vault server from the latest release, e.g.
vault-plugin-secrets-gitlab_<version>_linux_amd64, together withchecksums.txt. -
Verify and install it into Vault's
plugin_directoryunder the plugin's command name:sha256sum --ignore-missing -c checksums.txt install -m 0755 vault-plugin-secrets-gitlab_<version>_linux_amd64 \ /etc/vault/plugins/vault-plugin-secrets-gitlab
-
Register the plugin and enable the secrets engine:
SHASUM=$(sha256sum /etc/vault/plugins/vault-plugin-secrets-gitlab | cut -d ' ' -f1) vault plugin register -sha256="$SHASUM" -version="v<version>" \ secret vault-plugin-secrets-gitlab vault secrets enable -path=gitlab -plugin-version="v<version>" vault-plugin-secrets-gitlab
-versionis optional — without it Vault records the version the binary reports. When given, it must match the binary (v+ the release version) or Vault refuses the registration. Pinning the version lets you upgrade mount by mount withvault secrets tune -plugin-version=... gitlab/followed byvault plugin reload -mounts=gitlab/.
Check a binary's build metadata at any time with
vault-plugin-secrets-gitlab --version.
Configure the mount with the parent token used to mint project access tokens:
$ vault write gitlab/config \
base_url="https://gitlab.example.com" \
token="$GITLAB_TOKEN" \
max_ttl=720h
Key Value
--- -----
allow_owner_level false
base_url https://gitlab.example.com
max_ttl 2592000
| Field | Default | Description |
|---|---|---|
base_url |
https://gitlab.com |
GitLab instance URL. Must be https (plain http is only accepted for loopback addresses) so the parent token never transits in cleartext |
token |
— | Parent token; write-only, never returned |
max_ttl |
0 (mount max lease TTL) |
Maximum lease duration of a token, renewals included. Sub-day values are honored |
allow_owner_level |
false |
Allow access_level=50 (Owner) |
Changes take effect immediately on the next request.
Free-form request — ttl is the lease duration (mount default when omitted):
# always set access_level: if omitted, GitLab defaults the token to Maintainer (40)
$ vault write gitlab/token id=1 name=ci-token scopes=api,write_repository access_level=30 ttl=1h
Key Value
--- -----
lease_id gitlab/token/Xy7kz0yJ8qM4V1QeYf3cR2aT
lease_duration 1h
lease_renewable true
access_level 30
expires_at 2026-10-08 00:00:00 +0000 UTC
id 12345
name ci-token
scopes [api write_repository]
token glpat-REDACTED
The project and token name can also be carried by the path, which makes it easy to scope ACL policies per project:
$ vault write gitlab/dynamic/project_id/1/name/ci-token scopes=read_api ttl=15m
# only project 1, any token name
path "gitlab/dynamic/project_id/1/name/*" {
capabilities = ["create", "update"]
}Roles predefine the parameters, so callers cannot choose them; token_ttl
(default 24h) is the lease duration:
$ vault write gitlab/roles/ci-role id=1 name=project1-role scopes=read_api,read_repository token_ttl=30m
$ vault write -f gitlab/token/ci-role
The dynamic/project_id/<id>/name/<name> path carries the project and token
name in the URL, so a single templated ACL policy can
restrict every caller to its own project, using identity data instead of one
policy per project. Both patterns below are exercised against a real Vault in
the integration tests.
Per entity — store the project on the Vault entity and bind the token name to the caller:
vault policy write gitlab-own-project - <<'EOF'
# project from the entity metadata, token name = entity name
path "gitlab/dynamic/project_id/{{identity.entity.metadata.gitlab_project_id}}/name/{{identity.entity.name}}" {
capabilities = ["create", "update"]
}
EOF
vault write identity/entity name=alice metadata=gitlab_project_id=21 policies=gitlab-own-projectalice can now call vault write gitlab/dynamic/project_id/21/name/alice scopes=read_api ttl=1h,
and is denied for any other project, any other token name and the free-form
gitlab/token path. With identity groups use
{{identity.groups.names.<group>.metadata.gitlab_project_id}} to scope a whole
team.
GitLab CI/CD — each pipeline job authenticates with its
ID token and may only mint tokens for the project it runs in.
The JWT project_id claim is mapped to entity-alias metadata and used in the
policy:
vault auth enable jwt
vault write auth/jwt/config \
oidc_discovery_url="https://gitlab.example.com" \
bound_issuer="https://gitlab.example.com"
vault write auth/jwt/role/gitlab-ci - <<'EOF'
{
"role_type": "jwt",
"user_claim": "project_id",
"bound_audiences": ["https://vault.example.com"],
"bound_claims": {"namespace_path": "mygroup"},
"claim_mappings": {"project_id": "project_id", "project_path": "project_path"},
"token_policies": ["gitlab-ci"],
"token_ttl": "10m"
}
EOF
ACCESSOR=$(vault auth list -format=json | jq -r '."jwt/".accessor')
vault policy write gitlab-ci - <<EOF
path "gitlab/dynamic/project_id/{{identity.entity.aliases.${ACCESSOR}.metadata.project_id}}/name/ci-*" {
capabilities = ["create", "update"]
}
EOF# .gitlab-ci.yml
deploy:
id_tokens:
VAULT_ID_TOKEN:
aud: https://vault.example.com
script:
- export VAULT_TOKEN=$(vault write -field=token auth/jwt/login role=gitlab-ci jwt=$VAULT_ID_TOKEN)
- export GITLAB_TOKEN=$(vault write -field=token gitlab/dynamic/project_id/$CI_PROJECT_ID/name/ci-$CI_JOB_ID scopes=read_api ttl=10m)user_claim=project_id matters: it gives each project its own entity alias,
so the alias metadata (and therefore the allowed project) cannot be overwritten
by a job from another project, as it could with a per-user claim such as
user_email. The token is revoked in GitLab when its lease ends, at the latest
ttl after the job asked for it. Tighten bound_claims (namespace_path, ref_protected, ...)
to control which pipelines may log in at all.
See vault path-help gitlab/ for every endpoint, and the
design principles for access-control guidance.
GitLab only accepts a date for a project access token's expiry, at the earliest tomorrow (tokens expire at midnight UTC), so a token cannot be created with a sub-day lifetime. The plugin complies with that constraint and enforces the real TTL through Vault instead:
- The token is created in GitLab with
expires_atset to the first UTC midnight after the lease's maximum possible end (now + min(max_ttl, mount max_lease_ttl)), i.e. tomorrow at the earliest. This is only a backstop in case Vault never revokes the token. - Vault returns it as a lease of
ttl. When the lease expires, is revoked (vault lease revoke,vault lease revoke -prefix gitlab/) or the mount is disabled, the plugin deletes the token throughDELETE /projects/:id/access_tokens/:token_id. - Leases are renewable (
vault lease renew) up tomax_ttlfrom issue time, which is always covered by the GitLab backstop.
The returned expires_at is that GitLab backstop; lease_duration is the
effective lifetime. A token already gone from GitLab counts as revoked, and if
GitLab is unreachable, Vault retries the revocation with backoff.
Revocation only calls GitLab while the lease's issuing base_url still matches
the configured one. If you reconfigure base_url, leases issued under the old
URL are cleared without a remote call (a warning is logged) and those
tokens live until their backstop date: the plugin will not send the current
parent token to a historical URL. Revoke outstanding leases
(vault lease revoke -prefix gitlab/) before switching instances.
- Tokens are now leased and deleted from GitLab when the lease ends.
Previously they lived until GitLab's expiry. Clients that keep a token
longer than its lease must renew the lease or request a longer
ttl. expires_atontokenanddynamic/...is deprecated: it now sets the lease end (ttl= time untilexpires_at); GitLab gets the backstop date. It cannot be combined withttl.- Requests without
ttl/expires_atused to create tokens with no expiry; they now get the mount's default lease TTL. max_ttlvalues below 24h, previously ignored with a warning, are now applied. Withoutmax_ttl, the mount'smax_lease_ttl(tune it withvault secrets tune -max-lease-ttl=...) bounds every lease.- The parent token must be allowed to revoke project access tokens (Maintainer or higher, same as creation).
- Tokens issued before the upgrade have no lease and are left untouched.
docker build -t vault-plugin-gitlab .
docker run --rm -d --cap-add=IPC_LOCK -e 'VAULT_DEV_ROOT_TOKEN_ID=root' \
-e 'VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200' -p 8200:8200 vault-plugin-gitlab
export VAULT_ADDR='http://127.0.0.1:8200'
vault login root
# Hash the binary *inside* the container — that is the file Vault checks.
CID=$(docker ps -q --filter ancestor=vault-plugin-gitlab | head -1)
SHASUM=$(docker exec "$CID" sha256sum /vault/plugins/vault-plugin-secrets-gitlab | cut -d ' ' -f1)
vault plugin register -sha256="$SHASUM" secret vault-plugin-secrets-gitlab
vault secrets enable -path=gitlab vault-plugin-secrets-gitlab
vault write gitlab/config base_url="$GITLAB_URL" token="$GITLAB_TOKEN"# builds into ./plugins and starts `vault server -dev -dev-plugin-dir=./plugins`
make vault-only
# in another terminal
export VAULT_ADDR=http://127.0.0.1:8200 GITLAB_URL=https://gitlab.example.com GITLAB_TOKEN=...
./scripts/setup_dev_vault.shUnit tests run with no external dependencies:
make test # all unit tests, with coverage in coverage/unit
make test TESTARGS='-run=TestConfig'
make report # coverage/coverage.htmlVault integration tests build the plugin, start a real vault server -dev,
register it in the catalog (sha256 + version), mount it, and drive every
endpoint against a fake GitLab API that enforces GitLab's day-granular expiry
— including the templated policies above (entity metadata and a GitLab CI JWT
login), lease expiry and revocation deleting tokens in GitLab, renewal
caps, version pinning, multiplexed mounts and plugin reload. The Vault binary is downloaded into .tools/:
make test-vault # latest GA Vault
make test-vault VAULT_VERSION=2.0.4 # a specific release
VAULT_BIN=$(which vault) go test ./integration -v # your own binaryAcceptance tests mint real project access tokens against a live GitLab. make test-gitlab starts a disposable GitLab CE container (scripts/gitlab-ce.sh,
about 5 minutes to boot, needs docker), creates an API token and a test
project, runs them and removes the container. To use an existing instance
instead:
export GITLAB_URL=https://gitlab.example.com
export GITLAB_TOKEN=<parent token>
export GITLAB_PROJECT_ID=<project id>
go test ./plugin -run TestAcc -vIn CI they run against GitLab CE only as a release gate (and on demand via the "GitLab acceptance" workflow): no tag or release is published if they fail.
Lint with make lint (golangci-lint, config in .golangci.yml).
Known vulnerabilities are checked with govulncheck on every push, pull request and weekly.
Releases are automated. Every push to main is analyzed with
Conventional Commits: the next
semver is derived from the commit types since the last
tag, the live GitLab CE acceptance tests run, then the tag is created and
GoReleaser publishes the
cross-compiled binaries and checksums.txt to a GitHub Release. The release
version is stamped into the binary and reported to Vault as the plugin version.
| Commit prefix | Version bump |
|---|---|
fix: |
patch (x.y.Z) |
feat: |
minor (x.Y.0) |
feat!: / fix!: / BREAKING CHANGE: footer |
major (X.0.0) |
docs:, chore:, ci:, refactor:, test:, style:, build: |
no release |
You can also cut a release at any specific version by pushing a v* tag
directly (e.g. git tag v1.0.0 && git push origin v1.0.0) — that builds and
publishes that exact tag (after the same acceptance gate). Build every artifact locally without publishing with
make release-snapshot.
git remote add upstream https://github.com/splunk/vault-plugin-secrets-gitlab.git
git fetch upstream && git merge upstream/mainThe Go module path is kept as github.com/splunk/vault-plugin-secrets-gitlab
to keep upstream merges conflict-free.
This plugin was initially created as a Hackathon project to enhance the ephemeral credential suite. Another example is vault-plugin-secrets-artifactory. Contributions in the form of issues and pull requests are welcome.
Please refer to CONTRIBUTING.md and CODE_OF_CONDUCT.md before contributing.