diff --git a/docs/prds/0048-ssh-deploy-key-provisioning.md b/docs/prds/0048-ssh-deploy-key-provisioning.md new file mode 100644 index 0000000..c14bcf7 --- /dev/null +++ b/docs/prds/0048-ssh-deploy-key-provisioning.md @@ -0,0 +1,288 @@ +# PRD 0048: SSH Deploy-Key Provisioning + +- **Status:** Draft +- **Author:** didericis-claude +- **Created:** 2026-06-03 +- **Issue:** #169 + +## Summary + +Replace per-repo static SSH identity files with short-lived ed25519 deploy +keys that are generated at spin-up and revoked at teardown. Introduce +`bot_bottle/contrib/` as the package for platform-specific provisioners and +ship the first contrib sub-package: `bot_bottle/contrib/gitea/` with +`GiteaDeployKeyProvisioner`. A new `deploy_key:` block in `git-gate.repos` +entries opts a repo into automatic key lifecycle management; `identity:` stays +valid for operators who supply their own key material. + +## Problem + +The current `git-gate.repos` entries require an `identity:` field pointing to +a host-side SSH private key (PRD 0047). Keys are static: the operator generates +them once, registers them with the upstream forge, and the same key is reused +across every bottle spin-up. This has several consequences: + +- **No automatic revocation.** If a bottle misbehaves or a key leaks, the + operator must notice and manually delete the key from the forge. There is no + teardown hook that does it. +- **Broad blast radius.** A forge deploy key typically grants write access for + the lifetime of the key. A static key that survives bottle teardown continues + to grant that access. +- **Manual rotation burden.** Operators must manage key files on disk, keeping + them secure, rotating them on a schedule, and distributing them across hosts + that run `./cli.py start`. + +## Goals / Success Criteria + +- `git-gate.repos` entries accept `deploy_key:` as an alternative to + `identity:`. The parser rejects entries that have both, or neither. +- `deploy_key.provider: gitea` provisions and revokes deploy keys via the + Gitea HTTP API. +- At prepare time the provisioner generates a fresh ed25519 keypair, registers + the public half as a repo-scoped deploy key, and makes the private key + available to git-gate at the path it expects — the rest of the pipeline is + unchanged. +- At teardown the provisioner deletes the registered deploy key. Failure to + delete is logged as a warning and does not abort teardown. +- `bot_bottle/contrib/` is introduced as the package for platform-specific + implementations; the core defines the abstract interface; contrib sub-packages + provide concrete implementations. +- Existing `identity:`-based repos continue to work without change. +- The unit test suite passes unchanged for `identity:` paths; new tests cover + `deploy_key:` parse, validation, and provisioner dispatch. + +## Non-goals + +- GitHub, GitLab, or other forge providers (a future contrib sub-package each). +- Dashboard UI for listing or revoking orphaned deploy keys. +- SSH CA certificate approach (rejected in the issue thread in favour of + per-repo deploy keys for simpler revocation, smaller blast radius, and forge + compatibility). +- Key rotation mid-session (keys live for exactly one spin-up / teardown cycle). +- Any change to how `identity:` repos are provisioned. + +## Design + +### Manifest changes (builds on PRD 0047) + +`git-gate.repos.` currently accepts exactly: + +``` +url (required string) +identity (required string) +host_key (optional string) +``` + +After this PRD: + +``` +url (required string) +identity (optional string — mutually exclusive with deploy_key) +deploy_key (optional object — mutually exclusive with identity) +host_key (optional string) +``` + +Exactly one of `identity` or `deploy_key` must be present. The parser emits a +targeted error for each violation: + +``` +bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of +'identity' or 'deploy_key'; got neither. + +bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of +'identity' or 'deploy_key'; got both. +``` + +`deploy_key` object schema: + +```yaml +deploy_key: + provider: gitea # required; names the contrib module to load + token_env: GITEA_TOKEN # required; name of a host env var holding the API token + api_url: https://... # optional; defaults to https:// +``` + +| Field | Type | Notes | +|-------|------|-------| +| `provider` | required string | Must match a sub-package under `bot_bottle/contrib/` | +| `token_env` | required string | Resolved at provision time via `os.environ`; never stored in plan | +| `api_url` | optional string | Override when the API endpoint differs from the git host | + +**Example bottle manifest:** + +```yaml +git-gate: + user: + name: implementer-bot + email: eric+implementer@dideric.is + repos: + bot-bottle: + url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git + deploy_key: + provider: gitea + token_env: GITEA_DEPLOY_TOKEN + host_key: "ssh-rsa AAAA..." +``` + +### `contrib` package structure + +``` +bot_bottle/ + contrib/ + __init__.py # empty; no core symbols + gitea/ + __init__.py # empty + deploy_key_provisioner.py +``` + +`contrib` is a flat namespace of forge/platform sub-packages. Each sub-package +is self-contained; the core imports from contrib lazily (inside factory +functions) so that missing optional dependencies in a contrib sub-package don't +break unrelated features. + +### Core interface + +New file: `bot_bottle/deploy_key_provisioner.py` + +```python +from abc import ABC, abstractmethod + +class DeployKeyProvisioner(ABC): + @abstractmethod + def create(self, owner_repo: str, title: str) -> tuple[str, bytes]: + """Generate a keypair and register the public half. + + owner_repo: '/' portion of the git upstream URL. + title: human-readable label shown in the forge key list. + + Returns (key_id, private_key_pem) where key_id is opaque to + the caller and is only passed back to delete().""" + + @abstractmethod + def delete(self, owner_repo: str, key_id: str) -> None: + """Delete the registered deploy key. Best-effort; should not + raise if the key is already absent.""" + + +def get_provisioner(provider: str, token: str, api_url: str) -> DeployKeyProvisioner: + """Instantiate the named contrib provisioner. + + Raises ManifestError for unknown providers so the error is caught + at parse time rather than at runtime.""" + if provider == "gitea": + from bot_bottle.contrib.gitea.deploy_key_provisioner import ( + GiteaDeployKeyProvisioner, + ) + return GiteaDeployKeyProvisioner(token=token, api_url=api_url) + from .manifest_util import ManifestError + raise ManifestError(f"unknown deploy_key provider: {provider!r}") +``` + +### Gitea contrib implementation + +`bot_bottle/contrib/gitea/deploy_key_provisioner.py`: + +`create(owner_repo, title)`: +1. Generate an ed25519 keypair via `ssh-keygen -t ed25519 -f -N ''` + (uses the SSH tooling already required by git-gate; no new Python dependency). +2. Read the private key bytes and the `.pub` file. +3. `POST /api/v1/repos/{owner}/{repo}/keys` with the public key, `title`, and + `read_only: false` (deploy keys always need push access for git-gate). +4. Return `(str(response["id"]), private_key_bytes)`. + +`delete(owner_repo, key_id)`: +1. `DELETE /api/v1/repos/{owner}/{repo}/keys/{id}`. +2. Treat HTTP 404 as success (key already gone). +3. Log a warning and return (do not raise) for any other error. + +HTTP calls use `urllib.request` from the stdlib; no new runtime dependency. + +### `GitEntry` dataclass changes + +`bot_bottle/manifest_git.py`: + +- Add `DeployKeyConfig` dataclass: + + ```python + @dataclass(frozen=True) + class DeployKeyConfig: + provider: str + token_env: str + api_url: str # empty string means "derive from UpstreamHost" + ``` + +- `GitEntry`: + - `IdentityFile: str` → `IdentityFile: str` (unchanged internally; empty string + when `deploy_key` is used; set at provision time, not parse time) + - New field: `DeployKey: DeployKeyConfig | None = None` + - `from_repos_entry` validates the mutually-exclusive constraint and parses + the `deploy_key` block when present. + +### `GitGateUpstream` / prepare-time changes + +`bot_bottle/git_gate.py` and `bot_bottle/backend/docker/provision/git.py`: + +The existing path writes the identity file path into `GitGateUpstream.IdentityFile` +and docker-cp's it into `/git-gate/creds/-key`. That path stays unchanged +for `identity:` repos. + +For `deploy_key:` repos, a new helper `provision_deploy_key(entry, stage_dir, +bottle_name)` runs before the git-gate sidecar starts: + +1. Resolve `token = os.environ[entry.DeployKey.token_env]`. Missing key raises + `RuntimeError` with a clear message naming the env var. +2. Resolve `api_url = entry.DeployKey.api_url or f"https://{entry.UpstreamHost}"`. +3. Instantiate `get_provisioner(entry.DeployKey.provider, token, api_url)`. +4. Call `provisioner.create(entry.UpstreamPath.lstrip("/"), title)` where + `title = f"bot-bottle:{bottle_name}:{entry.Name}"`. +5. Write private key to `stage_dir / f"{entry.Name}-key"` (mode 0o600). +6. Write key ID to `stage_dir / f"{entry.Name}-deploy-key-id"` (plain text). +7. Return the key file path; caller sets `GitGateUpstream.IdentityFile` to it. + +`owner_repo` is extracted from `entry.UpstreamPath` (the path component of the +`ssh://` URL, e.g. `/didericis/bot-bottle.git` → `didericis/bot-bottle`). + +### Teardown changes + +`bot_bottle/backend/docker/cleanup.py` (or the equivalent teardown path): + +After the git-gate sidecar stops, for each `GitEntry` with `DeployKey` set: + +1. Check that `stage_dir / f"{entry.Name}-deploy-key-id"` exists; skip if absent + (provision never ran or already cleaned up). +2. Resolve token and API URL as above. +3. Instantiate provisioner and call `provisioner.delete(owner_repo, key_id)`. +4. Log result at INFO (success) or WARNING (failure); continue regardless. + +The private key file in `stage_dir` is cleaned up as part of normal stage-dir +teardown (no extra step needed). + +## Testing strategy + +``` +python3 -m unittest discover -s tests/unit +``` + +New / modified test files: + +- `tests/unit/test_manifest_git.py` — add cases for: + - `deploy_key:` accepted with valid `provider`, `token_env`, optional `api_url` + - Both `identity` and `deploy_key` present → `ManifestError` + - Neither `identity` nor `deploy_key` present → `ManifestError` + - Unknown key inside `deploy_key` block → `ManifestError` + - Missing `provider` or `token_env` inside `deploy_key` → `ManifestError` + +- `tests/unit/test_deploy_key_provisioner.py` — new: + - `get_provisioner("gitea", ...)` returns `GiteaDeployKeyProvisioner` + - `get_provisioner("unknown", ...)` raises `ManifestError` + +- `tests/unit/test_contrib_gitea_deploy_key.py` — new (using `unittest.mock` + to stub `urllib.request.urlopen` and `subprocess.run`): + - `create()` calls `ssh-keygen`, POSTs to correct endpoint, returns key ID + - `delete()` DELETEs to correct endpoint + - `delete()` tolerates HTTP 404 (already-deleted key) + - `delete()` logs warning (does not raise) on other HTTP errors + +## Open questions + +None.