Files
2026-06-30 20:37:40 +02:00

20 KiB

Design Document: PAT Renewal

Overview

The PAT Renewal system automates Personal Access Token lifecycle management across GitLab, Jira, Confluence, and OrgMyLife services. It runs as a GitHub Actions scheduled workflow (weekly, Mondays at 06:00 UTC) that:

  1. Reads a PAT Registry (JSON) tracking all managed tokens
  2. Checks each token's expiry status via service-specific API calls
  3. Auto-rotates GitLab PATs using the GitLab Personal Access Tokens API
  4. Creates OrgMyLife tasks or sends email alerts for tokens requiring manual renewal
  5. Produces a summary report in the GitHub Actions run log

The system integrates with the existing GitHub Actions infrastructure already used in the workspace (deploy workflows, OrgMyLife sync) and leverages the OrgMyLife API for task creation alerts.

Key Design Decisions:

  • Python-based workflow script: Matches the workspace's primary language (Python 3.11+) and allows reuse of patterns from the AI-Orchestrator project (httpx for HTTP, pytest for testing)
  • JSON registry file: Simple, version-controllable, human-readable format stored in the repository
  • GitHub Actions secrets for token storage: Leverages existing secure secret management without introducing new infrastructure
  • Idempotent alerting: Prevents duplicate OrgMyLife tasks by checking for existing open tasks before creating new ones

Architecture

graph TD
    subgraph "GitHub Actions"
        CRON[Cron Trigger<br/>Monday 06:00 UTC] --> WF[pat-check Workflow]
        DISPATCH[workflow_dispatch] --> WF
        WF --> SCRIPT[pat_manager.py]
    end

    subgraph "PAT Manager Script"
        SCRIPT --> LOAD[Load PAT Registry]
        LOAD --> CHECK[Expiry Checker]
        CHECK --> CLASSIFY{Classify PATs}
        CLASSIFY -->|expiring soon + auto| ROTATE[GitLab Rotator]
        CLASSIFY -->|expiring soon + manual| ALERT[Alert Sender]
        CLASSIFY -->|expired| ALERT
        CLASSIFY -->|healthy| REPORT[Summary Reporter]
        ROTATE --> SECRET_UPDATE[Update GitHub Secret]
        ROTATE -->|failure| ALERT
        SECRET_UPDATE --> REPORT
        ALERT --> REPORT
    end

    subgraph "External Services"
        CHECK --> GITLAB_API[GitLab API]
        CHECK --> JIRA_API[Jira API]
        CHECK --> CONFLUENCE_API[Confluence API]
        CHECK --> ORGMYLIFE_API[OrgMyLife API]
        ROTATE --> GITLAB_API
        SECRET_UPDATE --> GH_API[GitHub API]
        ALERT --> ORGMYLIFE_API
        ALERT --> EMAIL[Email / SMTP]
    end

    subgraph "Storage"
        LOAD --> REGISTRY[pat-registry.json]
        REPORT --> LOG[Workflow Run Log]
    end

Component Interaction Flow

sequenceDiagram
    participant GHA as GitHub Actions
    participant PM as pat_manager.py
    participant REG as pat-registry.json
    participant GL as GitLab API
    participant JIRA as Jira API
    participant OML as OrgMyLife API
    participant GH as GitHub API

    GHA->>PM: Trigger (cron/dispatch)
    PM->>REG: Load registry
    REG-->>PM: PAT entries

    loop For each PAT
        alt GitLab PAT
            PM->>GL: GET /personal_access_tokens
            GL-->>PM: expires_at
        else Jira/Confluence/OrgMyLife PAT
            PM->>JIRA: Authenticated API call
            JIRA-->>PM: 200 OK / 401 Unauthorized
        end
        PM->>PM: Classify (healthy/expiring/expired/check failed)
    end

    alt GitLab PAT expiring soon
        PM->>GL: POST /personal_access_tokens/{id}/rotate
        GL-->>PM: New token + expiry
        PM->>GH: Update secret
        PM->>REG: Update expiry_date
    end

    alt Manual token expiring/expired
        PM->>OML: POST /api/tasks (create alert task)
    end

    PM->>GHA: Summary report (log output)

Components and Interfaces

1. PAT Registry Module (registry.py)

Responsible for loading, validating, and persisting the PAT registry JSON file.

@dataclass
class PatEntry:
    service: str          # "GitLab" | "Jira" | "Confluence" | "OrgMyLife"
    token_name: str       # max 128 chars
    expiry_date: str | None  # ISO 8601 (YYYY-MM-DD) or None
    renewal_method: str   # "auto" | "manual"

class PatRegistry:
    def load(self, path: Path) -> list[PatEntry]: ...
    def validate_entry(self, entry: dict) -> PatEntry | ValidationError: ...
    def save(self, path: Path, entries: list[PatEntry]) -> None: ...
    def create_empty(self, path: Path) -> None: ...

Validation Rules:

  • service must be one of: GitLab, Jira, Confluence, OrgMyLife
  • token_name must be non-empty, max 128 characters
  • expiry_date must be valid ISO 8601 (YYYY-MM-DD) or null
  • renewal_method must match service: GitLab → "auto", others → "manual"
  • No duplicate (service, token_name) combinations

2. Expiry Checker Module (checker.py)

Queries each service API to determine PAT status.

class PatStatus(Enum):
    HEALTHY = "healthy"
    EXPIRING_SOON = "expiring soon"
    EXPIRED = "expired"
    CHECK_FAILED = "check failed"

@dataclass
class CheckResult:
    entry: PatEntry
    status: PatStatus
    days_remaining: int | None
    error_message: str | None

class ExpiryChecker:
    def __init__(self, expiry_window_days: int = 14, timeout: int = 30, max_retries: int = 2): ...
    async def check_all(self, entries: list[PatEntry], secrets: dict[str, str]) -> list[CheckResult]: ...
    async def check_gitlab(self, entry: PatEntry, token: str) -> CheckResult: ...
    async def check_jira(self, entry: PatEntry, token: str) -> CheckResult: ...
    async def check_confluence(self, entry: PatEntry, token: str) -> CheckResult: ...
    async def check_orgmylife(self, entry: PatEntry, token: str) -> CheckResult: ...

Service-Specific Logic:

  • GitLab: GET /personal_access_tokens → read expires_at field, compare with window
  • Jira: Authenticated call → HTTP 401 = expired
  • Confluence: Authenticated call → HTTP 401 = expired
  • OrgMyLife: Authenticated call → HTTP 401/403 = expired

3. GitLab Rotator Module (rotator.py)

Handles automatic token rotation for GitLab PATs.

@dataclass
class RotationResult:
    success: bool
    new_expiry_date: str | None
    error_message: str | None

class GitLabRotator:
    def __init__(self, timeout: int = 30): ...
    async def rotate(self, token_id: str, current_token: str) -> RotationResult: ...

4. Secret Updater Module (secret_updater.py)

Updates GitHub Actions secrets with new token values.

class SecretUpdater:
    def __init__(self, github_token: str, repo_owner: str, repo_name: str): ...
    async def update_secret(self, secret_name: str, secret_value: str) -> bool: ...

5. Alert Sender Module (alerter.py)

Sends alerts via OrgMyLife task creation or email.

class AlertChannel(Enum):
    ORGMYLIFE = "orgmylife"
    EMAIL = "email"

@dataclass
class AlertResult:
    sent: bool
    channel: AlertChannel
    error_message: str | None

class AlertSender:
    def __init__(self, channel: AlertChannel, config: dict): ...
    async def send_alert(self, result: CheckResult) -> AlertResult: ...
    async def check_existing_task(self, service: str, token_name: str, expiry_date: str) -> bool: ...

OrgMyLife Integration:

  • Uses POST /api/tasks with X-API-Key header for authentication
  • Task title format: [PAT Renewal] {service} - {token_name}
  • Includes days remaining and renewal instructions in description
  • Checks for existing open tasks before creating duplicates

6. Summary Reporter Module (reporter.py)

Produces the workflow summary log.

class SummaryReporter:
    def report(self, results: list[CheckResult], rotations: list[RotationResult]) -> str: ...

7. Main Orchestrator (pat_manager.py)

Entry point that coordinates all modules.

async def main() -> int:
    """Returns exit code: 0 for success, 1 for failures."""
    ...

Data Models

PAT Registry JSON Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "version": { "type": "string", "const": "1.0" },
    "tokens": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "service": {
            "type": "string",
            "enum": ["GitLab", "Jira", "Confluence", "OrgMyLife"]
          },
          "token_name": {
            "type": "string",
            "maxLength": 128,
            "minLength": 1
          },
          "expiry_date": {
            "type": ["string", "null"],
            "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
          },
          "renewal_method": {
            "type": "string",
            "enum": ["auto", "manual"]
          }
        },
        "required": ["service", "token_name", "renewal_method"],
        "additionalProperties": false
      }
    }
  },
  "required": ["version", "tokens"]
}

Example PAT Registry

{
  "version": "1.0",
  "tokens": [
    {
      "service": "GitLab",
      "token_name": "ci-pipeline-token",
      "expiry_date": "2025-08-15",
      "renewal_method": "auto"
    },
    {
      "service": "Jira",
      "token_name": "jira-api-access",
      "expiry_date": "2025-07-20",
      "renewal_method": "manual"
    },
    {
      "service": "Confluence",
      "token_name": "confluence-bot",
      "expiry_date": "2025-09-01",
      "renewal_method": "manual"
    },
    {
      "service": "OrgMyLife",
      "token_name": "orchestrator-api-key",
      "expiry_date": null,
      "renewal_method": "manual"
    }
  ]
}

Check Result Data Model

@dataclass
class CheckResult:
    entry: PatEntry
    status: PatStatus          # healthy | expiring soon | expired | check failed
    days_remaining: int | None # None if check failed or no expiry date
    error_message: str | None  # Populated only for check_failed
    stale_warning: bool = False  # True if API succeeds but stored date has passed

GitHub Actions Workflow Configuration

name: PAT Lifecycle Check

on:
  schedule:
    - cron: '0 6 * * 1'  # Every Monday at 06:00 UTC
  workflow_dispatch: {}

jobs:
  check-pats:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.11'
      - name: Install dependencies
        run: pip install httpx
      - name: Run PAT Manager
        env:
          GITLAB_PAT: ${{ secrets.GITLAB_PAT }}
          JIRA_PAT: ${{ secrets.JIRA_PAT }}
          CONFLUENCE_PAT: ${{ secrets.CONFLUENCE_PAT }}
          ORGMYLIFE_API_KEY: ${{ secrets.ORGMYLIFE_API_SECRET }}
          ORGMYLIFE_USER: ${{ secrets.ORGMYLIFE_USER }}
          ORGMYLIFE_PASS: ${{ secrets.ORGMYLIFE_PASS }}
          GH_TOKEN: ${{ secrets.PAT_MANAGER_TOKEN }}
          ALERT_CHANNEL: orgmylife
        run: python scripts/pat_manager.py

Environment Variables / Secrets Mapping

Secret Name Purpose Used By
GITLAB_PAT GitLab API access + rotation source Checker, Rotator
JIRA_PAT Jira authenticated check Checker
CONFLUENCE_PAT Confluence authenticated check Checker
ORGMYLIFE_API_SECRET OrgMyLife API key for task creation Alerter
ORGMYLIFE_USER OrgMyLife basic auth username Alerter
ORGMYLIFE_PASS OrgMyLife basic auth password Alerter
PAT_MANAGER_TOKEN GitHub API token for secret updates SecretUpdater

Correctness Properties

A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.

Property 1: Registry serialization round-trip

For any valid list of PAT entries, serializing the registry to JSON and deserializing it back SHALL produce an equivalent list of entries with identical field values.

Validates: Requirements 1.1

Property 2: Validation accepts only valid entries

For any dictionary representing a PAT entry, the validator SHALL accept it if and only if it contains all required fields (service, token_name, expiry_date, renewal_method), the service is one of the four supported values, the token_name is non-empty and at most 128 characters, the expiry_date is either null or a valid ISO 8601 date string, and the renewal_method matches the service (GitLab → "auto", others → "manual").

Validates: Requirements 1.2, 1.4

Property 3: Invalid entries and duplicates preserve registry state

For any PAT registry and any entry that is either invalid (fails validation) or a duplicate (same service + token_name already exists), attempting to add that entry SHALL leave the registry unchanged — the entry count and all existing entries remain identical.

Validates: Requirements 1.3, 1.5

Property 4: Expiry classification is deterministic and correct

For any PAT entry with an expiry date and any reference date, the classification SHALL be:

  • "expired" if expiry_date < reference_date
  • "expiring soon" if reference_date <= expiry_date <= reference_date + expiry_window
  • "healthy" if expiry_date > reference_date + expiry_window

This holds regardless of whether the expiry date comes from the service API or the stored registry value.

Validates: Requirements 2.2, 2.3, 2.4, 6.5

Property 5: Classification completeness

For any PAT registry with N entries, after running the expiry check, the system SHALL produce exactly N classification results, each with exactly one status from the set {"healthy", "expiring soon", "expired", "check failed"}.

Validates: Requirements 2.7

Property 6: Registry update after successful rotation

For any GitLab PAT entry and any valid rotation response containing a new expiry date, after successful rotation the registry entry's expiry_date SHALL equal the new expiry date from the rotation response.

Validates: Requirements 3.3

Property 7: Alert triggering correctness

For any set of classified PATs:

  • If ALL PATs have status "healthy", THEN zero alerts SHALL be sent
  • For each manual-renewal PAT with status "expiring soon" or "expired", exactly one alert SHALL be triggered

Validates: Requirements 4.1, 5.4

Property 8: Alert message completeness

For any PAT entry requiring an alert:

  • If the alert channel is OrgMyLife, the task title SHALL contain both the service name and token identifier
  • If the alert channel is email, the email body SHALL contain the service name, token identifier, expiration date, and a renewal instructions URL

Validates: Requirements 4.2, 4.3

Property 9: Days remaining calculation

For any expiry date and reference date, the computed days_remaining SHALL equal max(0, (expiry_date - reference_date).days) — yielding 0 for already-expired tokens and the correct positive integer for future dates.

Validates: Requirements 4.4

Property 10: Alert idempotence

For any PAT that requires an alert, if an open OrgMyLife task already exists for the same service, token_name, and expiry_date, THEN no new task SHALL be created. Running the alert flow twice for the same PAT state SHALL produce at most one task.

Validates: Requirements 4.5

Property 11: Summary report completeness

For any list of check results, the generated summary report SHALL contain one line per result, and each line SHALL include the service name, token identifier, and classification status.

Validates: Requirements 5.3

Property 12: Non-auth HTTP errors yield "check failed"

For any HTTP response status code that is not 200-299, 401, or 403, the classification SHALL be "check failed". This includes 4xx errors (other than 401/403), 5xx errors, and timeout conditions.

Validates: Requirements 6.6

Error Handling

Retry Strategy

Operation Max Retries Backoff Fallback
Service API health check 2 5s linear Classify as "check failed"
GitLab token rotation 1 None Manual alert flow
GitHub secret update 0 N/A Alert + non-zero exit
OrgMyLife task creation 1 2s Log error + non-zero exit
Email sending 1 2s Log error + non-zero exit

Error Classification

class PatManagerError(Exception):
    """Base exception for PAT Manager errors."""
    pass

class RegistryValidationError(PatManagerError):
    """Raised when a registry entry fails validation."""
    def __init__(self, entry: dict, failed_fields: list[str]): ...

class ServiceCheckError(PatManagerError):
    """Raised when a service API check fails after retries."""
    def __init__(self, service: str, token_name: str, reason: str): ...

class RotationError(PatManagerError):
    """Raised when GitLab token rotation fails."""
    def __init__(self, token_name: str, http_status: int | None, reason: str): ...

class SecretUpdateError(PatManagerError):
    """Raised when GitHub secret update fails."""
    def __init__(self, secret_name: str, reason: str): ...

class AlertError(PatManagerError):
    """Raised when alert delivery fails."""
    def __init__(self, channel: str, reason: str): ...

Exit Codes

Code Meaning
0 All checks passed, all actions succeeded
1 One or more alerts could not be delivered
2 Registry validation failed (malformed registry file)

Security Error Handling

  • Token values are masked via ::add-mask::{value} before any operation that might log output
  • On rotation failure, the old token remains valid — no service disruption
  • On secret update failure, the new token exists in GitLab but the workflow still uses the old one — alert is critical
  • All HTTP errors are logged without including request/response bodies that might contain tokens

Testing Strategy

Property-Based Tests (Hypothesis)

The project uses Python 3.11+ with pytest. Property-based tests will use the Hypothesis library, which is the standard PBT framework for Python.

Configuration:

  • Minimum 100 examples per property test (@settings(max_examples=100))
  • Each test tagged with a comment referencing the design property
  • Tag format: # Feature: pat-renewal, Property {number}: {property_text}

Properties to implement:

  1. Registry round-trip serialization
  2. Validation correctness (accepts valid, rejects invalid)
  3. Invalid/duplicate entries preserve state
  4. Expiry classification determinism
  5. Classification completeness
  6. Registry update after rotation
  7. Alert triggering correctness
  8. Alert message completeness
  9. Days remaining calculation
  10. Alert idempotence
  11. Summary report completeness
  12. Non-auth HTTP errors → "check failed"

Generators needed:

  • pat_entries() — generates valid PatEntry instances with random services, token names, dates
  • invalid_entries() — generates dictionaries missing fields or with invalid values
  • expiry_dates(relative_to) — generates dates in various ranges (past, within window, beyond window)
  • http_status_codes() — generates non-success, non-auth status codes
  • check_results() — generates lists of CheckResult with various statuses

Unit Tests (pytest)

Example-based tests for specific scenarios:

  • Empty registry file creation (Req 1.6)
  • Successful rotation logging format (Req 3.6)
  • Expired GitLab PAT skips rotation (Req 3.7)
  • Stale expiry_date warning (Req 6.7)
  • Token masking with ::add-mask:: (Req 7.2)

Integration Tests (pytest + httpx mock)

Mock-based tests for external service interactions:

  • GitLab API expiry check with mocked response (Req 6.1)
  • Jira/Confluence 401 interpretation (Req 6.2, 6.3)
  • OrgMyLife 401/403 interpretation (Req 6.4)
  • GitLab rotation API call + retry on failure (Req 3.1, 3.4)
  • GitHub secret update after rotation (Req 3.2)
  • Rotation success + secret update failure → alert (Req 3.5)
  • Alert channel unreachable → non-zero exit (Req 4.6)
  • Service timeout after retries → "check failed" (Req 2.5)
  • Null expiry_date classification (Req 2.6, 6.8)

Smoke Tests

  • Workflow YAML contains correct cron schedule (Req 5.1)
  • Workflow YAML contains workflow_dispatch trigger (Req 5.2)
  • All API URLs use HTTPS scheme (Req 7.3)
  • No token values written to non-secret locations (Req 7.1)

Test Dependencies

hypothesis>=6.100
pytest>=8.0
pytest-asyncio>=0.23
pytest-cov>=5.0
respx>=0.21  # httpx mock library