11 KiB
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.reposentries acceptprovisioned_key:as an alternative toidentity:. The parser rejects entries that have both, or neither.provisioned_key.provider: giteaprovisions 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 coverprovisioned_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:
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:
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
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):
- 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). - Read the private key bytes and the
.pubfile. POST /api/v1/repos/{owner}/{repo}/keyswith the public key,title, andread_only: false(deploy keys always need push access for git-gate).- Return
(str(response["id"]), private_key_bytes).
delete(owner_repo, key_id):
DELETE /api/v1/repos/{owner}/{repo}/keys/{id}.- Treat HTTP 404 as success (key already gone).
- Raise
RuntimeErrorfor 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
ProvisionedKeyConfigdataclass:@dataclass(frozen=True) class ProvisionedKeyConfig: provider: str token_env: str api_url: str # empty string means "derive from UpstreamHost" -
GitEntry:IdentityFile: strunchanged internally; empty string whenprovisioned_keyis used; set at provision time, not parse time.- New field:
ProvisionedKey: ProvisionedKeyConfig | None = None from_repos_entryvalidates the mutually-exclusive constraint and parses theprovisioned_keyblock 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:
- Resolve
token = os.environ[entry.ProvisionedKey.token_env]. Missing key raisesRuntimeErrorwith a clear message naming the env var. - Resolve
api_url = entry.ProvisionedKey.api_url or f"https://{entry.UpstreamHost}". - Instantiate
get_provisioner(entry.ProvisionedKey.provider, token, api_url). - Call
provisioner.create(entry.UpstreamPath.lstrip("/"), title)wheretitle = f"bot-bottle:{bottle_name}:{entry.Name}". - Write private key to
stage_dir / f"{entry.Name}-key"(mode 0o600). - Write key ID to
stage_dir / f"{entry.Name}-deploy-key-id"(plain text). - Return the key file path; caller sets
GitGateUpstream.IdentityFileto 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:
- Check that
stage_dir / f"{entry.Name}-deploy-key-id"exists; skip if absent (provision never ran or already cleaned up). - Resolve token and API URL as above.
- Instantiate provisioner and call
provisioner.delete(owner_repo, key_id). - 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 validprovider,token_env, optionalapi_url- Both
identityandprovisioned_keypresent →ManifestError - Neither
identitynorprovisioned_keypresent →ManifestError - Unknown key inside
provisioned_keyblock →ManifestError - Missing
providerortoken_envinsideprovisioned_key→ManifestError
-
tests/unit/test_deploy_key_provisioner.py— new:get_provisioner("gitea", ...)returnsGiteaDeployKeyProvisionerget_provisioner("unknown", ...)raisesManifestError
-
tests/unit/test_contrib_gitea_deploy_key.py— new (usingunittest.mockto stuburllib.request.urlopenandsubprocess.run):create()callsssh-keygen, POSTs to correct endpoint, returns key IDdelete()DELETEs to correct endpointdelete()tolerates HTTP 404 (already-deleted key)delete()raisesRuntimeErroron non-404 HTTP error
Open questions
None.