Files
ankn a5f8fb49ab 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.
2026-06-30 20:39:52 +02:00

561 lines
20 KiB
Python

"""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,
)