add HKDF credential derivation and auto-rotation
Single root key derives all 18 service credentials via HKDF-SHA256. Bootstrap/service tiers rotate on key change vs every commit. Provisioners update PostgreSQL roles and Gitea tokens automatically. CI pipeline runs `api.auth provision` after each deploy.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# ── Deploy ───────────────────────────────────────────────────────
|
||||
# Builds the Python package, pushes it to Gitea's PyPI registry,
|
||||
# then builds, scans, and pushes container images.
|
||||
# builds, scans, and pushes container images, then updates the
|
||||
# host working copy and restarts all changed services.
|
||||
# Only runs on pushes to main (i.e. after PR merge).
|
||||
|
||||
when:
|
||||
@@ -146,3 +147,46 @@ steps:
|
||||
- scan-zotero
|
||||
when:
|
||||
- path: "zotero/**"
|
||||
|
||||
# ── Deploy: pull latest code + restart services ────────────────
|
||||
# Runs AFTER all image builds/pushes complete. Updates the host
|
||||
# repo to the commit that triggered this pipeline, pulls any new
|
||||
# registry images, and restarts changed services.
|
||||
- name: deploy
|
||||
image: docker:cli
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
- /home/kert/stack:/home/kert/stack
|
||||
commands:
|
||||
# Install git so we can update the host working copy
|
||||
- apk add --no-cache git
|
||||
- cd /home/kert/stack
|
||||
# Fetch the exact commit via HTTP (public repo, intrastack)
|
||||
# and hard-reset the deploy dir to match it. Untracked files
|
||||
# (duckdb, zotero data, etc.) are untouched.
|
||||
- git fetch http://gitea:3000/homelab/stack.git main
|
||||
- git reset --hard FETCH_HEAD
|
||||
# Pull new images from registry, restart changed services
|
||||
- docker compose pull --ignore-buildable
|
||||
- docker compose up -d --remove-orphans
|
||||
depends_on:
|
||||
- publish-package
|
||||
- push-notebooks
|
||||
- upload-notebooks-scan
|
||||
- push-zotero
|
||||
- upload-zotero-scan
|
||||
|
||||
# ── Provision: derive credentials and rotate backends ────────
|
||||
- name: provision
|
||||
image: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
volumes:
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock
|
||||
- /home/kert/stack:/home/kert/stack
|
||||
environment:
|
||||
ROOT_KEY:
|
||||
from_secret: root_key
|
||||
commands:
|
||||
- cd /home/kert/stack
|
||||
- uv run python -m api.auth provision ${CI_COMMIT_SHA}
|
||||
depends_on:
|
||||
- deploy
|
||||
|
||||
134
README.md
134
README.md
@@ -143,49 +143,29 @@ Add to `/etc/hosts` (replace IP with your server's LAN IP):
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Environment Variables
|
||||
### 1. Generate Root Key and Bootstrap Credentials
|
||||
|
||||
Create `.env` file:
|
||||
All service credentials are derived from a single 256-bit root key via HKDF-SHA256. See [Credential Management](#credential-management) for details.
|
||||
|
||||
```bash
|
||||
# Generate root key
|
||||
ROOT_KEY=$(openssl rand -hex 32)
|
||||
|
||||
# Add non-managed vars to .env first
|
||||
cat > .env <<EOF
|
||||
DOMAIN=homelab.fhirworx.io
|
||||
HOST_IP=192.168.1.192
|
||||
|
||||
POSTGRES_PASSWORD=<your_password>
|
||||
RUSTFS_ACCESS_KEY=<your_access_key>
|
||||
RUSTFS_SECRET_KEY=<your_secret_key>
|
||||
GITEA_S3_ACCESS_KEY=<gitea_s3_user>
|
||||
GITEA_S3_SECRET_KEY=<gitea_s3_password>
|
||||
GITEA_DB_PASSWORD=<gitea_db_password>
|
||||
GITEA_TOKEN=<gitea_api_token>
|
||||
WOODPECKER_DB_PASSWORD=<woodpecker_db_password>
|
||||
WOODPECKER_ADMIN=<admin_username>
|
||||
WOODPECKER_AGENT_SECRET=<generated_secret>
|
||||
WOODPECKER_GITEA_CLIENT=<oauth_client_id>
|
||||
WOODPECKER_GITEA_SECRET=<oauth_client_secret>
|
||||
GF_ADMIN_PASSWORD=<grafana_password>
|
||||
|
||||
# Nessie Data Lake
|
||||
NESSIE_DB_PASSWORD=<nessie_db_password>
|
||||
NESSIE_S3_ACCESS_KEY=<nessie_s3_user>
|
||||
NESSIE_S3_SECRET_KEY=<nessie_s3_password>
|
||||
|
||||
# Polaris Catalog
|
||||
POLARIS_DB_PASSWORD=<polaris_db_password>
|
||||
POLARIS_ROOT_SECRET=<polaris_root_secret>
|
||||
POLARIS_S3_ACCESS_KEY=<polaris_s3_user>
|
||||
POLARIS_S3_SECRET_KEY=<polaris_s3_password>
|
||||
EOF
|
||||
```
|
||||
|
||||
Generate agent secret:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
### 2. Start Core Services
|
||||
### 2. Start Core Services and Bootstrap
|
||||
|
||||
```bash
|
||||
docker compose up -d postgres rustfs traefik
|
||||
|
||||
# Bootstrap: creates DB roles, databases, derives all credentials, writes .env
|
||||
ROOT_KEY=$ROOT_KEY uv run python -m api.auth bootstrap $(git rev-parse HEAD)
|
||||
```
|
||||
|
||||
### 3. Configure RustFS
|
||||
@@ -265,35 +245,13 @@ docker compose up -d postgres rustfs traefik
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Configure PostgreSQL
|
||||
|
||||
Create databases and users:
|
||||
|
||||
```bash
|
||||
# Gitea database
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE gitea;"
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER git WITH PASSWORD '<gitea_db_password>'; ALTER DATABASE gitea OWNER TO git;"
|
||||
|
||||
# Woodpecker database
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE woodpecker;"
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER woodpecker WITH PASSWORD '<woodpecker_db_password>'; ALTER DATABASE woodpecker OWNER TO woodpecker;"
|
||||
|
||||
# Nessie database
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER nessie WITH PASSWORD '<nessie_db_password>';"
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE nessie OWNER nessie;"
|
||||
|
||||
# Polaris database
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE USER polaris WITH PASSWORD '<polaris_db_password>';"
|
||||
docker exec -e PGPASSWORD=<postgres_password> postgres psql -U postgres -c "CREATE DATABASE polaris OWNER polaris;"
|
||||
```
|
||||
|
||||
### 5. Start All Services
|
||||
### 4. Start All Services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 6. Configure Gitea
|
||||
### 5. Configure Gitea
|
||||
|
||||
1. Complete initial setup at `gitea.homelab.fhirworx.io`
|
||||
2. Create organization `homelab`
|
||||
@@ -304,13 +262,16 @@ docker compose up -d
|
||||
- Redirect URI: `http://ci.homelab.fhirworx.io/authorize`
|
||||
- Copy Client ID and Secret to `.env`
|
||||
|
||||
### 7. Configure Woodpecker Secrets
|
||||
### 6. Configure Woodpecker Secrets
|
||||
|
||||
Add secrets in Woodpecker UI (`ci.homelab.fhirworx.io`):
|
||||
- `root_key` — The hex-encoded root key (enables auto-rotation on every deploy)
|
||||
- `registry_user` — Gitea username
|
||||
- `registry_pass` — Gitea token
|
||||
- `s3_access_key` — RustFS access key
|
||||
- `s3_secret_key` — RustFS secret key
|
||||
|
||||
### 8. Verify Services
|
||||
### 7. Verify Services
|
||||
|
||||
```bash
|
||||
# Check Traefik routes
|
||||
@@ -359,6 +320,60 @@ All traces flow to Jaeger via OTLP gRPC (port 4317).
|
||||
| polaris | polaris:8182/q/metrics |
|
||||
| trino | trino:8080/v1/status |
|
||||
|
||||
## Credential Management
|
||||
|
||||
All service credentials are derived from a single 256-bit root key using HKDF-SHA256 (RFC 5869). No passwords are stored in `.env` — they are deterministically regenerated from the root key and a commit SHA on every deploy.
|
||||
|
||||
### How It Works
|
||||
|
||||
```
|
||||
ROOT_KEY (one Woodpecker secret) + commit_sha → HKDF-SHA256 → all service credentials → .env
|
||||
```
|
||||
|
||||
The derivation uses two tiers:
|
||||
|
||||
| Tier | Salt | Rotates | Purpose |
|
||||
|------|------|---------|---------|
|
||||
| **Bootstrap** | `b"bootstrap"` | Only when root key changes | Superuser passwords, OAuth2 app |
|
||||
| **Service** | `commit_sha` | Every deploy | DB passwords, S3 keys, API tokens |
|
||||
|
||||
### Managed Credentials (18 derived, 2 skipped)
|
||||
|
||||
| Variable | Tier | Format | Backend |
|
||||
|----------|------|--------|---------|
|
||||
| `POSTGRES_PASSWORD` | Bootstrap | password | env only |
|
||||
| `GITEA_DB_PASSWORD` | Service | password | PostgreSQL (role: git) |
|
||||
| `WOODPECKER_DB_PASSWORD` | Service | password | PostgreSQL (role: woodpecker) |
|
||||
| `NESSIE_DB_PASSWORD` | Service | password | PostgreSQL (role: nessie) |
|
||||
| `POLARIS_DB_PASSWORD` | Service | password | PostgreSQL (role: polaris) |
|
||||
| `RUSTFS_ACCESS_KEY` | Service | hex | env only |
|
||||
| `RUSTFS_SECRET_KEY` | Service | hex | env only |
|
||||
| `GITEA_S3_*`, `NESSIE_S3_*`, `POLARIS_S3_*` | Service | hex | env only (aliases of RUSTFS) |
|
||||
| `WOODPECKER_AGENT_SECRET` | Service | hex | env only |
|
||||
| `POLARIS_ROOT_SECRET` | Service | password | env only |
|
||||
| `GF_ADMIN_PASSWORD` | Service | password | env only |
|
||||
| `GITEA_ADMIN_PASSWORD` | Service | password | Gitea (change-password) |
|
||||
| `GITEA_TOKEN` | Service | — | Gitea (API token) |
|
||||
|
||||
Not managed: `WOODPECKER_GITEA_CLIENT/SECRET` (created once via OAuth2 app), `DATABRICKS_TOKEN` (external).
|
||||
|
||||
### CLI Usage
|
||||
|
||||
```bash
|
||||
# First-time setup: create DB roles + databases, derive all creds, write .env
|
||||
ROOT_KEY=$KEY uv run python -m api.auth bootstrap $(git rev-parse HEAD)
|
||||
|
||||
# Rotate: derive new service credentials, update backends, rewrite .env
|
||||
ROOT_KEY=$KEY uv run python -m api.auth provision $COMMIT_SHA
|
||||
|
||||
# Dry-run: print derived values without provisioning
|
||||
ROOT_KEY=$KEY uv run python -m api.auth derive $COMMIT_SHA --redact
|
||||
```
|
||||
|
||||
### Auto-Rotation via CI
|
||||
|
||||
The `provision` step in `.woodpecker/deploy.yml` runs after every deploy, deriving fresh service-tier credentials from the commit SHA and rotating PostgreSQL passwords and Gitea tokens automatically.
|
||||
|
||||
## Data Lakehouse
|
||||
|
||||
| Component | Purpose |
|
||||
@@ -455,7 +470,8 @@ stack/
|
||||
│ └── etc/ # Trino config + catalog properties
|
||||
├── notebooks/ # Marimo notebook files
|
||||
├── zotero/ # Zotero data + profiles
|
||||
├── src/ # Python source (narwhals expressions, pipes)
|
||||
├── src/ # Python source
|
||||
│ └── api/auth/ # HKDF credential derivation + provisioning
|
||||
├── tests/ # Test suite
|
||||
├── .woodpecker/ # CI/CD pipeline definitions
|
||||
│ ├── ci.yml
|
||||
|
||||
20
src/api/auth/__init__.py
Normal file
20
src/api/auth/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""HKDF credential derivation and auto-rotation."""
|
||||
|
||||
from api.auth.derive import derive, derive_hex, derive_password
|
||||
from api.auth.manifest import CREDENTIALS, MANAGED_VARS, Credential, Format, Tier
|
||||
from api.auth.provision import bootstrap, derive_all, provision, write_env
|
||||
|
||||
__all__ = [
|
||||
"CREDENTIALS",
|
||||
"Credential",
|
||||
"Format",
|
||||
"MANAGED_VARS",
|
||||
"Tier",
|
||||
"bootstrap",
|
||||
"derive",
|
||||
"derive_all",
|
||||
"derive_hex",
|
||||
"derive_password",
|
||||
"provision",
|
||||
"write_env",
|
||||
]
|
||||
76
src/api/auth/__main__.py
Normal file
76
src/api/auth/__main__.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""CLI for credential derivation and provisioning.
|
||||
|
||||
Usage:
|
||||
uv run python -m api.auth bootstrap <commit_sha>
|
||||
uv run python -m api.auth provision <commit_sha>
|
||||
uv run python -m api.auth derive <commit_sha> [--redact]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
if len(args) < 2 or args[0] not in ("bootstrap", "provision", "derive"):
|
||||
print(__doc__.strip(), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
command = args[0]
|
||||
commit_sha = args[1]
|
||||
redact = "--redact" in args
|
||||
|
||||
root_hex = os.environ.get("ROOT_KEY", "")
|
||||
if not root_hex:
|
||||
print("ERROR: ROOT_KEY environment variable not set", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
root_key = bytes.fromhex(root_hex)
|
||||
except ValueError:
|
||||
print("ERROR: ROOT_KEY must be hex-encoded", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if len(root_key) < 16:
|
||||
print("ERROR: ROOT_KEY too short (need >= 128 bits)", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
env_path = Path(".env")
|
||||
|
||||
if command == "derive":
|
||||
from api.auth.provision import derive_all
|
||||
|
||||
values = derive_all(root_key, commit_sha)
|
||||
for k, v in sorted(values.items()):
|
||||
display = v[:4] + "..." if redact else v
|
||||
print(f"{k}={display}")
|
||||
return 0
|
||||
|
||||
if command == "provision":
|
||||
from api.auth.provision import provision
|
||||
|
||||
provision(root_key, commit_sha, env_path)
|
||||
print(f"Provisioned {len(derive_all_count(root_key, commit_sha))} credentials")
|
||||
return 0
|
||||
|
||||
if command == "bootstrap":
|
||||
from api.auth.provision import bootstrap
|
||||
|
||||
bootstrap(root_key, commit_sha, env_path)
|
||||
print("Bootstrap complete")
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def derive_all_count(root_key: bytes, commit_sha: str) -> dict[str, str]:
|
||||
from api.auth.provision import derive_all
|
||||
|
||||
return derive_all(root_key, commit_sha)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
54
src/api/auth/derive.py
Normal file
54
src/api/auth/derive.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""HKDF-SHA256 credential derivation using only stdlib."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import math
|
||||
|
||||
|
||||
def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes:
|
||||
"""HKDF-Extract (RFC 5869 Section 2.2)."""
|
||||
return hmac.new(salt, ikm, hashlib.sha256).digest()
|
||||
|
||||
|
||||
def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes:
|
||||
"""HKDF-Expand (RFC 5869 Section 2.3)."""
|
||||
hash_len = 32 # SHA-256
|
||||
n = math.ceil(length / hash_len)
|
||||
okm = b""
|
||||
t = b""
|
||||
for i in range(1, n + 1):
|
||||
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
|
||||
okm += t
|
||||
return okm[:length]
|
||||
|
||||
|
||||
def derive(root_key: bytes, salt: bytes, info: bytes, length: int = 32) -> bytes:
|
||||
"""Derive keying material via HKDF-SHA256.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
root_key : bytes
|
||||
Input keying material (256-bit recommended).
|
||||
salt : bytes
|
||||
Non-secret salt (tier identifier or commit SHA).
|
||||
info : bytes
|
||||
Context string (credential scope).
|
||||
length : int
|
||||
Output length in bytes (max 255 * 32).
|
||||
"""
|
||||
prk = _hkdf_extract(salt, root_key)
|
||||
return _hkdf_expand(prk, info, length)
|
||||
|
||||
|
||||
def derive_hex(root_key: bytes, salt: bytes, info: bytes, length: int = 32) -> str:
|
||||
"""Derive a hex-encoded credential string."""
|
||||
return derive(root_key, salt, info, length).hex()
|
||||
|
||||
|
||||
def derive_password(root_key: bytes, salt: bytes, info: bytes, length: int = 24) -> str:
|
||||
"""Derive a URL-safe base64 password (no padding)."""
|
||||
raw = derive(root_key, salt, info, length)
|
||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||
199
src/api/auth/manifest.py
Normal file
199
src/api/auth/manifest.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Declarative credential registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class Tier(Enum):
|
||||
BOOTSTRAP = auto()
|
||||
SERVICE = auto()
|
||||
|
||||
|
||||
class Format(Enum):
|
||||
HEX = auto()
|
||||
PASSWORD = auto()
|
||||
|
||||
|
||||
class Provisioner(Enum):
|
||||
ENV_ONLY = auto()
|
||||
POSTGRES = auto()
|
||||
GITEA = auto()
|
||||
SKIP = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Credential:
|
||||
env_var: str
|
||||
scope: str
|
||||
tier: Tier
|
||||
fmt: Format
|
||||
provisioner: Provisioner
|
||||
alias_of: str | None = None
|
||||
|
||||
|
||||
CREDENTIALS: tuple[Credential, ...] = (
|
||||
# ── PostgreSQL ───────────────────────────────────────────
|
||||
Credential(
|
||||
"POSTGRES_PASSWORD",
|
||||
"postgres/superuser",
|
||||
Tier.BOOTSTRAP,
|
||||
Format.PASSWORD,
|
||||
Provisioner.ENV_ONLY,
|
||||
),
|
||||
Credential(
|
||||
"GITEA_DB_PASSWORD",
|
||||
"postgres/gitea",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.POSTGRES,
|
||||
),
|
||||
Credential(
|
||||
"WOODPECKER_DB_PASSWORD",
|
||||
"postgres/woodpecker",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.POSTGRES,
|
||||
),
|
||||
Credential(
|
||||
"NESSIE_DB_PASSWORD",
|
||||
"postgres/nessie",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.POSTGRES,
|
||||
),
|
||||
Credential(
|
||||
"POLARIS_DB_PASSWORD",
|
||||
"postgres/polaris",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.POSTGRES,
|
||||
),
|
||||
# ── RustFS S3 ────────────────────────────────────────────
|
||||
Credential(
|
||||
"RUSTFS_ACCESS_KEY",
|
||||
"rustfs/access",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
),
|
||||
Credential(
|
||||
"RUSTFS_SECRET_KEY",
|
||||
"rustfs/secret",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
),
|
||||
Credential(
|
||||
"GITEA_S3_ACCESS_KEY",
|
||||
"rustfs/access",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
alias_of="RUSTFS_ACCESS_KEY",
|
||||
),
|
||||
Credential(
|
||||
"GITEA_S3_SECRET_KEY",
|
||||
"rustfs/secret",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
alias_of="RUSTFS_SECRET_KEY",
|
||||
),
|
||||
Credential(
|
||||
"NESSIE_S3_ACCESS_KEY",
|
||||
"rustfs/access",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
alias_of="RUSTFS_ACCESS_KEY",
|
||||
),
|
||||
Credential(
|
||||
"NESSIE_S3_SECRET_KEY",
|
||||
"rustfs/secret",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
alias_of="RUSTFS_SECRET_KEY",
|
||||
),
|
||||
Credential(
|
||||
"POLARIS_S3_ACCESS_KEY",
|
||||
"rustfs/access",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
alias_of="RUSTFS_ACCESS_KEY",
|
||||
),
|
||||
Credential(
|
||||
"POLARIS_S3_SECRET_KEY",
|
||||
"rustfs/secret",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
alias_of="RUSTFS_SECRET_KEY",
|
||||
),
|
||||
# ── Woodpecker ───────────────────────────────────────────
|
||||
Credential(
|
||||
"WOODPECKER_AGENT_SECRET",
|
||||
"woodpecker/agent",
|
||||
Tier.SERVICE,
|
||||
Format.HEX,
|
||||
Provisioner.ENV_ONLY,
|
||||
),
|
||||
Credential(
|
||||
"WOODPECKER_GITEA_CLIENT",
|
||||
"woodpecker/oauth-client",
|
||||
Tier.BOOTSTRAP,
|
||||
Format.HEX,
|
||||
Provisioner.SKIP,
|
||||
),
|
||||
Credential(
|
||||
"WOODPECKER_GITEA_SECRET",
|
||||
"woodpecker/oauth-secret",
|
||||
Tier.BOOTSTRAP,
|
||||
Format.HEX,
|
||||
Provisioner.SKIP,
|
||||
),
|
||||
# ── Others ───────────────────────────────────────────────
|
||||
Credential(
|
||||
"POLARIS_ROOT_SECRET",
|
||||
"polaris/root",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.ENV_ONLY,
|
||||
),
|
||||
Credential(
|
||||
"GF_ADMIN_PASSWORD",
|
||||
"grafana/admin",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.ENV_ONLY,
|
||||
),
|
||||
Credential(
|
||||
"GITEA_ADMIN_PASSWORD",
|
||||
"gitea/admin",
|
||||
Tier.SERVICE,
|
||||
Format.PASSWORD,
|
||||
Provisioner.GITEA,
|
||||
),
|
||||
Credential(
|
||||
"GITEA_TOKEN", "gitea/token", Tier.SERVICE, Format.PASSWORD, Provisioner.GITEA
|
||||
),
|
||||
)
|
||||
|
||||
MANAGED_VARS: frozenset[str] = frozenset(c.env_var for c in CREDENTIALS)
|
||||
|
||||
POSTGRES_ROLES: dict[str, str] = {
|
||||
"git": "GITEA_DB_PASSWORD",
|
||||
"woodpecker": "WOODPECKER_DB_PASSWORD",
|
||||
"nessie": "NESSIE_DB_PASSWORD",
|
||||
"polaris": "POLARIS_DB_PASSWORD",
|
||||
}
|
||||
|
||||
POSTGRES_DATABASES: dict[str, str] = {
|
||||
"git": "gitea",
|
||||
"woodpecker": "woodpecker",
|
||||
"nessie": "nessie",
|
||||
"polaris": "polaris",
|
||||
}
|
||||
242
src/api/auth/provision.py
Normal file
242
src/api/auth/provision.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Credential derivation, provisioning, and .env generation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from api.auth.derive import derive_hex, derive_password
|
||||
from api.auth.manifest import (
|
||||
CREDENTIALS,
|
||||
POSTGRES_DATABASES,
|
||||
POSTGRES_ROLES,
|
||||
Credential,
|
||||
Format,
|
||||
Provisioner,
|
||||
Tier,
|
||||
)
|
||||
|
||||
BOOTSTRAP_SALT = b"bootstrap"
|
||||
|
||||
|
||||
def _derive_one(cred: Credential, root_key: bytes, salt: bytes) -> str:
|
||||
info = cred.scope.encode()
|
||||
if cred.fmt is Format.HEX:
|
||||
return derive_hex(root_key, salt, info)
|
||||
return derive_password(root_key, salt, info)
|
||||
|
||||
|
||||
def derive_all(root_key: bytes, commit_sha: str) -> dict[str, str]:
|
||||
"""Derive all credentials from root key and commit SHA."""
|
||||
service_salt = commit_sha.encode()
|
||||
values: dict[str, str] = {}
|
||||
for cred in CREDENTIALS:
|
||||
if cred.provisioner is Provisioner.SKIP:
|
||||
continue
|
||||
salt = BOOTSTRAP_SALT if cred.tier is Tier.BOOTSTRAP else service_salt
|
||||
values[cred.env_var] = _derive_one(cred, root_key, salt)
|
||||
return values
|
||||
|
||||
|
||||
def write_env(values: dict[str, str], path: Path) -> None:
|
||||
"""Merge derived values into .env, preserving unmanaged keys."""
|
||||
existing: dict[str, str] = {}
|
||||
if path.exists():
|
||||
for line in path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
k, _, v = line.partition("=")
|
||||
existing[k.strip()] = v.strip()
|
||||
|
||||
existing.update(values)
|
||||
lines = [f"{k}={v}" for k, v in sorted(existing.items())]
|
||||
path.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def provision_postgres(values: dict[str, str], *, container: str = "postgres") -> None:
|
||||
"""Rotate passwords for all managed PostgreSQL roles."""
|
||||
for role, env_var in POSTGRES_ROLES.items():
|
||||
pw = values[env_var]
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-c",
|
||||
f"ALTER ROLE {role} PASSWORD '{pw}'",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_postgres(values: dict[str, str], *, container: str = "postgres") -> None:
|
||||
"""Idempotent first-time setup: create roles and databases."""
|
||||
superuser_pw = values["POSTGRES_PASSWORD"]
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-c",
|
||||
f"ALTER ROLE postgres PASSWORD '{superuser_pw}'",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for role, env_var in POSTGRES_ROLES.items():
|
||||
pw = values[env_var]
|
||||
db = POSTGRES_DATABASES[role]
|
||||
sql = (
|
||||
f"DO $$ BEGIN "
|
||||
f"IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname='{role}') "
|
||||
f"THEN CREATE ROLE {role} LOGIN PASSWORD '{pw}'; END IF; END $$; "
|
||||
f"SELECT 'CREATE DATABASE {db} OWNER {role}' "
|
||||
f"WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname='{db}');"
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-c",
|
||||
sql,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
# Create database if it doesn't exist (psql doesn't support
|
||||
# IF NOT EXISTS for CREATE DATABASE inside DO blocks).
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"psql",
|
||||
"-U",
|
||||
"postgres",
|
||||
"-c",
|
||||
f"CREATE DATABASE {db} OWNER {role}",
|
||||
],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def provision_gitea(
|
||||
values: dict[str, str],
|
||||
*,
|
||||
container: str = "gitea",
|
||||
admin_user: str = "kert",
|
||||
) -> str:
|
||||
"""Rotate Gitea admin password and create a fresh API token."""
|
||||
pw = values["GITEA_ADMIN_PASSWORD"]
|
||||
|
||||
# Change admin password
|
||||
subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"exec",
|
||||
container,
|
||||
"gitea",
|
||||
"admin",
|
||||
"user",
|
||||
"change-password",
|
||||
"-u",
|
||||
admin_user,
|
||||
"-p",
|
||||
pw,
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Delete stale deploy tokens then create a fresh one, via API
|
||||
auth_header = _basic_header(admin_user, pw)
|
||||
client = _make_gitea_client(container, auth_header)
|
||||
|
||||
# List and delete stale deploy-* tokens
|
||||
tokens = client.get(
|
||||
f"/users/{admin_user}/tokens",
|
||||
headers={"Authorization": auth_header},
|
||||
).json()
|
||||
for tok in tokens:
|
||||
if tok.get("name", "").startswith("deploy-"):
|
||||
client.delete(
|
||||
f"/users/{admin_user}/tokens/{tok['id']}",
|
||||
headers={"Authorization": auth_header},
|
||||
)
|
||||
|
||||
# Create fresh token
|
||||
token_name = f"deploy-{int(time.time())}"
|
||||
resp = client.post(
|
||||
f"/users/{admin_user}/tokens",
|
||||
json={"name": token_name, "scopes": ["all"]},
|
||||
headers={"Authorization": auth_header},
|
||||
).json()
|
||||
return resp["sha1"]
|
||||
|
||||
|
||||
def _basic_header(user: str, password: str) -> str:
|
||||
encoded = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||
return f"Basic {encoded}"
|
||||
|
||||
|
||||
def _make_gitea_client(container: str, auth_header: str):
|
||||
from api.clients.gitea import GiteaClient
|
||||
|
||||
return GiteaClient(
|
||||
"unused",
|
||||
base_url=f"http://{container}:3000/api/v1",
|
||||
)
|
||||
|
||||
|
||||
def provision(
|
||||
root_key: bytes,
|
||||
commit_sha: str,
|
||||
env_path: Path,
|
||||
*,
|
||||
skip_backends: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""Main entry: derive, provision backends, write .env."""
|
||||
values = derive_all(root_key, commit_sha)
|
||||
|
||||
if not skip_backends:
|
||||
provision_postgres(values)
|
||||
token = provision_gitea(values)
|
||||
values["GITEA_TOKEN"] = token
|
||||
|
||||
write_env(values, env_path)
|
||||
return values
|
||||
|
||||
|
||||
def bootstrap(
|
||||
root_key: bytes,
|
||||
commit_sha: str,
|
||||
env_path: Path,
|
||||
*,
|
||||
skip_backends: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""First-time setup: create DBs, roles, then provision."""
|
||||
values = derive_all(root_key, commit_sha)
|
||||
|
||||
if not skip_backends:
|
||||
bootstrap_postgres(values)
|
||||
provision_postgres(values)
|
||||
token = provision_gitea(values)
|
||||
values["GITEA_TOKEN"] = token
|
||||
|
||||
write_env(values, env_path)
|
||||
return values
|
||||
@@ -60,3 +60,27 @@ class GiteaClient(Client):
|
||||
|
||||
def create_webhook(self, owner: str, repo: str, body: dict) -> dict:
|
||||
return self.post(f"/repos/{owner}/{repo}/hooks", json=body).json()
|
||||
|
||||
# ── Admin ──────────────────────────────────────────────
|
||||
|
||||
def change_admin_password(self, username: str, password: str) -> dict:
|
||||
return self.patch(
|
||||
f"/admin/users/{username}",
|
||||
json={"password": password, "must_change_password": False},
|
||||
).json()
|
||||
|
||||
# ── Tokens ─────────────────────────────────────────────
|
||||
|
||||
def list_tokens(self, username: str) -> list[dict]:
|
||||
return self.get(f"/users/{username}/tokens").json()
|
||||
|
||||
def create_token(
|
||||
self, username: str, name: str, scopes: list[str] | None = None
|
||||
) -> dict:
|
||||
body: dict = {"name": name}
|
||||
if scopes is not None:
|
||||
body["scopes"] = scopes
|
||||
return self.post(f"/users/{username}/tokens", json=body).json()
|
||||
|
||||
def delete_token(self, username: str, token_id: int) -> None:
|
||||
self.delete(f"/users/{username}/tokens/{token_id}")
|
||||
|
||||
@@ -56,6 +56,23 @@ class WoodpeckerClient(Client):
|
||||
def create_secret(self, repo_id: int, body: dict) -> dict:
|
||||
return self.post(f"/repos/{repo_id}/secrets", json=body).json()
|
||||
|
||||
def update_secret(self, repo_id: int, name: str, body: dict) -> dict:
|
||||
return self.patch(f"/repos/{repo_id}/secrets/{name}", json=body).json()
|
||||
|
||||
def delete_secret(self, repo_id: int, name: str) -> None:
|
||||
self.delete(f"/repos/{repo_id}/secrets/{name}")
|
||||
|
||||
# ── Global secrets ─────────────────────────────────────
|
||||
|
||||
def list_global_secrets(self) -> list[dict]:
|
||||
return self.get("/secrets").json()
|
||||
|
||||
def create_global_secret(self, body: dict) -> dict:
|
||||
return self.post("/secrets", json=body).json()
|
||||
|
||||
def update_global_secret(self, name: str, body: dict) -> dict:
|
||||
return self.patch(f"/secrets/{name}", json=body).json()
|
||||
|
||||
# ── Server ───────────────────────────────────────────────
|
||||
|
||||
def version(self) -> dict:
|
||||
|
||||
92
tests/api/test_derive.py
Normal file
92
tests/api/test_derive.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Tests for HKDF credential derivation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from api.auth.derive import derive, derive_hex, derive_password
|
||||
|
||||
ROOT = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
|
||||
SALT = b"test-salt"
|
||||
INFO = b"test-info"
|
||||
|
||||
|
||||
class TestDerive:
|
||||
def test_deterministic(self):
|
||||
a = derive(ROOT, SALT, INFO)
|
||||
b = derive(ROOT, SALT, INFO)
|
||||
assert a == b
|
||||
|
||||
def test_output_length(self):
|
||||
assert len(derive(ROOT, SALT, INFO, 16)) == 16
|
||||
assert len(derive(ROOT, SALT, INFO, 32)) == 32
|
||||
assert len(derive(ROOT, SALT, INFO, 64)) == 64
|
||||
|
||||
def test_different_salt_different_output(self):
|
||||
a = derive(ROOT, b"salt-a", INFO)
|
||||
b = derive(ROOT, b"salt-b", INFO)
|
||||
assert a != b
|
||||
|
||||
def test_different_info_different_output(self):
|
||||
a = derive(ROOT, SALT, b"info-a")
|
||||
b = derive(ROOT, SALT, b"info-b")
|
||||
assert a != b
|
||||
|
||||
def test_different_key_different_output(self):
|
||||
key2 = bytes(32)
|
||||
a = derive(ROOT, SALT, INFO)
|
||||
b = derive(key2, SALT, INFO)
|
||||
assert a != b
|
||||
|
||||
|
||||
class TestDeriveHex:
|
||||
def test_hex_format(self):
|
||||
h = derive_hex(ROOT, SALT, INFO)
|
||||
assert len(h) == 64
|
||||
bytes.fromhex(h) # must not raise
|
||||
|
||||
def test_hex_deterministic(self):
|
||||
assert derive_hex(ROOT, SALT, INFO) == derive_hex(ROOT, SALT, INFO)
|
||||
|
||||
def test_hex_matches_raw(self):
|
||||
assert derive_hex(ROOT, SALT, INFO) == derive(ROOT, SALT, INFO).hex()
|
||||
|
||||
def test_hex_custom_length(self):
|
||||
h = derive_hex(ROOT, SALT, INFO, length=16)
|
||||
assert len(h) == 32
|
||||
|
||||
|
||||
class TestDerivePassword:
|
||||
def test_password_url_safe(self):
|
||||
pw = derive_password(ROOT, SALT, INFO)
|
||||
assert "+" not in pw
|
||||
assert "/" not in pw
|
||||
assert "=" not in pw
|
||||
|
||||
def test_password_deterministic(self):
|
||||
a = derive_password(ROOT, SALT, INFO)
|
||||
b = derive_password(ROOT, SALT, INFO)
|
||||
assert a == b
|
||||
|
||||
def test_password_length(self):
|
||||
pw = derive_password(ROOT, SALT, INFO, length=24)
|
||||
assert len(pw) == 32 # 24 bytes -> 32 base64url chars
|
||||
|
||||
def test_password_different_from_hex(self):
|
||||
pw = derive_password(ROOT, SALT, INFO)
|
||||
h = derive_hex(ROOT, SALT, INFO)
|
||||
assert pw != h
|
||||
|
||||
|
||||
class TestRFCVector:
|
||||
"""Verify against RFC 5869 Test Case 1."""
|
||||
|
||||
def test_rfc5869_case1(self):
|
||||
ikm = bytes.fromhex("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
|
||||
salt = bytes.fromhex("000102030405060708090a0b0c")
|
||||
info = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9")
|
||||
expected = bytes.fromhex(
|
||||
"3cb25f25faacd57a90434f64d0362f2a"
|
||||
"2d2d0a90cf1a5a4c5db02d56ecc4c5bf"
|
||||
"34007208d5b887185865"
|
||||
)
|
||||
result = derive(ikm, salt, info, length=42)
|
||||
assert result == expected
|
||||
@@ -75,3 +75,35 @@ class TestGiteaRoutes:
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/v1/repos/o/r/hooks"
|
||||
|
||||
def test_change_admin_password(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport({}))
|
||||
c.change_admin_password("kert", "newpw")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "PATCH"
|
||||
assert req.url.path == "/api/v1/admin/users/kert"
|
||||
|
||||
def test_list_tokens(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport([]))
|
||||
c.list_tokens("kert")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/v1/users/kert/tokens"
|
||||
|
||||
def test_create_token(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport({"sha1": "abc"}))
|
||||
c.create_token("kert", "deploy", scopes=["all"])
|
||||
req = cap.requests[0]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/api/v1/users/kert/tokens"
|
||||
|
||||
def test_delete_token(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = GiteaClient("t", _transport=cap.transport(None, status=204))
|
||||
c.delete_token("kert", 42)
|
||||
req = cap.requests[0]
|
||||
assert req.method == "DELETE"
|
||||
assert req.url.path == "/api/v1/users/kert/tokens/42"
|
||||
|
||||
191
tests/api/test_provision.py
Normal file
191
tests/api/test_provision.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for credential provisioning."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from api.auth.manifest import (
|
||||
CREDENTIALS,
|
||||
MANAGED_VARS,
|
||||
POSTGRES_DATABASES,
|
||||
POSTGRES_ROLES,
|
||||
Format,
|
||||
Provisioner,
|
||||
Tier,
|
||||
)
|
||||
from api.auth.provision import derive_all, write_env
|
||||
|
||||
ROOT = bytes.fromhex("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
COMMIT = "abc1234"
|
||||
|
||||
|
||||
class TestManifestInvariants:
|
||||
def test_no_duplicate_env_vars(self):
|
||||
env_vars = [c.env_var for c in CREDENTIALS]
|
||||
assert len(env_vars) == len(set(env_vars))
|
||||
|
||||
def test_all_managed_vars_in_credentials(self):
|
||||
assert MANAGED_VARS == frozenset(c.env_var for c in CREDENTIALS)
|
||||
|
||||
def test_postgres_roles_have_credentials(self):
|
||||
for env_var in POSTGRES_ROLES.values():
|
||||
assert env_var in MANAGED_VARS
|
||||
|
||||
def test_postgres_roles_have_databases(self):
|
||||
for role in POSTGRES_ROLES:
|
||||
assert role in POSTGRES_DATABASES
|
||||
|
||||
def test_aliases_share_scope(self):
|
||||
by_var = {c.env_var: c for c in CREDENTIALS}
|
||||
for cred in CREDENTIALS:
|
||||
if cred.alias_of:
|
||||
parent = by_var[cred.alias_of]
|
||||
assert cred.scope == parent.scope
|
||||
assert cred.fmt == parent.fmt
|
||||
|
||||
def test_credential_count(self):
|
||||
assert len(CREDENTIALS) == 20
|
||||
|
||||
|
||||
class TestDeriveAll:
|
||||
def test_returns_dict(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
assert isinstance(values, dict)
|
||||
|
||||
def test_skips_skip_provisioner(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
skip_vars = {
|
||||
c.env_var for c in CREDENTIALS if c.provisioner is Provisioner.SKIP
|
||||
}
|
||||
for var in skip_vars:
|
||||
assert var not in values
|
||||
|
||||
def test_hex_format_values(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
for cred in CREDENTIALS:
|
||||
if cred.provisioner is Provisioner.SKIP:
|
||||
continue
|
||||
if cred.fmt is Format.HEX:
|
||||
v = values[cred.env_var]
|
||||
assert len(v) == 64
|
||||
bytes.fromhex(v)
|
||||
|
||||
def test_password_format_values(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
for cred in CREDENTIALS:
|
||||
if cred.provisioner is Provisioner.SKIP:
|
||||
continue
|
||||
if cred.fmt is Format.PASSWORD:
|
||||
v = values[cred.env_var]
|
||||
assert "+" not in v
|
||||
assert "/" not in v
|
||||
|
||||
def test_deterministic(self):
|
||||
a = derive_all(ROOT, COMMIT)
|
||||
b = derive_all(ROOT, COMMIT)
|
||||
assert a == b
|
||||
|
||||
def test_different_commit_different_service_values(self):
|
||||
a = derive_all(ROOT, "commit-a")
|
||||
b = derive_all(ROOT, "commit-b")
|
||||
# Bootstrap values should be the same
|
||||
for cred in CREDENTIALS:
|
||||
if cred.tier is Tier.BOOTSTRAP and cred.provisioner is not Provisioner.SKIP:
|
||||
assert a[cred.env_var] == b[cred.env_var]
|
||||
# At least one service value should differ
|
||||
service_vars = [
|
||||
c.env_var
|
||||
for c in CREDENTIALS
|
||||
if c.tier is Tier.SERVICE
|
||||
and c.provisioner is not Provisioner.SKIP
|
||||
and c.alias_of is None
|
||||
]
|
||||
assert any(a[v] != b[v] for v in service_vars)
|
||||
|
||||
def test_aliases_match_parent(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
for cred in CREDENTIALS:
|
||||
if cred.alias_of and cred.provisioner is not Provisioner.SKIP:
|
||||
assert values[cred.env_var] == values[cred.alias_of]
|
||||
|
||||
|
||||
class TestWriteEnv:
|
||||
def test_creates_new_file(self, tmp_path):
|
||||
p = tmp_path / ".env"
|
||||
write_env({"A": "1", "B": "2"}, p)
|
||||
lines = p.read_text().splitlines()
|
||||
assert lines == ["A=1", "B=2"]
|
||||
|
||||
def test_preserves_unmanaged_keys(self, tmp_path):
|
||||
p = tmp_path / ".env"
|
||||
p.write_text("DATABRICKS_TOKEN=ext\nOLD_KEY=val\n")
|
||||
write_env({"NEW_KEY": "new"}, p)
|
||||
content = p.read_text()
|
||||
assert "DATABRICKS_TOKEN=ext" in content
|
||||
assert "NEW_KEY=new" in content
|
||||
assert "OLD_KEY=val" in content
|
||||
|
||||
def test_overwrites_managed_keys(self, tmp_path):
|
||||
p = tmp_path / ".env"
|
||||
p.write_text("MY_KEY=old\n")
|
||||
write_env({"MY_KEY": "new"}, p)
|
||||
lines = p.read_text().splitlines()
|
||||
assert "MY_KEY=new" in lines
|
||||
assert "MY_KEY=old" not in lines
|
||||
|
||||
def test_sorted_output(self, tmp_path):
|
||||
p = tmp_path / ".env"
|
||||
write_env({"Z": "3", "A": "1", "M": "2"}, p)
|
||||
lines = p.read_text().splitlines()
|
||||
keys = [l.split("=")[0] for l in lines]
|
||||
assert keys == sorted(keys)
|
||||
|
||||
def test_skips_comments_and_blanks(self, tmp_path):
|
||||
p = tmp_path / ".env"
|
||||
p.write_text("# comment\n\nKEY=val\n")
|
||||
write_env({"NEW": "v"}, p)
|
||||
content = p.read_text()
|
||||
assert "KEY=val" in content
|
||||
assert "NEW=v" in content
|
||||
# Comments are not preserved (by design)
|
||||
assert "# comment" not in content
|
||||
|
||||
|
||||
class TestProvisionPostgres:
|
||||
def test_calls_docker_exec(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
with patch("api.auth.provision.subprocess.run") as mock_run:
|
||||
from api.auth.provision import provision_postgres
|
||||
|
||||
provision_postgres(values, container="test-pg")
|
||||
|
||||
assert mock_run.call_count == len(POSTGRES_ROLES)
|
||||
for call in mock_run.call_args_list:
|
||||
cmd = call[0][0]
|
||||
assert cmd[:3] == ["docker", "exec", "test-pg"]
|
||||
assert "ALTER ROLE" in cmd[-1]
|
||||
|
||||
|
||||
class TestProvisionGitea:
|
||||
def test_calls_change_password(self):
|
||||
values = derive_all(ROOT, COMMIT)
|
||||
mock_client = type("C", (), {})()
|
||||
mock_client.get = lambda *a, **kw: type("R", (), {"json": lambda self: []})()
|
||||
mock_client.post = lambda *a, **kw: type(
|
||||
"R", (), {"json": lambda self: {"sha1": "tok123"}}
|
||||
)()
|
||||
with (
|
||||
patch("api.auth.provision.subprocess.run") as mock_run,
|
||||
patch(
|
||||
"api.auth.provision._make_gitea_client",
|
||||
return_value=mock_client,
|
||||
),
|
||||
):
|
||||
from api.auth.provision import provision_gitea
|
||||
|
||||
token = provision_gitea(values, container="test-gitea")
|
||||
|
||||
assert token == "tok123"
|
||||
assert mock_run.call_count == 1
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "change-password" in cmd
|
||||
@@ -76,3 +76,43 @@ class TestWoodpeckerRoutes:
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/repos/10/secrets"
|
||||
|
||||
def test_update_secret(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport({}))
|
||||
c.update_secret(10, "my_secret", {"value": "new"})
|
||||
req = cap.requests[0]
|
||||
assert req.method == "PATCH"
|
||||
assert req.url.path == "/api/repos/10/secrets/my_secret"
|
||||
|
||||
def test_delete_secret(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport(None, status=204))
|
||||
c.delete_secret(10, "old_secret")
|
||||
req = cap.requests[0]
|
||||
assert req.method == "DELETE"
|
||||
assert req.url.path == "/api/repos/10/secrets/old_secret"
|
||||
|
||||
def test_list_global_secrets(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport([]))
|
||||
c.list_global_secrets()
|
||||
req = cap.requests[0]
|
||||
assert req.method == "GET"
|
||||
assert req.url.path == "/api/secrets"
|
||||
|
||||
def test_create_global_secret(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport({}))
|
||||
c.create_global_secret({"name": "s", "value": "v"})
|
||||
req = cap.requests[0]
|
||||
assert req.method == "POST"
|
||||
assert req.url.path == "/api/secrets"
|
||||
|
||||
def test_update_global_secret(self, capture_transport):
|
||||
cap = capture_transport
|
||||
c = WoodpeckerClient("t", _transport=cap.transport({}))
|
||||
c.update_global_secret("root_key", {"value": "new"})
|
||||
req = cap.requests[0]
|
||||
assert req.method == "PATCH"
|
||||
assert req.url.path == "/api/secrets/root_key"
|
||||
|
||||
Reference in New Issue
Block a user