Add token expiry parameter to Create Runner API endpoint
### **Summary**
The [Create Runner API endpoint](https://docs.gitlab.com/api/users/#create-a-runner-linked-to-a-user) (`POST /user/runners`) currently does not support setting a custom expiration time for the generated runner authentication token. This prevents organizations from implementing automated, zero-trust runner management systems with hard token expiration policies.
### **Problem to Solve**
Organizations implementing automated "Bring Your Own Runner" (BYOR) services need the ability to set hard expiration dates on runner authentication tokens when creating runners programmatically through the API. The current API only supports instance-wide automatic rotation policies but doesn't allow per-token expiration control during creation.
**Current Limitation:**
* The API generates tokens without expiration control
* Instance-wide rotation policies don't meet granular security requirements
* No way to enforce different expiration policies for different teams/use cases
### **Proposal**
```
POST /user/runners
{
"runner_type": "project_type",
"project_id": 123,
"description": "BYOR runner",
"tag_list": ["docker"],
"token_expires_at": "2025-12-30T23:59:59Z",
"token_rotation_deadline": "2025-12-30T23:59:59Z"
}
```
1. **`token_expires_at`** (optional)
- Sets when the runner authentication token expires
- Must be 5 minutes to 15 days in the future
- Must respect existing instance/group/project-level max expiration settings (if configured)
2. **`token_rotation_deadline`** (optional)
- Sets the deadline after which token rotation requests are rejected
- Can only be specified if `token_expires_at` is also specified
- Must be ≤ `token_expires_at` (can't rotate after expiration)
- Must be ≥ current time (can't be in the past)
- Setting this equal to `token_expires_at` effectively disables token rotation
### **Behavior:**
| Condition | Result |
|-----------|--------|
| Current time < `token_rotation_deadline` < `token_expires_at` | Token is valid, rotation is allowed |
| Current time ≥ `token_rotation_deadline` AND < `token_expires_at` | Token still valid for authentication, rotation attempts are REJECTED |
| Current time ≥ `token_expires_at` | Token expired, authentication fails, rotation attempts are REJECTED |
### **Interaction with Existing Automatic Token Rotation**
Since GitLab 15.5 ([#345427](https://gitlab.com/gitlab-org/gitlab/-/issues/345427), [#30942](https://gitlab.com/gitlab-org/gitlab/-/issues/30942)), runners support automatic token rotation - runners self-rotate their tokens before expiry by calling `POST /runners/reset_authentication_token`.
**How this feature interacts with that mechanism:**
| Scenario | `token_expires_at` | `token_rotation_deadline` | Behavior |
|----------|-------------------|----------------------|----------|
| **Current** (no explicit params) | Computed from instance/group/project settings | N/A | Runner self-rotates before expiry, new token gets fresh computed expiry |
| **New:** Explicit expiry, rotation allowed | Explicit value (initial token only) | `null` or future date | Runner can self-rotate; **new token gets fresh expiry computed from current settings** (not the original explicit value) |
| **New:** Explicit expiry, rotation disabled | Explicit value | Same as `token_expires_at` | Runner cannot self-rotate; token dies at expiry; BYOR service must create new runner |
**Key design decisions:**
1. **`token_expires_at` only applies to the initial token:** The explicit `token_expires_at` parameter only applies to the **initial token at creation time**. If rotation is allowed and occurs, the new token's expiry is computed fresh using `Ci::Runner#compute_token_expiration` based on current instance/group/project settings. The explicit value is not persisted for subsequent rotations.
2. **`token_rotation_deadline` is cleared on successful rotation:** When a token is successfully rotated, `token_rotation_deadline` is reset to `NULL`. This means:
- The new token has no rotation deadline
- Subsequent rotations follow standard behavior (unrestricted, subject to token expiry)
- This is consistent with the "fresh start" semantics of a rotated token
- The BYOR use case (where `token_rotation_deadline = token_expires_at`) never hits this path since rotation is blocked entirely
This maintains consistency with the existing rotation behavior while giving BYOR services the control they need (by setting `token_rotation_deadline = token_expires_at` to disable rotation entirely).
#### **Validation Range for `token_expires_at`**
* **Minimum**: 5 minutes in the future (prevents accidental immediate expiration)
* **Maximum**: 15 days (or instance/group/project limit if lower)
#### **Instance/Group/Project Level Settings (Already Exist)**
The existing runner token expiration settings (configured in Admin > Settings > CI/CD) act as maximum constraints:
- `runner_token_expiration_interval` (instance runners)
- `group_runner_token_expiration_interval` (group runners)
- `project_runner_token_expiration_interval` (project runners)
**Validation rules:**
- If an instance/group/project limit is configured, `token_expires_at` cannot exceed it
- If no limit is configured, the API validation bounds apply (5 min to 15 days)
- The effective maximum is `min(15 days, instance/group/project limit)`
No new admin settings are required for this feature.
## **Use Case: Enterprise BYOR (Bring Your Own Runner) Service**
### **Customer Context**
Large financial services organization (4,000+ users affected) implementing automated runner provisioning.
### **BYOR Architecture**
* Internal service generates and manages runner tokens programmatically
* Onboarding service validates authentication and authorization requirements
* If authorized, automates runner creation and token registration (zero-trust)
* Tokens are never exposed to humans
* Currently in pilot phase, blocked from production rollout
### **Security Requirements**
* :white_check_mark: Runners must not last more than 14 days (compliance requirement)
* :white_check_mark: Tokens must have hard expiration (cannot rely on manual cleanup)
* :white_check_mark: **No token self-rotation** (only new tokens issued by BYOR service)
* :white_check_mark: Automated token management without human intervention
* :white_check_mark: When token expires, runner dies (BYOR service creates new runner/token)
### **Current Blocker**
* Without API-level token expiry and rotation control, BYOR service cannot enforce 14-day lifecycle
* Tokens currently have no automatic expiration mechanism
* Cannot prevent unwanted token rotation
* Manual workarounds defeat the purpose of zero-trust automation
* **Blocking 4,000+ users from adopting GitLab CI**
Related to: [#345427](https://gitlab.com/gitlab-org/gitlab/-/issues/345427), [#30942](https://gitlab.com/gitlab-org/gitlab/-/issues/30942)
---
## Implementation Plan
### Overview
This feature adds two optional parameters (`token_expires_at` and `token_rotation_deadline`) to the `POST /user/runners` API endpoint. The existing runner infrastructure already supports token expiration - we need to expose the ability to set it explicitly at creation time, and add a new mechanism to control rotation deadlines.
**Estimated Weight:** 3
### Phase 1: API Layer Changes
#### 1.1 Update API Parameter Definition
**File:** `lib/api/user_runners.rb`
Add two new optional parameters to the `POST /user/runners` endpoint:
```ruby
optional :token_expires_at, type: DateTime,
desc: 'The expiration time for the runner authentication token (ISO 8601 format). ' \
'Must be between 5 minutes and 15 days in the future, ' \
'and cannot exceed instance/group/project limits.'
optional :token_rotation_deadline, type: DateTime,
desc: 'The deadline for token rotation (ISO 8601 format). ' \
'Must be specified with token_expires_at and be <= token_expires_at.'
```
Add conditional requirement so `token_rotation_deadline` requires `token_expires_at`:
```ruby
given token_rotation_deadline: ->(val) { val.present? } do
requires :token_expires_at, type: DateTime,
desc: 'Required when token_rotation_deadline is specified.'
end
```
Update `attributes_for_keys` to include the new parameters:
```ruby
attributes = attributes_for_keys(
%i[runner_type group_id project_id description maintenance_note paused locked run_untagged tag_list
access_level maximum_timeout token_expires_at token_rotation_deadline]
)
```
### Phase 2: Service Layer Changes
#### 2.1 Update CreateRunnerService
**File:** `app/services/ci/runners/create_runner_service.rb`
Add duration constants and extract the new params in `normalize_params`, then validate in `execute`:
```ruby
MINIMUM_TOKEN_EXPIRY_DURATION = 5.minutes
MAXIMUM_TOKEN_EXPIRY_DURATION = 15.days
def normalize_params
params[:registration_type] = :authenticated_user
params[:active] = !params.delete(:paused) if params.key?(:paused)
params[:creator] = user
# Extract token expiration params before they reach the model
@token_expires_at = params.delete(:token_expires_at)
@token_rotation_deadline = params.delete(:token_rotation_deadline)
strategy.normalize_params
end
def execute
normalize_params
error = strategy.validate_params
return ServiceResponse.error(message: error, reason: :validation_error) if error
unless strategy.authorized_user?
return ServiceResponse.error(message: _('Insufficient permissions'), reason: :forbidden)
end
error = validate_token_expiration_params
return ServiceResponse.error(message: error, reason: :validation_error) if error
should_mark_hosted = params.delete(:hosted_runner)
runner = ::Ci::Runner.new(params)
# Set explicit token expiration on the runner (attr_accessor, not persisted)
runner.explicit_token_expires_at = @token_expires_at if @token_expires_at
runner.token_rotation_deadline = @token_rotation_deadline if @token_rotation_deadline
create_runner(runner, should_mark_hosted)
end
```
Validation methods:
```ruby
def validate_token_expiration_params
return unless @token_expires_at
expires_at = @token_expires_at
now = Time.current
min_expiry = now + MINIMUM_TOKEN_EXPIRY_DURATION
max_expiry = now + MAXIMUM_TOKEN_EXPIRY_DURATION
if expires_at < min_expiry
return format(
s_('Runners|token_expires_at must be at least %{minimum} in the future'),
minimum: MINIMUM_TOKEN_EXPIRY_DURATION.inspect
)
end
effective_max = calculate_effective_max_expiry(max_expiry)
if expires_at > effective_max
return format(
s_('Runners|token_expires_at is too far in the future (maximum is %{maximum})'),
maximum: effective_max.iso8601
)
end
validate_token_rotation_deadline(expires_at)
end
def validate_token_rotation_deadline(expires_at)
return unless @token_rotation_deadline
return s_('Runners|token_rotation_deadline cannot be in the past') if @token_rotation_deadline.past?
return unless @token_rotation_deadline > expires_at
s_('Runners|token_rotation_deadline must be less than or equal to token_expires_at')
end
def calculate_effective_max_expiry(default_max)
interval = case params[:runner_type]
when 'instance_type'
Gitlab::CurrentSettings.runner_token_expiration_interval
when 'group_type', 'project_type'
scope&.effective_runner_token_expiration_interval
end
return default_max unless interval
[default_max, interval.seconds.from_now].min
end
```
### Phase 3: Model Layer Changes
#### 3.1 Update Ci::Runner Model
**File:** `app/models/ci/runner.rb`
The model already has `token_expires_at` attribute (persisted) and the `compute_token_expiration` method that respects instance/group/project settings. We modify it to accept an explicit value **only at creation time**:
```ruby
# Used only at creation time to set an explicit token expiration via the API.
# Not persisted -- only affects the initial token. On rotation, compute_token_expiration
# falls back to the standard computation logic.
attr_accessor :explicit_token_expires_at
def compute_token_expiration
# If explicitly set via API at creation time, use that value.
# This attr_accessor is not persisted, so it only affects the initial token.
# When reset_token! is called during rotation, explicit_token_expires_at will be nil
# and the standard computation logic applies.
return explicit_token_expires_at if explicit_token_expires_at.present?
case runner_type
when 'instance_type'
compute_token_expiration_instance
when 'group_type'
compute_token_expiration_group
when 'project_type'
compute_token_expiration_project
end
end
```
**Note:** `explicit_token_expires_at` is an `attr_accessor` (not persisted), so it only affects the initial token. When `reset_token!` is called during rotation, `explicit_token_expires_at` will be `nil` and the standard computation logic applies.
#### 3.2 Add token_rotation_deadline Column
**Migration:** Add `token_rotation_deadline` column to `ci_runners` table:
```ruby
class AddTokenRotationDeadlineToCiRunners < Gitlab::Database::Migration[2.3]
milestone '18.10'
def up
add_column :ci_runners, :token_rotation_deadline, :datetime_with_timezone, if_not_exists: true
end
def down
remove_column :ci_runners, :token_rotation_deadline, if_exists: true
end
end
```
### Phase 4: Token Rotation Enforcement
#### 4.1 Update ResetAuthenticationTokenService
**File:** `app/services/ci/runners/reset_authentication_token_service.rb`
Add check for `token_rotation_deadline` deadline and clear it on successful rotation:
```ruby
def execute!
return ServiceResponse.error(message: 'Not permitted to reset', reason: :forbidden) unless reset_permitted?
if rotation_deadline_passed?
return ServiceResponse.error(
message: s_('Runners|Token rotation deadline has passed'),
reason: :forbidden
)
end
runner.reset_token!
# Clear rotation deadline for the new token -- it gets a fresh start
runner.update_column(:token_rotation_deadline, nil) if runner.token_rotation_deadline.present?
ServiceResponse.success
end
private
def rotation_deadline_passed?
runner.token_rotation_deadline.present? && Time.current >= runner.token_rotation_deadline
end
```
### Phase 5: Documentation Updates
#### 5.1 API Documentation
**File:** `doc/api/users.md`
Update the "Create a runner linked to a user" section to document the new parameters:
| Attribute | Type | Required | Description |
|-----------|------|----------|-------------|
| `token_expires_at` | datetime | No | The expiration time for the runner authentication token (ISO 8601 format). Must be between 5 minutes and 15 days in the future. Cannot exceed instance/group/project-level limits if configured. Only applies to the initial token; rotated tokens use computed expiry from settings. |
| `token_rotation_deadline` | datetime | No | The deadline after which token rotation is rejected. Requires `token_expires_at`. Must be ≤ `token_expires_at`. Setting both to the same value effectively disables token rotation. Cleared on successful rotation. |
### Phase 6: Testing
#### 6.1 Unit Tests
**File:** `spec/services/ci/runners/create_runner_service_spec.rb`
Add test cases for:
- Creating runner with `token_expires_at` only
- Creating runner with both `token_expires_at` and `token_rotation_deadline`
- Validation: `token_expires_at` less than 5 minutes in future (should fail)
- Validation: `token_expires_at` more than 15 days in future (should fail)
- Validation: `token_expires_at` exceeds instance/group/project limit (should fail)
- Validation: `token_rotation_deadline` without `token_expires_at` (should fail)
- Validation: `token_rotation_deadline` > `token_expires_at` (should fail)
- Validation: `token_rotation_deadline` in the past (should fail)
**File:** `spec/services/ci/runners/reset_authentication_token_service_spec.rb`
Add test cases for:
- Token rotation allowed when `token_rotation_deadline` is nil
- Token rotation allowed when current time < `token_rotation_deadline`
- Token rotation rejected when current time >= `token_rotation_deadline`
- After successful rotation, `token_rotation_deadline` is cleared to nil
- After successful rotation, new token has fresh computed expiry (not original explicit value)
**File:** `spec/models/ci/runner_spec.rb`
Add test cases for:
- `compute_token_expiration` returns `explicit_token_expires_at` when set
- `compute_token_expiration` falls back to standard computation when `explicit_token_expires_at` is nil
#### 6.2 API Tests
**File:** `spec/requests/api/user_runners_spec.rb`
Add request specs for the new parameters covering success and error scenarios.
### Files to Modify
| File | Change Type | Purpose |
|------|-------------|---------|
| `lib/api/user_runners.rb` | Modify | Add new API parameters |
| `app/services/ci/runners/create_runner_service.rb` | Modify | Add validation logic for `token_expires_at` and `token_rotation_deadline` |
| `app/models/ci/runner.rb` | Modify | Support explicit `token_expires_at` in `compute_token_expiration` |
| `app/services/ci/runners/reset_authentication_token_service.rb` | Modify | Enforce `token_rotation_deadline` deadline and clear on rotation |
| `db/migrate/XXXXXX_add_token_rotation_deadline_to_ci_runners.rb` | New | Add `token_rotation_deadline` column |
| `doc/api/users.md` | Modify | Document new parameters |
| `spec/services/ci/runners/create_runner_service_spec.rb` | Modify | Unit tests |
| `spec/services/ci/runners/reset_authentication_token_service_spec.rb` | Modify | Unit tests |
| `spec/models/ci/runner_spec.rb` | Modify | Unit tests |
| `spec/requests/api/user_runners_spec.rb` | Modify | API tests |
### Rollout Plan
1. **Feature Flag:** Consider adding a feature flag `runner_token_expiry_api` for gradual rollout
2. **Monitoring:** Add logging for token expiration validation failures
3. **Metrics:** Track usage of new parameters via internal events
### Open Questions
1. Should we also add these parameters to the GraphQL `runnerCreate` mutation? (Recommend: Yes, for consistency)
2. Should the `token_rotation_deadline` enforcement also apply to admin-initiated token resets? (Recommend: No, admins should be able to override)
issue