297 lines
11 KiB
Markdown
297 lines
11 KiB
Markdown
# PRD 0048: SSH Deploy-Key Provisioning
|
|
|
|
- **Status:** Active
|
|
- **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 `provisioned_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 `provisioned_key:` as an alternative to
|
|
`identity:`. The parser rejects entries that have both, or neither.
|
|
- `provisioned_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 halts teardown and propagates the error loudly.
|
|
- `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
|
|
`provisioned_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.<name>` 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 provisioned_key)
|
|
provisioned_key (optional object — mutually exclusive with identity)
|
|
host_key (optional string)
|
|
```
|
|
|
|
Exactly one of `identity` or `provisioned_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 'provisioned_key'; got neither.
|
|
|
|
bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of
|
|
'identity' or 'provisioned_key'; got both.
|
|
```
|
|
|
|
`provisioned_key` object schema:
|
|
|
|
```yaml
|
|
provisioned_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://<host from url>
|
|
```
|
|
|
|
| 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
|
|
provisioned_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: '<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.
|
|
|
|
Must not raise if the key is already absent (HTTP 404 is success).
|
|
Must raise for all other failures so that teardown halts."""
|
|
|
|
|
|
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 provisioned_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 <tmpfile> -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. Raise `RuntimeError` for any other non-2xx response or network error,
|
|
including the status code and response body in the message.
|
|
|
|
HTTP calls use `urllib.request` from the stdlib; no new runtime dependency.
|
|
|
|
### `GitEntry` dataclass changes
|
|
|
|
`bot_bottle/manifest_git.py`:
|
|
|
|
- Add `ProvisionedKeyConfig` dataclass:
|
|
|
|
```python
|
|
@dataclass(frozen=True)
|
|
class ProvisionedKeyConfig:
|
|
provider: str
|
|
token_env: str
|
|
api_url: str # empty string means "derive from UpstreamHost"
|
|
```
|
|
|
|
- `GitEntry`:
|
|
- `IdentityFile: str` unchanged internally; empty string when
|
|
`provisioned_key` is used; set at provision time, not parse time.
|
|
- New field: `ProvisionedKey: ProvisionedKeyConfig | None = None`
|
|
- `from_repos_entry` validates the mutually-exclusive constraint and parses
|
|
the `provisioned_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/<name>-key`. That path stays unchanged
|
|
for `identity:` repos.
|
|
|
|
For `provisioned_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.ProvisionedKey.token_env]`. Missing key
|
|
raises `RuntimeError` with a clear message naming the env var.
|
|
2. Resolve `api_url = entry.ProvisionedKey.api_url or f"https://{entry.UpstreamHost}"`.
|
|
3. Instantiate `get_provisioner(entry.ProvisionedKey.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 `ProvisionedKey`
|
|
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. On success, log at INFO. On failure, allow the exception to propagate —
|
|
teardown halts and the error surfaces to the operator.
|
|
|
|
A stranded deploy key is a security concern: the operator must know about it
|
|
and address it manually. Silent continuation is not acceptable.
|
|
|
|
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:
|
|
- `provisioned_key:` accepted with valid `provider`, `token_env`, optional `api_url`
|
|
- Both `identity` and `provisioned_key` present → `ManifestError`
|
|
- Neither `identity` nor `provisioned_key` present → `ManifestError`
|
|
- Unknown key inside `provisioned_key` block → `ManifestError`
|
|
- Missing `provider` or `token_env` inside `provisioned_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()` raises `RuntimeError` on non-404 HTTP error
|
|
|
|
## Open questions
|
|
|
|
None.
|