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:
@@ -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"
|
||||
Reference in New Issue
Block a user