Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""GitLab Rotator module for the PAT Manager system.
|
|
|
|
Handles automatic token rotation for GitLab PATs using the
|
|
GitLab Personal Access Tokens API.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from datetime import date
|
|
|
|
import httpx
|
|
|
|
from .errors import RotationError
|
|
from .models import RotationResult
|
|
|
|
|
|
class GitLabRotator:
|
|
"""Rotates GitLab Personal Access Tokens via the GitLab API.
|
|
|
|
Uses POST /api/v4/personal_access_tokens/{id}/rotate to obtain a new
|
|
token with a fresh expiry date. Implements retry logic (one retry on
|
|
failure) and skips rotation for already-expired tokens.
|
|
|
|
Attributes:
|
|
timeout: HTTP request timeout in seconds.
|
|
"""
|
|
|
|
def __init__(self, timeout: int = 30) -> None:
|
|
self.timeout = timeout
|
|
self._gitlab_url = os.environ.get("GITLAB_URL", "https://gitlab.com")
|
|
|
|
async def rotate(
|
|
self, token_id: str, current_token: str, expiry_date: str | None = None
|
|
) -> RotationResult:
|
|
"""Rotate a GitLab PAT to obtain a new token and expiry date.
|
|
|
|
If the token is already expired (based on the provided expiry_date),
|
|
rotation is skipped and an error result is returned indicating manual
|
|
intervention is needed.
|
|
|
|
On HTTP error or timeout, retries once. If the retry also fails,
|
|
returns an error RotationResult.
|
|
|
|
Args:
|
|
token_id: The GitLab personal access token ID (numeric string).
|
|
current_token: The current token value used for authentication.
|
|
expiry_date: Optional ISO 8601 date string (YYYY-MM-DD) of the
|
|
token's current expiry. Used to skip expired tokens.
|
|
|
|
Returns:
|
|
RotationResult with success=True and new token/expiry on success,
|
|
or success=False with an error message on failure.
|
|
"""
|
|
# Skip rotation for expired tokens
|
|
if expiry_date is not None:
|
|
try:
|
|
parsed_expiry = date.fromisoformat(expiry_date)
|
|
if parsed_expiry < date.today():
|
|
return RotationResult(
|
|
success=False,
|
|
new_token=None,
|
|
new_expiry_date=None,
|
|
error_message=(
|
|
f"Token {token_id} is expired (expiry: {expiry_date}). "
|
|
"Manual intervention required — cannot rotate an expired token."
|
|
),
|
|
)
|
|
except ValueError:
|
|
# Invalid date format — proceed with rotation attempt
|
|
pass
|
|
|
|
url = f"{self._gitlab_url}/api/v4/personal_access_tokens/{token_id}/rotate"
|
|
headers = {"PRIVATE-TOKEN": current_token}
|
|
|
|
# First attempt
|
|
result = await self._attempt_rotation(url, headers, token_id)
|
|
if result.success:
|
|
return result
|
|
|
|
# Retry once on failure
|
|
result = await self._attempt_rotation(url, headers, token_id)
|
|
return result
|
|
|
|
async def _attempt_rotation(
|
|
self, url: str, headers: dict[str, str], token_id: str
|
|
) -> RotationResult:
|
|
"""Make a single rotation API call.
|
|
|
|
Args:
|
|
url: The full rotation endpoint URL.
|
|
headers: Request headers including PRIVATE-TOKEN.
|
|
token_id: The token ID for error messages.
|
|
|
|
Returns:
|
|
RotationResult indicating success or failure.
|
|
"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
response = await client.post(url, headers=headers)
|
|
except httpx.TimeoutException:
|
|
return RotationResult(
|
|
success=False,
|
|
new_token=None,
|
|
new_expiry_date=None,
|
|
error_message=f"Rotation request timed out for token {token_id}",
|
|
)
|
|
except httpx.ConnectError as exc:
|
|
return RotationResult(
|
|
success=False,
|
|
new_token=None,
|
|
new_expiry_date=None,
|
|
error_message=f"Connection error during rotation for token {token_id}: {exc}",
|
|
)
|
|
except httpx.HTTPError as exc:
|
|
return RotationResult(
|
|
success=False,
|
|
new_token=None,
|
|
new_expiry_date=None,
|
|
error_message=f"HTTP error during rotation for token {token_id}: {exc}",
|
|
)
|
|
|
|
if not (200 <= response.status_code < 300):
|
|
return RotationResult(
|
|
success=False,
|
|
new_token=None,
|
|
new_expiry_date=None,
|
|
error_message=(
|
|
f"GitLab API returned HTTP {response.status_code} "
|
|
f"for token {token_id} rotation"
|
|
),
|
|
)
|
|
|
|
# Parse successful response
|
|
try:
|
|
data = response.json()
|
|
new_token = data["token"]
|
|
new_expiry_date = data["expires_at"]
|
|
except (KeyError, ValueError) as exc:
|
|
return RotationResult(
|
|
success=False,
|
|
new_token=None,
|
|
new_expiry_date=None,
|
|
error_message=(
|
|
f"Failed to parse rotation response for token {token_id}: {exc}"
|
|
),
|
|
)
|
|
|
|
return RotationResult(
|
|
success=True,
|
|
new_token=new_token,
|
|
new_expiry_date=new_expiry_date,
|
|
error_message=None,
|
|
)
|