Migrate all repos into monorepo context folders

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.
This commit is contained in:
2026-06-30 20:39:52 +02:00
parent 2f2b295531
commit a5f8fb49ab
1717 changed files with 447332 additions and 0 deletions
@@ -0,0 +1,3 @@
"""PAT Manager - Automated Personal Access Token lifecycle management."""
__version__ = "0.1.0"
@@ -0,0 +1,332 @@
"""Main orchestrator for the PAT Manager system.
Entry point for running the PAT lifecycle check workflow.
Coordinates all modules: registry loading, expiry checking, rotation,
secret updates, alerting, and summary reporting.
Usage:
python -m scripts.pat_manager
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from .alerter import AlertSender
from .checker import ExpiryChecker
from .errors import AlertError, RegistryValidationError, SecretUpdateError
from .models import AlertChannel, CheckResult, PatEntry, PatStatus, RotationResult
from .registry import PatRegistry
from .reporter import SummaryReporter
from .rotator import GitLabRotator
from .secret_updater import SecretUpdater
logger = logging.getLogger(__name__)
# Repository root: __main__.py is at scripts/pat_manager/__main__.py
# so parent.parent.parent gets us to the repo root
REPO_ROOT = Path(__file__).parent.parent.parent
REGISTRY_PATH = REPO_ROOT / "pat-registry.json"
def _load_secrets() -> dict[str, str]:
"""Load all PAT secrets from environment variables.
Returns:
Dictionary mapping secret names to their values.
"""
secret_names = [
"GITLAB_PAT",
"JIRA_PAT",
"CONFLUENCE_PAT",
"ORGMYLIFE_API_KEY",
"GH_TOKEN",
]
return {name: os.environ.get(name, "") for name in secret_names}
def _mask_secrets(secrets: dict[str, str]) -> None:
"""Mask all secret values in GitHub Actions logs.
Prints ::add-mask:: directives for each non-empty secret value
so that GitHub Actions replaces any occurrence with ***.
Args:
secrets: Dictionary of secret name → value pairs.
"""
for value in secrets.values():
if value:
print(f"::add-mask::{value}")
def _load_config() -> dict[str, str]:
"""Load configuration from environment variables.
Returns:
Dictionary of configuration values for alert sending.
"""
return {
"alert_channel": os.environ.get("ALERT_CHANNEL", "orgmylife"),
"orgmylife_url": os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"),
"orgmylife_user": os.environ.get("ORGMYLIFE_USER", ""),
"orgmylife_pass": os.environ.get("ORGMYLIFE_PASS", ""),
}
def _get_alert_channel(config: dict[str, str]) -> AlertChannel:
"""Determine the alert channel from configuration.
Args:
config: Configuration dictionary with 'alert_channel' key.
Returns:
The AlertChannel enum value.
"""
channel_str = config.get("alert_channel", "orgmylife").lower()
if channel_str == "email":
return AlertChannel.EMAIL
return AlertChannel.ORGMYLIFE
async def main() -> int:
"""Main orchestrator function coordinating all PAT Manager modules.
Flow:
1. Mask all token values with ::add-mask:: before any operations
2. Load the PAT registry (create empty if not found)
3. Run expiry checks on all entries
4. For GitLab tokens classified as "expiring soon": attempt rotation
5. For successful rotations: update GitHub secret, update registry expiry_date
6. If rotation succeeds but secret update fails: trigger alert flow
7. For manual-renewal tokens classified as "expiring soon" or "expired": send alerts
8. For GitLab tokens that failed rotation: also send alerts
9. Generate and print summary report
10. Return appropriate exit code
Returns:
Exit code:
0 - All checks passed, all actions succeeded
1 - One or more alerts could not be delivered
2 - Registry validation failed (malformed registry file)
"""
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# Step 1: Load and mask secrets
secrets = _load_secrets()
_mask_secrets(secrets)
config = _load_config()
# Step 2: Load the PAT registry
registry = PatRegistry()
try:
if not REGISTRY_PATH.exists():
logger.info("Registry file not found, creating empty registry at %s", REGISTRY_PATH)
registry.create_empty(REGISTRY_PATH)
entries: list[PatEntry] = []
else:
entries = registry.load(REGISTRY_PATH)
logger.info("Loaded %d entries from registry", len(entries))
except (json.JSONDecodeError, RegistryValidationError) as exc:
logger.error("Registry validation failed: %s", exc)
print(f"::error::Registry validation failed: {exc}")
return 2
# If no entries, just report and exit
if not entries:
reporter = SummaryReporter()
summary = reporter.report([], [])
print(summary)
return 0
# Step 3: Run expiry checks on all entries
checker = ExpiryChecker()
check_results = await checker.check_all(entries, secrets)
# Step 4-6: Handle GitLab tokens classified as "expiring soon"
rotator = GitLabRotator()
rotations: list[RotationResult] = []
alert_needed: list[CheckResult] = []
alert_delivery_failed = False
gh_token = secrets.get("GH_TOKEN", "")
repo_owner = os.environ.get("GITHUB_REPOSITORY_OWNER", "")
repo_name = os.environ.get("GITHUB_REPOSITORY_NAME", "")
# If GITHUB_REPOSITORY is set (format: owner/repo), parse it
github_repository = os.environ.get("GITHUB_REPOSITORY", "")
if github_repository and "/" in github_repository:
parts = github_repository.split("/", 1)
if not repo_owner:
repo_owner = parts[0]
if not repo_name:
repo_name = parts[1]
# Map service to GitHub secret name for updates
service_to_secret: dict[str, str] = {
"GitLab": "GITLAB_PAT",
}
for result in check_results:
if (
result.entry.service == "GitLab"
and result.entry.renewal_method == "auto"
and result.status == PatStatus.EXPIRING_SOON
):
# Attempt rotation for GitLab tokens expiring soon
old_expiry = result.entry.expiry_date
token_id = os.environ.get("GITLAB_TOKEN_ID", "")
current_token = secrets.get("GITLAB_PAT", "")
if not token_id or not current_token:
logger.warning(
"Cannot rotate %s: missing GITLAB_TOKEN_ID or GITLAB_PAT",
result.entry.token_name,
)
rotation_result = RotationResult(
success=False,
new_token=None,
new_expiry_date=None,
error_message="Missing GITLAB_TOKEN_ID or GITLAB_PAT environment variable",
entry=result.entry,
old_expiry_date=old_expiry,
)
rotations.append(rotation_result)
alert_needed.append(result)
continue
rotation_result = await rotator.rotate(
token_id=token_id,
current_token=current_token,
expiry_date=old_expiry,
)
rotation_result.entry = result.entry
rotation_result.old_expiry_date = old_expiry
if rotation_result.success:
# Log rotation event (Req 3.6)
print(
f"Rotated GitLab token '{result.entry.token_name}': "
f"old expiry={old_expiry}, new expiry={rotation_result.new_expiry_date}"
)
# Mask the new token value immediately
if rotation_result.new_token:
print(f"::add-mask::{rotation_result.new_token}")
# Step 5: Update GitHub secret with new token
secret_update_success = False
if gh_token and repo_owner and repo_name:
secret_name = service_to_secret.get(result.entry.service, "GITLAB_PAT")
updater = SecretUpdater(
github_token=gh_token,
repo_owner=repo_owner,
repo_name=repo_name,
)
try:
await updater.update_secret(secret_name, rotation_result.new_token or "")
secret_update_success = True
logger.info(
"Updated GitHub secret '%s' after rotation of '%s'",
secret_name,
result.entry.token_name,
)
except SecretUpdateError as exc:
logger.error(
"Secret update failed for '%s' after rotation: %s",
result.entry.token_name,
exc,
)
secret_update_success = False
else:
logger.error(
"Cannot update GitHub secret: missing GH_TOKEN, "
"GITHUB_REPOSITORY_OWNER, or GITHUB_REPOSITORY_NAME"
)
secret_update_success = False
if secret_update_success:
# Update registry with new expiry date
result.entry.expiry_date = rotation_result.new_expiry_date
registry.save(REGISTRY_PATH, entries)
else:
# Step 6: Rotation succeeded but secret update failed → alert
alert_needed.append(result)
else:
# Rotation failed → alert
logger.warning(
"Rotation failed for '%s': %s",
result.entry.token_name,
rotation_result.error_message,
)
alert_needed.append(result)
rotations.append(rotation_result)
elif (
result.entry.renewal_method == "manual"
and result.status in (PatStatus.EXPIRING_SOON, PatStatus.EXPIRED)
):
# Step 7: Manual-renewal tokens expiring soon or expired → alert
alert_needed.append(result)
elif (
result.entry.service == "GitLab"
and result.status == PatStatus.EXPIRED
):
# Expired GitLab tokens cannot be auto-rotated → alert
alert_needed.append(result)
# Step 8: Send alerts for all tokens that need them
if alert_needed:
alert_channel = _get_alert_channel(config)
alert_config = {
"orgmylife_url": config.get("orgmylife_url", ""),
"orgmylife_api_key": secrets.get("ORGMYLIFE_API_KEY", ""),
"orgmylife_user": config.get("orgmylife_user", ""),
"orgmylife_pass": config.get("orgmylife_pass", ""),
}
alerter = AlertSender(channel=alert_channel, config=alert_config)
for result in alert_needed:
try:
alert_result = await alerter.send_alert(result)
if alert_result.sent:
logger.info(
"Alert sent for %s/%s via %s",
result.entry.service,
result.entry.token_name,
alert_channel.value,
)
except AlertError as exc:
logger.error(
"Alert delivery failed for %s/%s: %s",
result.entry.service,
result.entry.token_name,
exc,
)
alert_delivery_failed = True
# Step 9: Generate and print summary report
reporter = SummaryReporter()
summary = reporter.report(check_results, rotations)
print(summary)
# Step 10: Return appropriate exit code
if alert_delivery_failed:
return 1
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
@@ -0,0 +1,437 @@
"""Alert Sender module for the PAT Manager system.
Sends alerts via OrgMyLife task creation or email when PATs require
manual renewal. Supports idempotent alerting by checking for existing
open tasks before creating duplicates.
"""
from __future__ import annotations
import asyncio
import logging
import os
import smtplib
from email.message import EmailMessage
import httpx
from .errors import AlertError
from .models import AlertChannel, AlertResult, CheckResult
logger = logging.getLogger(__name__)
class AlertSender:
"""Sends alert notifications for PATs requiring manual renewal.
Supports two channels:
- OrgMyLife: Creates a task via the OrgMyLife API
- Email: Sends an email via SMTP
Attributes:
channel: The alert delivery channel (OrgMyLife or email).
config: Configuration dict containing API keys, email settings, etc.
"""
def __init__(self, channel: AlertChannel, config: dict) -> None:
"""Initialize the AlertSender.
Args:
channel: The alert channel to use (OrgMyLife or email).
config: Configuration dictionary. Expected keys depend on channel:
OrgMyLife:
- orgmylife_url: Base URL (default: https://orgmylife.app)
- orgmylife_api_key: API key for X-API-Key header
- orgmylife_user: Basic auth username (alternative)
- orgmylife_pass: Basic auth password (alternative)
Email:
- smtp_host: SMTP server hostname
- smtp_port: SMTP server port
- smtp_user: SMTP username
- smtp_pass: SMTP password
- alert_email_to: Recipient email address
"""
self.channel = channel
self.config = config
async def send_alert(self, result: CheckResult) -> AlertResult:
"""Send an alert for a PAT that requires manual renewal.
Creates an OrgMyLife task or sends an email based on the configured
channel. Implements retry logic (1 retry with 2s backoff) on failure.
Args:
result: The CheckResult for the PAT requiring an alert.
Returns:
AlertResult indicating whether the alert was sent successfully.
Raises:
AlertError: If the alert cannot be delivered after retries.
"""
if self.channel == AlertChannel.ORGMYLIFE:
return await self._send_orgmylife_alert(result)
elif self.channel == AlertChannel.EMAIL:
return await self._send_email_alert(result)
else:
raise AlertError(
channel=self.channel.value,
reason=f"Unsupported alert channel: {self.channel.value}",
)
async def check_existing_task(
self, service: str, token_name: str, expiry_date: str
) -> bool:
"""Check if an open OrgMyLife task already exists for this PAT.
Queries the OrgMyLife API for open tasks and checks if one matches
the expected title pattern for this PAT and expiry date.
Args:
service: The service name (e.g. "Jira").
token_name: The token identifier.
expiry_date: The expiry date string (ISO 8601).
Returns:
True if a matching open task already exists, False otherwise.
"""
base_url = self.config.get(
"orgmylife_url",
os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"),
)
url = f"{base_url}/api/tasks"
headers = self._get_orgmylife_headers()
expected_title = f"[PAT Renewal] {service} - {token_name}"
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.get(url, headers=headers)
if not (200 <= response.status_code < 300):
# Cannot verify existing tasks — proceed with creation
logger.warning(
"Could not check existing tasks (HTTP %d), proceeding with creation",
response.status_code,
)
return False
tasks = response.json()
# Search for an open task with matching title
if isinstance(tasks, list):
for task in tasks:
title = task.get("title", "")
if title == expected_title:
return True
elif isinstance(tasks, dict):
# Handle paginated or wrapped response
task_list = tasks.get("tasks", tasks.get("items", []))
for task in task_list:
title = task.get("title", "")
if title == expected_title:
return True
except (httpx.TimeoutException, httpx.ConnectError) as exc:
logger.warning(
"Could not check existing tasks (%s), proceeding with creation",
exc,
)
return False
return False
def _get_orgmylife_headers(self) -> dict[str, str]:
"""Build authentication headers for OrgMyLife API.
Prefers API key authentication. Falls back to basic auth if
API key is not available.
Returns:
Dictionary of HTTP headers for authentication.
"""
api_key = self.config.get(
"orgmylife_api_key",
os.environ.get("ORGMYLIFE_API_KEY", ""),
)
if api_key:
return {
"X-API-Key": api_key,
"Content-Type": "application/json",
}
# Fall back to basic auth
user = self.config.get(
"orgmylife_user",
os.environ.get("ORGMYLIFE_USER", ""),
)
password = self.config.get(
"orgmylife_pass",
os.environ.get("ORGMYLIFE_PASS", ""),
)
if user and password:
import base64
credentials = base64.b64encode(f"{user}:{password}".encode()).decode()
return {
"Authorization": f"Basic {credentials}",
"Content-Type": "application/json",
}
return {"Content-Type": "application/json"}
def _build_task_title(self, result: CheckResult) -> str:
"""Build the OrgMyLife task title for a PAT alert.
Args:
result: The CheckResult for the PAT.
Returns:
Task title in format: [PAT Renewal] {service} - {token_name}
"""
return f"[PAT Renewal] {result.entry.service} - {result.entry.token_name}"
def _build_task_description(self, result: CheckResult) -> str:
"""Build the OrgMyLife task description for a PAT alert.
Args:
result: The CheckResult for the PAT.
Returns:
Task description with days remaining and renewal instructions.
"""
service = result.entry.service
token_name = result.entry.token_name
expiry_date = result.entry.expiry_date or "unknown"
days_remaining = result.days_remaining if result.days_remaining is not None else 0
if days_remaining == 0:
return (
f"Token has expired (0 days remaining). Manual renewal required.\n\n"
f"Service: {service}\n"
f"Token: {token_name}\n"
f"Expiry date: {expiry_date}"
)
else:
return (
f"Token expires in {days_remaining} days ({expiry_date}). "
f"Manual renewal required.\n\n"
f"Service: {service}\n"
f"Token: {token_name}\n"
f"Expiry date: {expiry_date}"
)
async def _send_orgmylife_alert(self, result: CheckResult) -> AlertResult:
"""Send an alert by creating an OrgMyLife task.
Checks for existing tasks first (idempotent alerting), then creates
a new task if none exists. Retries once with 2s backoff on failure.
Args:
result: The CheckResult for the PAT.
Returns:
AlertResult indicating success or failure.
Raises:
AlertError: If task creation fails after retry.
"""
service = result.entry.service
token_name = result.entry.token_name
expiry_date = result.entry.expiry_date or ""
# Check for existing task (idempotent alerting)
if expiry_date:
existing = await self.check_existing_task(service, token_name, expiry_date)
if existing:
logger.info(
"Open task already exists for %s/%s, skipping creation",
service,
token_name,
)
return AlertResult(
sent=False,
channel=AlertChannel.ORGMYLIFE,
error_message=None,
)
base_url = self.config.get(
"orgmylife_url",
os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app"),
)
url = f"{base_url}/api/tasks"
headers = self._get_orgmylife_headers()
title = self._build_task_title(result)
description = self._build_task_description(result)
payload = {"title": title, "description": description}
# Attempt with 1 retry and 2s backoff
last_error: str | None = None
for attempt in range(2):
try:
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(url, headers=headers, json=payload)
if 200 <= response.status_code < 300:
logger.info(
"Created OrgMyLife task for %s/%s: %s",
service,
token_name,
title,
)
return AlertResult(
sent=True,
channel=AlertChannel.ORGMYLIFE,
error_message=None,
)
else:
last_error = (
f"OrgMyLife API returned HTTP {response.status_code}"
)
logger.warning(
"OrgMyLife task creation failed (attempt %d): HTTP %d",
attempt + 1,
response.status_code,
)
except (httpx.TimeoutException, httpx.ConnectError) as exc:
last_error = f"OrgMyLife API unreachable: {exc}"
logger.warning(
"OrgMyLife task creation failed (attempt %d): %s",
attempt + 1,
exc,
)
# Retry after 2s backoff (only on first attempt)
if attempt == 0:
await asyncio.sleep(2)
# All attempts failed
error_msg = last_error or "Unknown error creating OrgMyLife task"
logger.error("Alert delivery failed via orgmylife: %s", error_msg)
raise AlertError(channel="orgmylife", reason=error_msg)
async def _send_email_alert(self, result: CheckResult) -> AlertResult:
"""Send an alert via email using SMTP.
Retries once with 2s backoff on failure.
Args:
result: The CheckResult for the PAT.
Returns:
AlertResult indicating success or failure.
Raises:
AlertError: If email sending fails after retry.
"""
smtp_host = self.config.get(
"smtp_host", os.environ.get("SMTP_HOST", "")
)
smtp_port = int(
self.config.get("smtp_port", os.environ.get("SMTP_PORT", "587"))
)
smtp_user = self.config.get(
"smtp_user", os.environ.get("SMTP_USER", "")
)
smtp_pass = self.config.get(
"smtp_pass", os.environ.get("SMTP_PASS", "")
)
email_to = self.config.get(
"alert_email_to", os.environ.get("ALERT_EMAIL_TO", "")
)
if not smtp_host or not email_to:
error_msg = "SMTP configuration incomplete (missing smtp_host or alert_email_to)"
logger.error("Alert delivery failed via email: %s", error_msg)
raise AlertError(channel="email", reason=error_msg)
service = result.entry.service
token_name = result.entry.token_name
expiry_date = result.entry.expiry_date or "unknown"
days_remaining = result.days_remaining if result.days_remaining is not None else 0
subject = f"[PAT Renewal] {service} - {token_name}"
if days_remaining == 0:
body = (
f"Token has expired (0 days remaining). Manual renewal required.\n\n"
f"Service: {service}\n"
f"Token: {token_name}\n"
f"Expiry date: {expiry_date}\n\n"
f"Please renew this token as soon as possible."
)
else:
body = (
f"Token expires in {days_remaining} days ({expiry_date}). "
f"Manual renewal required.\n\n"
f"Service: {service}\n"
f"Token: {token_name}\n"
f"Expiry date: {expiry_date}\n\n"
f"Please renew this token before it expires."
)
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = smtp_user or "pat-manager@noreply.local"
msg["To"] = email_to
msg.set_content(body)
# Attempt with 1 retry and 2s backoff
last_error: str | None = None
for attempt in range(2):
try:
await asyncio.to_thread(
self._send_smtp_message, smtp_host, smtp_port, smtp_user, smtp_pass, msg
)
logger.info(
"Sent email alert for %s/%s to %s",
service,
token_name,
email_to,
)
return AlertResult(
sent=True,
channel=AlertChannel.EMAIL,
error_message=None,
)
except Exception as exc:
last_error = f"Email sending failed: {exc}"
logger.warning(
"Email alert failed (attempt %d): %s",
attempt + 1,
exc,
)
# Retry after 2s backoff (only on first attempt)
if attempt == 0:
await asyncio.sleep(2)
# All attempts failed
error_msg = last_error or "Unknown error sending email"
logger.error("Alert delivery failed via email: %s", error_msg)
raise AlertError(channel="email", reason=error_msg)
@staticmethod
def _send_smtp_message(
host: str, port: int, user: str, password: str, msg: EmailMessage
) -> None:
"""Send an email message via SMTP (blocking, run in thread).
Args:
host: SMTP server hostname.
port: SMTP server port.
user: SMTP username for authentication.
password: SMTP password for authentication.
msg: The email message to send.
"""
with smtplib.SMTP(host, port, timeout=30) as server:
server.ehlo()
if port != 25:
server.starttls()
server.ehlo()
if user and password:
server.login(user, password)
server.send_message(msg)
@@ -0,0 +1,560 @@
"""Expiry Checker module for the PAT Manager system.
Queries each service API to determine PAT status and classifies tokens
as healthy, expiring soon, expired, or check failed.
"""
from __future__ import annotations
import asyncio
import os
from datetime import date, timedelta
import httpx
from .models import CheckResult, PatEntry, PatStatus
def classify_by_date(
expiry_date: str, reference_date: date, window: int
) -> tuple[PatStatus, int]:
"""Pure function that classifies a PAT based on its expiry date.
Compares the expiry_date against the reference_date and window to determine
the token's status.
Args:
expiry_date: ISO 8601 date string (YYYY-MM-DD) representing token expiry.
reference_date: The date to compare against (typically today).
window: Number of days before expiry to classify as "expiring soon".
Returns:
A tuple of (status, days_remaining) where:
- status is one of PatStatus.EXPIRED, EXPIRING_SOON, or HEALTHY
- days_remaining is max(0, days until expiry)
Classification rules:
- "expired" if expiry_date < reference_date
- "expiring soon" if reference_date <= expiry_date <= reference_date + timedelta(days=window)
- "healthy" if expiry_date > reference_date + timedelta(days=window)
"""
parsed_expiry = date.fromisoformat(expiry_date)
days_remaining = (parsed_expiry - reference_date).days
if parsed_expiry < reference_date:
return PatStatus.EXPIRED, 0
elif parsed_expiry <= reference_date + timedelta(days=window):
return PatStatus.EXPIRING_SOON, days_remaining
else:
return PatStatus.HEALTHY, days_remaining
class ExpiryChecker:
"""Checks PAT expiry status across all registered services.
Attributes:
expiry_window_days: Number of days before expiry to classify as "expiring soon".
timeout: HTTP request timeout in seconds.
max_retries: Maximum number of retry attempts for failed API calls.
"""
def __init__(
self,
expiry_window_days: int = 14,
timeout: int = 30,
max_retries: int = 2,
) -> None:
self.expiry_window_days = expiry_window_days
self.timeout = timeout
self.max_retries = max_retries
async def check_all(
self, entries: list[PatEntry], secrets: dict[str, str]
) -> list[CheckResult]:
"""Check expiry status for all PAT entries.
Iterates all entries and calls the appropriate service-specific check
method for each one. Returns exactly one CheckResult per entry.
Args:
entries: List of PAT entries to check.
secrets: Mapping of environment variable names to token values
(e.g. {"GITLAB_PAT": "glpat-xxx", "JIRA_PAT": "token"}).
Returns:
A list of CheckResult objects, one per entry, each with exactly
one status from {healthy, expiring soon, expired, check failed}.
"""
results: list[CheckResult] = []
# Map service names to their secret environment variable names
service_secret_map: dict[str, str] = {
"GitLab": "GITLAB_PAT",
"Jira": "JIRA_PAT",
"Confluence": "CONFLUENCE_PAT",
"OrgMyLife": "ORGMYLIFE_API_KEY",
}
for entry in entries:
secret_key = service_secret_map.get(entry.service, "")
token = secrets.get(secret_key, "")
if not token:
results.append(
CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"No secret found for service '{entry.service}' (expected env var: {secret_key})",
)
)
continue
try:
if entry.service == "GitLab":
result = await self.check_gitlab(entry, token)
elif entry.service == "Jira":
result = await self.check_jira(entry, token)
elif entry.service == "Confluence":
result = await self.check_confluence(entry, token)
elif entry.service == "OrgMyLife":
result = await self.check_orgmylife(entry, token)
else:
result = CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Unsupported service: {entry.service}",
)
results.append(result)
except Exception as exc:
results.append(
CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Unexpected error checking {entry.service}/{entry.token_name}: {exc}",
)
)
return results
async def _request_with_retry(
self,
client: httpx.AsyncClient,
method: str,
url: str,
headers: dict[str, str],
service: str,
token_name: str,
) -> httpx.Response:
"""Make an HTTP request with retry logic on timeout/connection errors.
Retries up to self.max_retries times with 5s backoff between attempts.
Args:
client: The httpx async client to use.
method: HTTP method (GET, POST, etc.).
url: The full URL to request.
headers: Request headers.
service: Service name for error messages.
token_name: Token name for error messages.
Returns:
The HTTP response on success.
Raises:
httpx.TimeoutException: If all retries are exhausted due to timeouts.
httpx.ConnectError: If all retries are exhausted due to connection errors.
"""
last_exception: Exception | None = None
for attempt in range(self.max_retries + 1):
try:
response = await client.request(method, url, headers=headers)
return response
except (httpx.TimeoutException, httpx.ConnectError) as exc:
last_exception = exc
if attempt < self.max_retries:
await asyncio.sleep(5)
raise last_exception # type: ignore[misc]
def _check_stale_expiry(
self, entry: PatEntry, reference_date: date
) -> bool:
"""Check if the stored expiry_date has passed (stale).
Args:
entry: The PAT entry to check.
reference_date: The current date.
Returns:
True if the stored expiry_date has passed, False otherwise.
"""
if entry.expiry_date is None:
return False
try:
stored_expiry = date.fromisoformat(entry.expiry_date)
return stored_expiry < reference_date
except ValueError:
return False
async def check_gitlab(self, entry: PatEntry, token: str) -> CheckResult:
"""Check a GitLab PAT's expiry status via the GitLab API.
Uses GET /personal_access_tokens/self to retrieve the expires_at field
and classifies the token based on the expiry window.
Args:
entry: The PAT registry entry to check.
token: The GitLab personal access token value.
Returns:
CheckResult with the classification status.
"""
gitlab_url = os.environ.get("GITLAB_URL", "https://gitlab.com")
url = f"{gitlab_url}/api/v4/personal_access_tokens/self"
headers = {"PRIVATE-TOKEN": token}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await self._request_with_retry(
client, "GET", url, headers, entry.service, entry.token_name
)
except (httpx.TimeoutException, httpx.ConnectError) as exc:
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
)
if response.status_code == 401:
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
)
if response.status_code == 403:
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
)
if not (200 <= response.status_code < 300):
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
)
# Successful response — parse expires_at
data = response.json()
expires_at = data.get("expires_at")
reference_date = date.today()
if expires_at is None:
# No expiry date from API — token has no expiry set
# Check if stored expiry_date is stale
if self._check_stale_expiry(entry, reference_date):
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
stale_warning=True,
)
return CheckResult(
entry=entry,
status=PatStatus.HEALTHY,
days_remaining=None,
error_message=None,
)
# Classify based on the API-returned expiry date
status, days_remaining = classify_by_date(
expires_at, reference_date, self.expiry_window_days
)
# Check for stale stored expiry_date
stale_warning = False
if self._check_stale_expiry(entry, reference_date):
stale_warning = True
# If API says token is still valid but stored date has passed,
# classify as expired with stale warning
if status == PatStatus.HEALTHY or status == PatStatus.EXPIRING_SOON:
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
stale_warning=True,
)
return CheckResult(
entry=entry,
status=status,
days_remaining=days_remaining,
error_message=None,
stale_warning=stale_warning,
)
async def check_jira(self, entry: PatEntry, token: str) -> CheckResult:
"""Check a Jira PAT's expiry status via an authenticated API call.
Interprets HTTP 401 as an expired or revoked token.
Args:
entry: The PAT registry entry to check.
token: The Jira personal access token value.
Returns:
CheckResult with the classification status.
"""
jira_url = os.environ.get("JIRA_URL", "")
if not jira_url:
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: JIRA_URL environment variable not set",
)
url = f"{jira_url}/rest/api/2/myself"
headers = {"Authorization": f"Bearer {token}"}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await self._request_with_retry(
client, "GET", url, headers, entry.service, entry.token_name
)
except (httpx.TimeoutException, httpx.ConnectError) as exc:
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
)
reference_date = date.today()
if response.status_code == 401:
# Token is expired/revoked
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
)
if not (200 <= response.status_code < 300):
# Non-auth HTTP error
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
)
# API call succeeded — token is working
# Check for stale expiry_date
if self._check_stale_expiry(entry, reference_date):
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
stale_warning=True,
)
# If no expiry_date stored, classify as healthy
if entry.expiry_date is None:
return CheckResult(
entry=entry,
status=PatStatus.HEALTHY,
days_remaining=None,
error_message=None,
)
# Classify based on stored expiry_date
status, days_remaining = classify_by_date(
entry.expiry_date, reference_date, self.expiry_window_days
)
return CheckResult(
entry=entry,
status=status,
days_remaining=days_remaining,
error_message=None,
)
async def check_confluence(self, entry: PatEntry, token: str) -> CheckResult:
"""Check a Confluence PAT's expiry status via an authenticated API call.
Interprets HTTP 401 as an expired or revoked token.
Args:
entry: The PAT registry entry to check.
token: The Confluence personal access token value.
Returns:
CheckResult with the classification status.
"""
confluence_url = os.environ.get("CONFLUENCE_URL", "")
if not confluence_url:
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: CONFLUENCE_URL environment variable not set",
)
url = f"{confluence_url}/rest/api/user/current"
headers = {"Authorization": f"Bearer {token}"}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await self._request_with_retry(
client, "GET", url, headers, entry.service, entry.token_name
)
except (httpx.TimeoutException, httpx.ConnectError) as exc:
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
)
reference_date = date.today()
if response.status_code == 401:
# Token is expired/revoked
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
)
if not (200 <= response.status_code < 300):
# Non-auth HTTP error
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
)
# API call succeeded — token is working
# Check for stale expiry_date
if self._check_stale_expiry(entry, reference_date):
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
stale_warning=True,
)
# If no expiry_date stored, classify as healthy
if entry.expiry_date is None:
return CheckResult(
entry=entry,
status=PatStatus.HEALTHY,
days_remaining=None,
error_message=None,
)
# Classify based on stored expiry_date
status, days_remaining = classify_by_date(
entry.expiry_date, reference_date, self.expiry_window_days
)
return CheckResult(
entry=entry,
status=status,
days_remaining=days_remaining,
error_message=None,
)
async def check_orgmylife(self, entry: PatEntry, token: str) -> CheckResult:
"""Check an OrgMyLife token's expiry status via an authenticated API call.
Interprets HTTP 401 or HTTP 403 as an expired or revoked token.
Args:
entry: The PAT registry entry to check.
token: The OrgMyLife API key value.
Returns:
CheckResult with the classification status.
"""
orgmylife_url = os.environ.get("ORGMYLIFE_URL", "https://orgmylife.app")
url = f"{orgmylife_url}/api/tasks"
headers = {"X-API-Key": token}
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await self._request_with_retry(
client, "GET", url, headers, entry.service, entry.token_name
)
except (httpx.TimeoutException, httpx.ConnectError) as exc:
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: {exc}",
)
reference_date = date.today()
if response.status_code in (401, 403):
# Token is expired/revoked
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
)
if not (200 <= response.status_code < 300):
# Non-auth HTTP error
return CheckResult(
entry=entry,
status=PatStatus.CHECK_FAILED,
days_remaining=None,
error_message=f"Service check failed for {entry.service}/{entry.token_name}: HTTP {response.status_code}",
)
# API call succeeded — token is working
# Check for stale expiry_date
if self._check_stale_expiry(entry, reference_date):
return CheckResult(
entry=entry,
status=PatStatus.EXPIRED,
days_remaining=0,
error_message=None,
stale_warning=True,
)
# If no expiry_date stored, classify as healthy
if entry.expiry_date is None:
return CheckResult(
entry=entry,
status=PatStatus.HEALTHY,
days_remaining=None,
error_message=None,
)
# Classify based on stored expiry_date
status, days_remaining = classify_by_date(
entry.expiry_date, reference_date, self.expiry_window_days
)
return CheckResult(
entry=entry,
status=status,
days_remaining=days_remaining,
error_message=None,
)
@@ -0,0 +1,98 @@
"""Custom exception classes for the PAT Manager system."""
from __future__ import annotations
class PatManagerError(Exception):
"""Base exception for PAT Manager errors."""
pass
class RegistryValidationError(PatManagerError):
"""Raised when a registry entry fails validation.
Attributes:
entry: The raw dictionary that failed validation.
failed_fields: List of field names that failed validation checks.
"""
def __init__(self, entry: dict, failed_fields: list[str]) -> None:
self.entry = entry
self.failed_fields = failed_fields
fields_str = ", ".join(failed_fields)
super().__init__(
f"Registry entry validation failed for fields: {fields_str}. "
f"Entry: {entry}"
)
class ServiceCheckError(PatManagerError):
"""Raised when a service API check fails after retries.
Attributes:
service: The service name that failed (e.g. "GitLab").
token_name: The token identifier that was being checked.
reason: Human-readable description of the failure.
"""
def __init__(self, service: str, token_name: str, reason: str) -> None:
self.service = service
self.token_name = token_name
self.reason = reason
super().__init__(
f"Service check failed for {service}/{token_name}: {reason}"
)
class RotationError(PatManagerError):
"""Raised when GitLab token rotation fails.
Attributes:
token_name: The token identifier that failed rotation.
http_status: The HTTP status code returned, or None if no response.
reason: Human-readable description of the failure.
"""
def __init__(
self, token_name: str, http_status: int | None, reason: str
) -> None:
self.token_name = token_name
self.http_status = http_status
self.reason = reason
status_info = f" (HTTP {http_status})" if http_status else ""
super().__init__(
f"Token rotation failed for {token_name}{status_info}: {reason}"
)
class SecretUpdateError(PatManagerError):
"""Raised when GitHub secret update fails.
Attributes:
secret_name: The name of the secret that could not be updated.
reason: Human-readable description of the failure.
"""
def __init__(self, secret_name: str, reason: str) -> None:
self.secret_name = secret_name
self.reason = reason
super().__init__(
f"Failed to update GitHub secret '{secret_name}': {reason}"
)
class AlertError(PatManagerError):
"""Raised when alert delivery fails.
Attributes:
channel: The alert channel that failed (e.g. "orgmylife", "email").
reason: Human-readable description of the failure.
"""
def __init__(self, channel: str, reason: str) -> None:
self.channel = channel
self.reason = reason
super().__init__(
f"Alert delivery failed via {channel}: {reason}"
)
@@ -0,0 +1,94 @@
"""Data models for the PAT Manager system."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
class PatStatus(Enum):
"""Classification status for a PAT after expiry check."""
HEALTHY = "healthy"
EXPIRING_SOON = "expiring soon"
EXPIRED = "expired"
CHECK_FAILED = "check failed"
class AlertChannel(Enum):
"""Supported alert delivery channels."""
ORGMYLIFE = "orgmylife"
EMAIL = "email"
@dataclass
class PatEntry:
"""A single PAT entry in the registry.
Attributes:
service: The service this PAT belongs to (GitLab, Jira, Confluence, OrgMyLife).
token_name: Human-readable identifier for the token (max 128 chars).
expiry_date: ISO 8601 date string (YYYY-MM-DD) or None if no expiry.
renewal_method: How the token is renewed - "auto" or "manual".
"""
service: str
token_name: str
expiry_date: str | None
renewal_method: str
@dataclass
class CheckResult:
"""Result of checking a single PAT's expiry status.
Attributes:
entry: The PAT entry that was checked.
status: Classification result (healthy, expiring soon, expired, check failed).
days_remaining: Days until expiry, or None if check failed or no expiry date.
error_message: Error details, populated only for check_failed status.
stale_warning: True if API succeeds but stored expiry_date has passed.
"""
entry: PatEntry
status: PatStatus
days_remaining: int | None
error_message: str | None
stale_warning: bool = False
@dataclass
class RotationResult:
"""Result of a GitLab token rotation attempt.
Attributes:
success: Whether the rotation completed successfully.
new_token: The new token value from GitLab, or None on failure.
new_expiry_date: The new expiry date from GitLab, or None on failure.
error_message: Error details on failure, or None on success.
entry: The PAT entry that was rotated, or None if not set.
old_expiry_date: The previous expiry date before rotation, or None.
"""
success: bool
new_token: str | None
new_expiry_date: str | None
error_message: str | None
entry: PatEntry | None = None
old_expiry_date: str | None = None
@dataclass
class AlertResult:
"""Result of sending an alert notification.
Attributes:
sent: Whether the alert was delivered successfully.
channel: Which alert channel was used.
error_message: Error details on failure, or None on success.
"""
sent: bool
channel: AlertChannel
error_message: str | None
@@ -0,0 +1,220 @@
"""PAT Registry module for loading, validating, and persisting the PAT registry."""
from __future__ import annotations
import json
import re
from pathlib import Path
from .errors import RegistryValidationError
from .models import PatEntry
# Valid service names and their required renewal methods
VALID_SERVICES: dict[str, str] = {
"GitLab": "auto",
"Jira": "manual",
"Confluence": "manual",
"OrgMyLife": "manual",
}
# ISO 8601 date pattern (YYYY-MM-DD)
_ISO_DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Maximum token name length
_MAX_TOKEN_NAME_LENGTH = 128
class PatRegistry:
"""Manages loading, validation, and persistence of the PAT registry.
The registry is a JSON file with structure:
{"version": "1.0", "tokens": [...]}
Each token entry contains: service, token_name, expiry_date, renewal_method.
"""
def load(self, path: Path) -> list[PatEntry]:
"""Load and validate all entries from a PAT registry JSON file.
Args:
path: Path to the registry JSON file.
Returns:
List of validated PatEntry objects.
Raises:
FileNotFoundError: If the registry file does not exist.
json.JSONDecodeError: If the file contains invalid JSON.
RegistryValidationError: If any entry fails validation.
"""
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise RegistryValidationError(
entry={},
failed_fields=["root"],
)
tokens = data.get("tokens", [])
if not isinstance(tokens, list):
raise RegistryValidationError(
entry={},
failed_fields=["tokens"],
)
entries: list[PatEntry] = []
seen: set[tuple[str, str]] = set()
for raw_entry in tokens:
entry = self.validate_entry(raw_entry)
# Check for duplicates
key = (entry.service, entry.token_name)
if key in seen:
raise RegistryValidationError(
entry=raw_entry,
failed_fields=["service", "token_name"],
)
seen.add(key)
entries.append(entry)
return entries
def validate_entry(self, entry: dict) -> PatEntry:
"""Validate a single registry entry dictionary.
Checks:
- All required fields are present (service, token_name, renewal_method)
- service is one of the supported values
- token_name is non-empty and max 128 characters
- expiry_date is null or valid ISO 8601 (YYYY-MM-DD)
- renewal_method matches the service (GitLab -> "auto", others -> "manual")
Args:
entry: Raw dictionary representing a PAT entry.
Returns:
A validated PatEntry instance.
Raises:
RegistryValidationError: If validation fails, with specific failed fields.
"""
if not isinstance(entry, dict):
raise RegistryValidationError(
entry=entry if isinstance(entry, dict) else {"_raw": str(entry)},
failed_fields=["entry_type"],
)
failed_fields: list[str] = []
# Check required fields presence
required_fields = ["service", "token_name", "renewal_method"]
for field in required_fields:
if field not in entry:
failed_fields.append(field)
# If required fields are missing, raise immediately
if failed_fields:
raise RegistryValidationError(entry=entry, failed_fields=failed_fields)
# Validate service
service = entry["service"]
if service not in VALID_SERVICES:
failed_fields.append("service")
# Validate token_name
token_name = entry["token_name"]
if (
not isinstance(token_name, str)
or len(token_name) == 0
or len(token_name) > _MAX_TOKEN_NAME_LENGTH
):
failed_fields.append("token_name")
# Validate expiry_date (can be null/None or valid ISO 8601 YYYY-MM-DD)
expiry_date = entry.get("expiry_date")
if expiry_date is not None:
if not isinstance(expiry_date, str) or not _is_valid_iso_date(expiry_date):
failed_fields.append("expiry_date")
# Validate renewal_method matches service
renewal_method = entry["renewal_method"]
if service in VALID_SERVICES:
expected_method = VALID_SERVICES[service]
if renewal_method != expected_method:
failed_fields.append("renewal_method")
elif renewal_method not in ("auto", "manual"):
# If service is invalid, still check renewal_method is at least valid
failed_fields.append("renewal_method")
if failed_fields:
raise RegistryValidationError(entry=entry, failed_fields=failed_fields)
return PatEntry(
service=service,
token_name=token_name,
expiry_date=expiry_date,
renewal_method=renewal_method,
)
def save(self, path: Path, entries: list[PatEntry]) -> None:
"""Persist a list of PAT entries to a registry JSON file.
Args:
path: Path where the registry file should be written.
entries: List of PatEntry objects to serialize.
"""
data = {
"version": "1.0",
"tokens": [
{
"service": entry.service,
"token_name": entry.token_name,
"expiry_date": entry.expiry_date,
"renewal_method": entry.renewal_method,
}
for entry in entries
],
}
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
def create_empty(self, path: Path) -> None:
"""Create a new empty registry file with valid JSON structure.
Args:
path: Path where the empty registry file should be created.
"""
self.save(path, [])
def _is_valid_iso_date(date_str: str) -> bool:
"""Check if a string is a valid ISO 8601 date (YYYY-MM-DD).
Validates both the format and that the date values are actually valid
(e.g., rejects 2025-02-30).
"""
if not _ISO_DATE_PATTERN.match(date_str):
return False
# Validate actual date values
try:
year, month, day = date_str.split("-")
year_int, month_int, day_int = int(year), int(month), int(day)
# Basic range checks
if month_int < 1 or month_int > 12:
return False
if day_int < 1 or day_int > 31:
return False
# Use datetime for full validation (handles leap years, etc.)
import datetime
datetime.date(year_int, month_int, day_int)
return True
except (ValueError, OverflowError):
return False
@@ -0,0 +1,153 @@
"""Summary Reporter module for the PAT Manager system.
Generates a human-readable summary report of PAT lifecycle check results
suitable for GitHub Actions log output.
"""
from __future__ import annotations
from .models import CheckResult, PatStatus, RotationResult
class SummaryReporter:
"""Produces a formatted summary report of PAT check and rotation results.
The report includes a table of all checked PATs with their status and
details, plus a rotation results section if any tokens were rotated.
"""
def report(
self, results: list[CheckResult], rotations: list[RotationResult]
) -> str:
"""Generate a plain-text summary report.
Args:
results: List of check results for all PATs in the registry.
rotations: List of rotation results for tokens that were rotated.
Returns:
A formatted plain-text string suitable for printing to the
GitHub Actions log.
"""
lines: list[str] = []
lines.append("=== PAT Lifecycle Check Summary ===")
lines.append("")
# Build the table
lines.append(self._build_table(results))
# Add rotation section if there are rotation results
successful_rotations = [r for r in rotations if r.success]
if successful_rotations:
lines.append("")
lines.append("--- Rotation Results ---")
for rotation in successful_rotations:
lines.append(self._format_rotation(rotation))
return "\n".join(lines)
def _build_table(self, results: list[CheckResult]) -> str:
"""Build the formatted results table.
Args:
results: List of check results to display.
Returns:
Formatted table string with header, separator, and data rows.
"""
# Column headers
header_service = "Service"
header_token = "Token Name"
header_status = "Status"
header_details = "Details"
# Calculate column widths based on content
service_width = max(
len(header_service),
*(len(r.entry.service) for r in results) if results else [0],
)
token_width = max(
len(header_token),
*(len(r.entry.token_name) for r in results) if results else [0],
)
status_width = max(
len(header_status),
*(len(r.status.value) for r in results) if results else [0],
)
details_width = max(
len(header_details),
*(len(self._get_details(r)) for r in results) if results else [0],
)
# Format header
header = (
f"{header_service:<{service_width}} | "
f"{header_token:<{token_width}} | "
f"{header_status:<{status_width}} | "
f"{header_details:<{details_width}}"
)
# Format separator
separator = (
f"{'-' * service_width}-+-"
f"{'-' * token_width}-+-"
f"{'-' * status_width}-+-"
f"{'-' * details_width}"
)
# Format data rows
rows: list[str] = []
for result in results:
details = self._get_details(result)
row = (
f"{result.entry.service:<{service_width}} | "
f"{result.entry.token_name:<{token_width}} | "
f"{result.status.value:<{status_width}} | "
f"{details:<{details_width}}"
)
rows.append(row)
table_lines = [header, separator] + rows
return "\n".join(table_lines)
def _get_details(self, result: CheckResult) -> str:
"""Get the details string for a check result.
Args:
result: The check result to describe.
Returns:
A human-readable details string based on the PAT status.
"""
if result.status == PatStatus.CHECK_FAILED:
return result.error_message or "Unknown error"
if result.status == PatStatus.EXPIRED:
return "0 days remaining"
if result.days_remaining is not None:
return f"{result.days_remaining} days remaining"
return ""
def _format_rotation(self, rotation: RotationResult) -> str:
"""Format a single rotation result line.
Args:
rotation: A successful rotation result.
Returns:
Formatted string like:
"GitLab/token-name: Rotated successfully (old: 2025-07-01, new: 2025-08-01)"
"""
if rotation.entry is not None:
service = rotation.entry.service
token_name = rotation.entry.token_name
else:
service = "Unknown"
token_name = "unknown-token"
old_date = rotation.old_expiry_date or "N/A"
new_date = rotation.new_expiry_date or "N/A"
return (
f"{service}/{token_name}: "
f"Rotated successfully (old: {old_date}, new: {new_date})"
)
@@ -0,0 +1,5 @@
hypothesis>=6.100
pytest>=8.0
pytest-asyncio>=0.23
pytest-cov>=5.0
respx>=0.21
@@ -0,0 +1,2 @@
httpx>=0.27
pynacl>=1.5
@@ -0,0 +1,154 @@
"""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,
)
@@ -0,0 +1,143 @@
"""GitHub Actions secret updater using the GitHub API.
Updates repository secrets by encrypting values with the repository's
public key using libsodium sealed box encryption (NaCl).
"""
from __future__ import annotations
import base64
import logging
import httpx
from nacl.public import PublicKey, SealedBox
from .errors import SecretUpdateError
logger = logging.getLogger(__name__)
GITHUB_API_BASE = "https://api.github.com"
class SecretUpdater:
"""Updates GitHub Actions repository secrets via the GitHub API.
Uses libsodium sealed box encryption to encrypt secret values before
sending them to the GitHub API.
Attributes:
github_token: GitHub PAT with repo scope for secret management.
repo_owner: Repository owner (user or organization).
repo_name: Repository name.
"""
def __init__(self, github_token: str, repo_owner: str, repo_name: str) -> None:
self._github_token = github_token
self._repo_owner = repo_owner
self._repo_name = repo_name
def _headers(self) -> dict[str, str]:
"""Build authorization headers for GitHub API requests."""
return {
"Authorization": f"Bearer {self._github_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
def _encrypt_secret(self, public_key_b64: str, secret_value: str) -> str:
"""Encrypt a secret value using the repository's public key.
Args:
public_key_b64: Base64-encoded repository public key from GitHub API.
secret_value: The plaintext secret value to encrypt.
Returns:
Base64-encoded encrypted value ready for the GitHub API.
"""
public_key_bytes = base64.b64decode(public_key_b64)
public_key = PublicKey(public_key_bytes)
sealed_box = SealedBox(public_key)
encrypted = sealed_box.encrypt(secret_value.encode("utf-8"))
return base64.b64encode(encrypted).decode("utf-8")
async def update_secret(self, secret_name: str, secret_value: str) -> bool:
"""Update a GitHub Actions repository secret.
Fetches the repository's public key, encrypts the secret value using
libsodium sealed box encryption, and updates the secret via the GitHub API.
On failure, the previous secret value remains unchanged (GitHub API is atomic).
Args:
secret_name: Name of the secret to update.
secret_value: New plaintext value for the secret.
Returns:
True if the secret was updated successfully.
Raises:
SecretUpdateError: If the update fails for any reason.
The error message never contains the secret value.
"""
base_url = (
f"{GITHUB_API_BASE}/repos/{self._repo_owner}/{self._repo_name}"
)
async with httpx.AsyncClient() as client:
# Step 1: Get the repository's public key for secret encryption
try:
key_response = await client.get(
f"{base_url}/actions/secrets/public-key",
headers=self._headers(),
)
except httpx.HTTPError as exc:
raise SecretUpdateError(
secret_name,
f"Failed to fetch repository public key: {type(exc).__name__}",
) from exc
if key_response.status_code != 200:
raise SecretUpdateError(
secret_name,
f"Failed to fetch repository public key "
f"(HTTP {key_response.status_code})",
)
key_data = key_response.json()
public_key_b64 = key_data["key"]
key_id = key_data["key_id"]
# Step 2: Encrypt the secret value using the public key
try:
encrypted_value = self._encrypt_secret(public_key_b64, secret_value)
except Exception as exc:
raise SecretUpdateError(
secret_name,
f"Failed to encrypt secret value: {type(exc).__name__}",
) from exc
# Step 3: Update the secret via PUT request
try:
put_response = await client.put(
f"{base_url}/actions/secrets/{secret_name}",
headers=self._headers(),
json={
"encrypted_value": encrypted_value,
"key_id": key_id,
},
)
except httpx.HTTPError as exc:
raise SecretUpdateError(
secret_name,
f"Failed to update secret: {type(exc).__name__}",
) from exc
# GitHub returns 201 (created) or 204 (updated) on success
if put_response.status_code not in (201, 204):
raise SecretUpdateError(
secret_name,
f"Failed to update secret (HTTP {put_response.status_code})",
)
logger.info("Successfully updated GitHub secret '%s'", secret_name)
return True
@@ -0,0 +1,200 @@
"""Unit tests for the GitLab Rotator module."""
from __future__ import annotations
import pytest
import respx
import httpx
from datetime import date, timedelta
from .rotator import GitLabRotator
from .models import RotationResult
@pytest.fixture
def rotator(monkeypatch):
"""Create a GitLabRotator with a test GitLab URL."""
monkeypatch.setenv("GITLAB_URL", "https://gitlab.example.com")
return GitLabRotator(timeout=5)
@pytest.mark.asyncio
async def test_successful_rotation(rotator):
"""Test successful token rotation returns new token and expiry."""
with respx.mock:
respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/123/rotate"
).mock(
return_value=httpx.Response(
200,
json={"token": "glpat-new-token-value", "expires_at": "2025-12-15"},
)
)
result = await rotator.rotate("123", "glpat-old-token")
assert result.success is True
assert result.new_token == "glpat-new-token-value"
assert result.new_expiry_date == "2025-12-15"
assert result.error_message is None
@pytest.mark.asyncio
async def test_expired_token_skips_rotation(rotator):
"""Test that expired tokens are not rotated — returns error for manual intervention."""
yesterday = (date.today() - timedelta(days=1)).isoformat()
result = await rotator.rotate("123", "glpat-old-token", expiry_date=yesterday)
assert result.success is False
assert result.new_token is None
assert result.new_expiry_date is None
assert "expired" in result.error_message.lower()
assert "manual intervention" in result.error_message.lower()
@pytest.mark.asyncio
async def test_retry_on_first_failure_then_success(rotator):
"""Test that rotation retries once on failure and succeeds on second attempt."""
with respx.mock:
route = respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/456/rotate"
)
route.side_effect = [
httpx.Response(500, json={"error": "internal error"}),
httpx.Response(
200,
json={"token": "glpat-retry-token", "expires_at": "2025-11-30"},
),
]
result = await rotator.rotate("456", "glpat-current")
assert result.success is True
assert result.new_token == "glpat-retry-token"
assert result.new_expiry_date == "2025-11-30"
@pytest.mark.asyncio
async def test_retry_exhausted_returns_error(rotator):
"""Test that after retry is exhausted, an error result is returned."""
with respx.mock:
respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/789/rotate"
).mock(return_value=httpx.Response(503, json={"error": "unavailable"}))
result = await rotator.rotate("789", "glpat-current")
assert result.success is False
assert result.new_token is None
assert result.new_expiry_date is None
assert "503" in result.error_message
@pytest.mark.asyncio
async def test_timeout_returns_error(rotator):
"""Test that timeout on both attempts returns error result."""
with respx.mock:
respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/101/rotate"
).mock(side_effect=httpx.TimeoutException("timed out"))
result = await rotator.rotate("101", "glpat-current")
assert result.success is False
assert result.new_token is None
assert "timed out" in result.error_message.lower()
@pytest.mark.asyncio
async def test_connection_error_returns_error(rotator):
"""Test that connection error on both attempts returns error result."""
with respx.mock:
respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/202/rotate"
).mock(side_effect=httpx.ConnectError("connection refused"))
result = await rotator.rotate("202", "glpat-current")
assert result.success is False
assert result.new_token is None
assert "connection" in result.error_message.lower()
@pytest.mark.asyncio
async def test_non_expired_token_proceeds_with_rotation(rotator):
"""Test that a token not yet expired proceeds with rotation."""
future_date = (date.today() + timedelta(days=5)).isoformat()
with respx.mock:
respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/303/rotate"
).mock(
return_value=httpx.Response(
200,
json={"token": "glpat-fresh", "expires_at": "2026-01-01"},
)
)
result = await rotator.rotate("303", "glpat-old", expiry_date=future_date)
assert result.success is True
assert result.new_token == "glpat-fresh"
assert result.new_expiry_date == "2026-01-01"
@pytest.mark.asyncio
async def test_no_expiry_date_proceeds_with_rotation(rotator):
"""Test that when no expiry_date is provided, rotation proceeds normally."""
with respx.mock:
respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/404/rotate"
).mock(
return_value=httpx.Response(
200,
json={"token": "glpat-no-expiry", "expires_at": "2025-10-01"},
)
)
result = await rotator.rotate("404", "glpat-current", expiry_date=None)
assert result.success is True
assert result.new_token == "glpat-no-expiry"
@pytest.mark.asyncio
async def test_malformed_response_returns_error(rotator):
"""Test that a response missing expected fields returns error."""
with respx.mock:
respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/505/rotate"
).mock(
return_value=httpx.Response(
200,
json={"unexpected": "data"},
)
)
result = await rotator.rotate("505", "glpat-current")
assert result.success is False
assert result.new_token is None
assert "parse" in result.error_message.lower()
@pytest.mark.asyncio
async def test_authentication_header_sent(rotator):
"""Test that the PRIVATE-TOKEN header is sent with the current token."""
with respx.mock:
route = respx.post(
"https://gitlab.example.com/api/v4/personal_access_tokens/606/rotate"
).mock(
return_value=httpx.Response(
200,
json={"token": "glpat-new", "expires_at": "2025-12-01"},
)
)
await rotator.rotate("606", "glpat-my-secret-token")
assert route.calls[0].request.headers["PRIVATE-TOKEN"] == "glpat-my-secret-token"