Support direct API access to secrets from non-CI/CD workloads
### Release notes
GitLab Secrets Manager can now be read by non-CI/CD workloads. A new API endpoint mints a short-lived, project- or group-scoped token that clients present directly to the OpenBao backend to fetch secrets. This unblocks Kubernetes runtime workloads (for example via the External Secrets Operator), Terraform / OpenTofu data sources, and service-account automation, using standard Vault-compatible tooling and a GitLab token the client already has. Available in beta on Premium and Ultimate.
### Problem to solve
GitLab Secrets Manager currently supports secret access only through CI/CD pipelines (via Runner JWT) and the GitLab UI (via Rails GraphQL proxy). There is no supported path for non-CI/CD workloads, such as short-lived Kubernetes applications not bound to GitLab, to retrieve secrets programmatically.
As a platform engineer, I want my non-CI workloads to read GitLab-managed secrets using tooling they already have, so I do not have to bind them to a GitLab pipeline or copy secrets into another store.
> "Would be nice to be able to use the API to retrieve secrets for non CI/CD processes. We have short lived applications in Kubernetes not bound to GitLab that require access to the vault." — Beta customer
### Intended users
- [Priyanka (Platform Engineer)](https://handbook.gitlab.com/handbook/product/personas/#priyanka-platform-engineer) running Kubernetes workloads that need secrets at startup or runtime (ESO is the common pattern).
- [Isaac (Infrastructure Engineer)](https://handbook.gitlab.com/handbook/product/personas/#isaac-infrastructure-security-engineer) using Terraform / OpenTofu and needing secrets as data sources.
- [Sidney (Systems Administrator)](https://handbook.gitlab.com/handbook/product/personas/#sidney-systems-administrator) managing secrets through a service account when human owner access is restricted.
### User experience goal
A non-CI client can fetch a GitLab-managed secret with tools it already has (curl, a Vault client library, the External Secrets Operator), using a GitLab token it already understands, without GitLab-specific SDKs.
### Proposal
A new Rails endpoint mints a short-lived JWT for the caller's project or group and returns the OpenBao connection details (URL, namespace, KV mount, auth mount + role). The client uses the JWT to read directly from OpenBao, the same direct-access pattern Runners already use.
This unblocks the **read** use cases, which are the actual gap today. Writes already work through existing GraphQL mutations for any user-bound token (PAT, SA token, project/group access token), so they are out of scope here.
_This design was reviewed and agreed in [the discussion thread below](https://gitlab.com/gitlab-org/gitlab/-/work_items/594090#note_3391198730) (Fabien, Mark, Joe, Adil)._
**Endpoint**
```
POST /api/v4/projects/:id/secrets_manager/access_token
POST /api/v4/groups/:id/secrets_manager/access_token
```
**Response shape** (aligned with `external-secrets.io/v1.VaultProvider`, with `expires_at` at the top level so clients can manage refresh without parsing the JWT):
```json
{
"expires_at": "2026-05-27T10:35:00Z",
"provider": {
"vault": {
"server": "https://openbao.example.com",
"namespace": "org_5/ns_42/project_99",
"path": "secrets/kv",
"version": "v2",
"auth": {
"jwt": {
"path": "api_jwt",
"role": "all_api_access",
"token": "<encoded JWT>"
}
}
}
}
}
```
The client can use the JWT two ways:
- **Inline auth**: one HTTP call per read. Simplest.
- **Explicit JWT login**: one login, many reads. Best for high-fanout like ESO.
Clients use existing Vault client libraries (Vault SDKs, `vault` CLI, ESO's HashiCorp Vault provider). We are just the JWT issuer. The rest is standard Vault.
### Further details
**OpenBao auth.** We follow the existing one-mount-per-auth-type pattern (`pipeline_jwt` for runners, `user_jwt` for the UI) and add a new `api_jwt` mount with its own CEL role (`all_api_access`) for non-CI API access. Existing mounts are untouched.
**How clients use this endpoint:**
- **ESO (K8s runtime).** A refresher CronJob calls the endpoint with a stored service-account token and writes the JWT to a K8s Secret. ESO's HashiCorp Vault provider reads that Secret and does explicit JWT login against OpenBao on each reconciliation. Standard ESO config, no custom provider.
- **Terraform.** Reads the endpoint at plan and apply time, then uses standard Vault client libraries to log in to OpenBao and read the secret.
- **Technical account ops and admin scripts.** A shell script with curl and jq fetches the JWT, then uses inline auth or the `vault` CLI to read.
<details>
<summary>Sample: ESO setup (untested, to validate in 19.2)</summary>
```yaml
# 1. Token Secret (customer-created)
# Recommended: use a token on a dedicated GitLab service account, not a personal PAT.
apiVersion: v1
kind: Secret
metadata:
name: gitlab-sm-token
type: Opaque
stringData:
token: <your-service-account-token>
---
# 2. RBAC for the refresher CronJob
apiVersion: v1
kind: ServiceAccount
metadata:
name: gitlab-sm-jwt-refresher
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: gitlab-sm-jwt-refresher
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: gitlab-sm-jwt-refresher
subjects:
- kind: ServiceAccount
name: gitlab-sm-jwt-refresher
roleRef:
kind: Role
name: gitlab-sm-jwt-refresher
apiGroup: rbac.authorization.k8s.io
---
# 3. The refresher CronJob: mints a JWT every 3 minutes and writes it to a K8s Secret
apiVersion: batch/v1
kind: CronJob
metadata:
name: gitlab-sm-jwt-refresher
spec:
schedule: "*/3 * * * *"
jobTemplate:
spec:
template:
spec:
serviceAccountName: gitlab-sm-jwt-refresher
restartPolicy: OnFailure
containers:
- name: refresh
# Any image with curl, jq, and kubectl works.
image: alpine/k8s:1.30.0
command: ["/bin/sh", "-c"]
args:
- |
set -eu
JWT=$(curl -sf -X POST \
-H "PRIVATE-TOKEN: ${GITLAB_TOKEN}" \
"${GITLAB_URL}/api/v4/projects/${PROJECT_ID}/secrets_manager/access_token" \
| jq -r .provider.vault.auth.jwt.token)
kubectl create secret generic gitlab-sm-jwt \
--from-literal=jwt="${JWT}" \
--dry-run=client -o yaml | kubectl apply -f -
env:
- name: GITLAB_TOKEN
valueFrom: { secretKeyRef: { name: gitlab-sm-token, key: token } }
- name: GITLAB_URL
value: https://gitlab.example.com
- name: PROJECT_ID
value: "12345"
---
# 4. ESO SecretStore: points at OpenBao and reads the refreshed JWT
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: gitlab-openbao
spec:
provider:
vault:
server: https://openbao.example.com
namespace: org_5/ns_42/project_99
path: secrets/kv
version: v2
auth:
jwt:
path: api_jwt
role: all_api_access
secretRef:
name: gitlab-sm-jwt
key: jwt
```
Customers then write standard `ExternalSecret` resources pointing at `gitlab-openbao` with `data/explicit/<name>` as the remote key. ESO references: [HashiCorp Vault provider](https://external-secrets.io/latest/provider/hashicorp-vault/), [SecretStore API](https://external-secrets.io/latest/api/secretstore/), [ExternalSecret API](https://external-secrets.io/latest/api/externalsecret/).
</details>
<details>
<summary>Sample: Terraform workaround (untested, until gitlab-org&21177 lands)</summary>
```hcl
variable "gitlab_token" { sensitive = true }
variable "gitlab_url" { default = "https://gitlab.example.com" }
variable "project_id" {}
# Step 1: Mint a JWT via curl wrapped in an external data source.
data "external" "gitlab_sm_jwt" {
program = ["sh", "-c", <<-EOT
curl -sf -X POST \
-H "PRIVATE-TOKEN: ${var.gitlab_token}" \
"${var.gitlab_url}/api/v4/projects/${var.project_id}/secrets_manager/access_token" \
| jq '{token: .provider.vault.auth.jwt.token}'
EOT
]
}
# Step 2: Configure the standard Vault provider with the JWT.
provider "vault" {
address = "https://openbao.example.com"
namespace = "org_5/ns_42/project_99"
auth_login_jwt {
path = "api_jwt"
role = "all_api_access"
jwt = data.external.gitlab_sm_jwt.result.token
}
}
# Step 3: Read the secret with the stock Vault data source.
data "vault_kv_secret_v2" "db_password" {
mount = "secrets/kv"
name = "explicit/DB_PASSWORD"
}
resource "aws_db_instance" "example" {
password = data.vault_kv_secret_v2.db_password.data["value"]
# ...
}
```
**State file caveat.** Terraform writes every value it reads to its state file. Once a resource references the secret, the value lives in state. This is a general Terraform property, not specific to our endpoint. Customers who cannot accept the state-file copy should not reference SM secrets in long-lived Terraform resources.
</details>
### Permissions and Security
Two layers of authorization:
1. **Minting a token** reuses the existing `:read_project_secrets` ability (and group equivalent). The role lookup goes through `max_member_access_for_*`, which already respects service account scoping (TLG, subgroup, project).
2. **Reading a value** is governed by a new, explicit `read_value` secret permission (see below). Membership alone does not expose values. An Owner must deliberately grant `read_value` to a principal. This is enforced in OpenBao: only the value-read policy grants `read` on the data path.
The minted JWT carries an `auth_via` claim (`PAT` / `PrAT` / `GrAT`) for audit forensics.
**New `read_value` permission.** Today the per-principal secret permissions are `read` (metadata only), `write`, and `delete`. None grant read on the value path, so values are not readable outside the CI flow. We add an explicit value-read permission and, to remove the "read what?" ambiguity, relabel the existing one:
| Permission | Action | OpenBao capability | Path |
|---|---|---|---|
| Read metadata (today's "Read") | `read_metadata` | `read` | metadata path |
| Write | `write` | `create` + `update` | value path |
| Delete | `delete` | `delete` | value path |
| **Read value (new)** | `read_value` | `read` | value path |
`read_value` is additive and requires `read_metadata` (you cannot read a value for a secret you cannot otherwise see). No data migration: actions are derived from OpenBao capabilities on readback, so a read capability on the metadata path maps to `read_metadata` and one on the value path maps to `read_value`. Existing grants keep working and gain nothing until someone opts in.
**Policy structure (two policies per principal).** To keep the value read off the UI mount, each principal gets two OpenBao policies:
- A **management policy** (under `users/`) used by the existing user mount: metadata read, write, delete. It never grants read on the value path, so the UI flow still cannot read values.
- A **dedicated read-only policy** (under `api/`) used by the new API mount: read on the value path only, written when `read_value` is granted and removed when it is not.
Each mount's CEL attaches its own policy. The user mount attaches the management policy, the API mount attaches the `api/` read-only policy. So an API token can read values but never write, regardless of the principal's other grants. This `api/` policy is also the surface we would extend if we later support API writes (the same mount, with write capabilities added).
Expected impact by membership level:
- [ ] No access (0): cannot mint a token. Endpoint returns 404.
- [ ] Guest (10): cannot mint a token.
- [ ] Reporter (20) and up: can mint a token. Can read values only for secrets where they (or a role/group/member-role they belong to) were granted `read_value`.
Security notes:
- The minted JWT is short-lived (proposed 5 min TTL) and scoped by CEL to the issuing project or group. A stolen JWT only grants the value reads the principal was explicitly granted, and only for that one project or group.
- Value access is opt-in per principal via `read_value`. Membership does not implicitly expose values, so this is secure by default.
- Auth accepts any user-bound token (PAT, SA token, project/group access token). No new auth mechanism. The realistic attack surface (a stolen long-lived token) is unchanged from today's GraphQL path.
- A dedicated token scope (`read_secrets_via_openbao`) is deferred to GA, coordinated with ~"group::authorization".
- A threat model will be done with the Application Security Team (`@gitlab-com/gl-security/appsec`) as part of the implementation.
### Documentation
- New API docs page for the endpoint (request, response shape, beta notice).
- A "non-CI access" guide with the ESO refresher recipe and the Terraform workaround.
- Both ship in the same milestone as the endpoint.
### Availability & Testing
No QA / E2E work is in scope for the endpoint. The existing SM RSpec suite boots a real OpenBao server via the `:gitlab_secrets_manager` tag, so the full mint-then-read path is covered at the request-spec level.
- **Unit:** new JWT class (claims, `auth_via`, TTL), CEL role program.
- **Integration (request specs, `:gitlab_secrets_manager`):**
- New endpoint contract: response shape, authz (`:read_project_secrets` reuse), feature flag off, beta, expired token, unenrolled namespace.
- JWT-against-OpenBao validation: extend `ee/spec/requests/secrets_management/authentication_boundaries_spec.rb`. It already builds each JWT type, logs into the booted OpenBao with `use_cel_auth: true`, and asserts the CEL role accepts or rejects and grants the right policies. We add the new `api_jwt` mount + `all_api_access` role as new rows, plus the negative cases (wrong project, wrong scope).
- Provisioning: service spec asserting the `api_jwt` mount + role are created on SM provision.
- **End-to-end (follow-up):** a QA spec covering the real deployed read path (call the endpoint via the API, then read from the real external OpenBao URL with the JWT). This mirrors `project_secret_ci_access_success_spec.rb` but for the API path instead of the pipeline path. Tracked as a separate issue. The full ESO-in-a-cluster path is not automated (the QA framework has no ESO harness); the ESO flow is validated manually with design partners during 19.2 beta.
Availability risk is low. The endpoint is behind a feature flag, off by default, and the new OpenBao mount is additive (existing `pipeline_jwt` and `user_jwt` mounts are untouched).
### Available Tier
Premium and Ultimate.
### Feature Usage Metrics
- Internal event on token issuance (count of mint calls per project / group).
- Read volume via the direct-OpenBao path, from audit logs (tracked in #600967).
### What does success look like, and how can we measure that?
Success metrics:
- Adoption: number of projects / groups minting access tokens.
- At least one design-partner customer running ESO against the endpoint in 19.2.
Acceptance criteria:
- An authorized caller (Reporter+) gets a valid JWT plus OpenBao connection details.
- A caller granted `read_value` can read those secret values through the `api_jwt` mount. A caller without `read_value` cannot read values, even with project membership.
- An unauthorized caller (Guest or no access) gets 404. A caller on a non-enrolled namespace gets 404. With the feature flag off, the endpoint is not exposed.
- The JWT expires at the advertised `expires_at`.
### What is the type of buyer?
Platform / infrastructure and security buyers (the same buyers who adopt Secrets Manager). This is an enablement feature for SM, gated to Premium and Ultimate.
### Is this a cross-stage feature?
Yes. It touches ~"group::authorization" for token scopes and fine-grained PATs on service accounts, and the fulfillment work (usage tracking and billing) tracked in #600967.
### What is the competitive advantage or differentiation for this feature?
Direct, Vault-compatible secret access with no proprietary SDK or agent lock-in. Customers reuse the HashiCorp Vault ecosystem they already know (ESO provider, Vault SDKs, `vault` CLI) and a GitLab token they already have. Reads are decoupled from Rails, so secret availability scales with OpenBao independently of GitLab.
### Scope and breakdown
This issue covers the **endpoint plus provisioning for new SM enrollments**, so a freshly provisioned project or group works end to end.
Implementation plan (this issue):
1. Configurable TTL on `GlobalSecretsManagerJwt` (today hardcoded to 30s).
2. New `read_value` permission in the model (relabel existing read to `read_metadata`), plus the policy wiring so `read_value` grants `read` on the value path.
3. New JWT class emitting the `api` scope and `auth_via` claim.
4. New `api_jwt` OpenBao mount + CEL role in the provision service (new enrollments get it). The role surfaces the per-principal value-read policies.
5. New endpoint (project + group) reusing `:read_project_secrets` for token issuance, behind the feature flag, marked beta.
6. Request specs (endpoint contract + `authentication_boundaries_spec.rb` extension) and provisioning service spec.
7. Docs + ESO refresher recipe.
Backend only. The `read_value` GraphQL exposure and the UI to grant it land in #602726 (frontend), which does not block this issue from merging. Until that ships, `read_value` is grantable in specs and via the API, just not through the UI.
Split out as sibling issues:
- **#602726** — "Read value" permission in the secrets permission UI (frontend + GraphQL exposure).
- **#602549** — backfill the `api_jwt` mount + role into already-enrolled SM namespaces. Operational catch-up for existing customers, separate rollout risk.
- **#602550** — QA spec for the non-CI API read path (endpoint -> real external OpenBao read).
### Maturity
Endpoint ships as **beta** (`route_setting :lifecycle, :beta`), aligned with SM's Open Beta status. It stays beta for one milestone past SM GA to firm up the spec from design-partner feedback.
### Consumers (separate work items)
This endpoint is the shared primitive. Each consumer ships on its own track.
- **Kubernetes Operator** — gitlab-org&20382
- **GitLab Terraform Provider** — gitlab-org&21177
- **glab CLI** — to be opened in `gitlab-org/cli`. Not required for Phase 1; curl-based YAML covers the urgent ESO path.
- **Workload identity federation (OIDC inbound)** — #601894. Exchanges an external OIDC token (K8s SA, AWS/GCP/Azure) for an SM JWT, removing the stored long-lived credential. Future phase.
### Links / references
- Design discussion: [#594090 (note_3391198730)](https://gitlab.com/gitlab-org/gitlab/-/work_items/594090#note_3391198730)
- Plugin Architecture Strategy MR: https://gitlab.com/gitlab-com/content-sites/handbook/-/merge_requests/17396
- Secrets Manager GA Epic: https://gitlab.com/groups/gitlab-org/-/epics/10723
- Plugin Architecture Epic: https://gitlab.com/groups/gitlab-org/-/epics/20295
- Usage tracking and billing: #600967
- Workload identity federation: #601894
- "Read value" permission UI: #602726
- Backfill for existing namespaces: #602549
- QA spec: #602550
issue