Files
Orchestrator/.kiro/specs/pat-renewal/design.md
T
2026-06-30 20:37:40 +02:00

580 lines
20 KiB
Markdown

# 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
```mermaid
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
```mermaid
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.
```python
@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.
```python
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.
```python
@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.
```python
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.
```python
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.
```python
class SummaryReporter:
def report(self, results: list[CheckResult], rotations: list[RotationResult]) -> str: ...
```
### 7. Main Orchestrator (`pat_manager.py`)
Entry point that coordinates all modules.
```python
async def main() -> int:
"""Returns exit code: 0 for success, 1 for failures."""
...
```
## Data Models
### PAT Registry JSON Schema
```json
{
"$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
```json
{
"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
```python
@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
```yaml
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
```python
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
```