Goals
We have two goals here, first one is to make the Git flow as generic as possible so when we need new providers they become easy to add. Second one is the connect flow, other providers don’t offer easy connect like Github does, so we design that part ourselves here and every provider goes through it, even Github. People can still use other Git providers through the CLI, they addunkey deploy as a step in their own CI and every push deploys through that. But that’s not a great DX, they have to set up and maintain that pipeline themselves. Our priority as Unkey is to offer the best DX that can be offered.
Gitlab, Bitbucket and Gitea are not the deliverable here, we use them to prove
the refactor actually works.
Definition of done
When is this refactor actually done? When adding a new provider feels like adding a handler/endpoint to our API. Basically one package that implementsProvider interface and one line in the registry,
same process as a new endpoint in svc/api/routes. No need to jump between worker, webhook mux and
connect service. The “Connect” UI needs one line too, a descriptor in the dashboard’s provider map,
but no new components, because the wizard renders from the connect style. See How the dashboard receives the descriptor.
Background
Today the Git integration is very coupled to Github. Sadly, we couldn’t foresee the days we would need other providers. When we started building Deploy, Github was the most popular and the best answer. And, still there are alternatives but not a replacement. Before this RFC I did a POC to see how easy it is to add new providers into our existing setup: POC. Surprisingly, adding new providers wasn’t really that hard, but we need to make our logic provider agnostic. Of course, Github will have some extra love, because compared to the others it has some unique features like fork-PR. I did three end-to-end POCs, and each provider does the same things slightly differently:For Gitea, we must be able to reach the customer’s self-hosted instance from
the public internet.
Where the Github code lives
pkg/github: GitHub App client, plus theGitHubClientinterface every caller depends on.pkg/webhook/verifiers/github: Webhook signature verification.svc/ctrl/api/webhooks/github: Inbound webhook route atPOST /webhooks/github.svc/ctrl/worker/githubwebhook: Turns a verified push into a deployment.svc/ctrl/worker/deploy/build.go: Mints the scoped clone token for BuildKit.web/internal/db/src/schema/github_app.ts: Thegithub_app_installationsandgithub_repo_connectionstables.
Where we are coupled to Github
These are the places where we assumed everything is Github, and we fix all of them with this refactor:fillFromGitHubandhasAuthincreate_deployment.goassume every connection is a Github installation. If we pass them a Gitlab project id or a Bitbucket hash, they still call the public Github API with it.- The worker looks up connections by
(installation_id, repository_id)and that’s a Github specific identity. The query isgithub_repo_connection_list_deploy_context.sql, we call it fromhandle_push.go. Bitbucket doesn’t even have a numeric repo id, and Gitea ids are per-instance, so two customers’ self-hosted instances can collide. - We write connection rows in three different places:
svc/ctrl,svc/api, and the dashboard directly via Drizzle. So when we add a new column, it’s easy to miss one of them.
Design
Everything in this section is a proposal, not a final decision. Column names, signatures and RPC shapes are all open to discussion. Here is what we want to end up with. Every provider is its own package on the left, they all talk to the interface in the middle, and the rest of the system only talks to the interface. Adding a provider means adding one more package on the left, nothing else changes, just like adding an API endpoint: Almost all Git providers follow these steps:- User clicks on Connect in the onboarding
- They grant us access to their repositories on the provider’s side
- We list the granted repositories and the user selects one
- User makes a change in the repository
- Provider calls us via Webhook
- We verify the webhook and parse the event
- And route the call to our build pipeline
github_repo_connections table provider agnostic. Eventually we’ll call it repo_connections, but it’s fine to do it later.
Current one
Proposed one
id and provider_token.
We need id because the webhook design below puts the connection id into the hook URL, and that
URL is the only thing that tells us which connection a delivery belongs to, the payload has no
Unkey ids in it. Since the URL sits in the customer’s provider settings, the id has to be safe to expose. We can’t use pk,
because people can do malicious stuff and try to guess other people’s webhooks. We can’t use app_id
either, because then we would leak people’s app_id. That’s why every connection gets a fresh conn_ uid.
provider_token is empty for Github on purpose. Github doesn’t give us a token to keep, it gives
us an installation, and we generate a short-lived, repo-scoped token from the App key every time we
build a customer’s deployment. The other providers give us a token once and we have to save it, so it goes there.
For Github CloneCredential (will be explained below) generates a fresh token, for Gitlab and Gitea it reads the
stored token, for Bitbucket it refreshes and persists.
The webhook signing keys are a different secret and they are not on this row. provider_token is
what we send to the provider to clone the repo and manage the hook, a signing key is what the
provider sends to us so we can tell a real delivery from a forged one. They also rotate the other
way around, the provider rotates the token on us, we rotate the signing key on the provider. And a
connection can hold more than one key while a rotation is in flight, so the keys live in their own
table, see Signing keys and rotation.
After the connect and repository selection every provider behaves the same. We verify the webhook,
parse the push, resolve a clone credential, and talk to the provider through a client.
If something is provider specific it goes into Descriptor, like which connect UI to show or how to mount their webhooks. See below for examples.
ScmConnectService stays generic and just delegates.
Not every provider does every step though, so the oauth-only and token+url-only parts go on their
own interfaces, and the service picks which one from Descriptor().ConnectStyle.
CreateHook returns the provider’s hook id (gitlab hook_id, bitbucket uid, gitea id) and we
store it on the connection, so a disconnect can delete the hook again. UpdateHook uses the same
id when we rotate a signing key. CreateHook runs inside the connect flow, so it takes the
session. UpdateHook and DeleteHook run days or months later, so they take the connection and
use the token stored on it.
Descriptor for connect wizard:
pkg/scm:
ConnectStyle and WebhookScope become typed string constants.
How the dashboard receives the descriptor
There are two ways to do it, we either let the dashboard RPC it, or we define another set ofProviderDescriptor in the dashboard.
The first way, ScmConnectService on ctrl exposes a ListProviders RPC, and the dashboard calls it via tRPC or the API:
Connect flow
The connect flow is our own version of “Github Installation” in a nutshell. Github gives us the install page, the repository listing and the callback. The other providers don’t, so we build those steps ourselves, once, and every provider goes through them. The dashboard just calls the new ctrlScmConnectService and renders the results.
The OAuth exchange and the repo listing are implemented on ctrl, so the dashboard holds
no provider logic, only the descriptor map. Today the Github connect completion is a dashboard tRPC that writes to
the DB directly, with this refactor that write moves to ctrl too.
The whole service is six RPCs.
Startis basically ourcreateGithubConnection, it generates the authorize URL with the signed state. The only difference is Github hosts the install page, here the button takes the user to the provider’s authorize page.Callbackis the redirect Github does back to the dashboard after the install. Same job, verify the state and handle the result. Here the result is a token, so it goes into a session row and never reaches the browser.ListReposis the “select repositories” screen of the Github install. Github renders that picker for us, here we render it ourselves using the session’s token.Completeis theconnectRepowrite we already do today, plus creating the webhook on the repo.Disconnectis the delete we already do today, plus removing the webhook from the repo first.Validatehas no Github example, it replaces the whole redirect dance for token+url providers.
{provider, instanceUrl, token}, Validate checks if it actually works and creates
the same session row Callback would. After that it goes through the same steps as the oauth flow, session then repos then complete.
Github (app_install) touches none of this, its install flow already works and we leave it alone.
One thing to be careful about here is that instanceUrl comes from the user, and ctrl runs inside our
cluster, so a URL like http://10.0.0.5:7091 would make ctrl call our own internal services for
them. Checking the URL once isn’t enough, a redirect can point it somewhere
private after the check passes. So we have to validate every request, not just at Validate, refuse redirects, and only allow https.
While researching this I found code.dny.dev/ssrf, and I believe we can use something like it instead of rolling
our own.
Sessions are DB rows. For the POC I kept them in memory, for production use that won’t work because
as soon as a pod restarts we would lose all the state. It also breaks with more than one replica,
the callback can land on pod A and the repo listing on pod B.
So sessions get their own table.
Webhook ingress
We go with one webhook per connection, not per repo. Each connection gets its own hook and its own signing keys. Github is unique because its webhook is configured on the App itself, not per repo. We create one webhook per connection using the/v1/webhooks/{provider}/{connection_id} format. During
the “Connect” step we generate a random secret, store it, and put it in the provider’s hook config,
so both sides hold it. On every delivery we take the conn id from the path, load that connection’s
signing keys, and verify the signature against them, newest first.
All four verify the same way, with one shared secret the provider holds and uses on every
delivery. For Github that secret is set once on the App, for the others we set it per connection.
Only the header and the algorithm differ:
Gitlab is the weakest of the four, it puts the secret itself in a header on every delivery. So
anything on the path to ctrl that logs request headers ends up holding a working secret. That is
one more reason a leak has to be repairable for one connection on its own.
Why not keep the flat
/webhooks/{provider} routes? Because that means one shared secret per
provider. Say customer A self-hosts Gitea. A is the admin, so the shared secret is sitting in A’s
own Gitea settings. Now A signs a fake push for customer B’s repo, sends it to /webhooks/gitea,
and we deploy B’s app. A never touched B’s Gitea or B’s repo.
With the conn id in the path every connection gets its own secret. If a secret leaks, the attacker
can only forge deliveries for that one connection, which they already control, and we can rotate
that one connection without touching anybody else. Github stays flat because no customer can read
its App secret.
Signing keys and rotation
A connection holds a list of signing keys.- Insert a new key row. Both keys verify from now on.
- Call the provider’s update-hook API with the new secret.
- Deliveries start arriving signed with the new key.
- Delete the old key row.
conn_id, cached in memory with a
short TTL so a flood of forged deliveries does not become DB load. We try the keys newest first and
compare in constant time.
Why the version is the first segment
Key rotation lives inscm_webhook_keys and needs no path change at all. The version is there because the hook URL sits
in the customer’s provider settings, so changing it is one API call per hook, and we can’t do all
of them at the same instant. If we later want a different shape, for example one hook per repo or
an extra segment for the workspace, we mount /v2/ next to /v1/, move hooks over in the
background, and drop /v1/ when its traffic reaches zero. The version comes first so everything
after it is free to change. A version in the middle only covers the tail of the path.
The Github app-global route keeps its current unversioned /webhooks/github path. Its secret comes
from ctrl config and its URL is configured once on the App, not per connection.
Github moves onto the interface too
Github becomes just anotherProvider. Its descriptor says app_install and app_global, and
those two values are the only places the rest of the code learns Github is different. The fork-PR
logic stays as a worker special case, there is no point abstracting it for one provider.
Nothing about Github’s behavior should change here, same routes, same secrets, same deployments,
we just route them through pkg/scm. If we can’t move Github onto the interface without changing
its behavior, that means the interface design is not well thought, so we adjust the interface.
Rollout
Each PR is shippable on its own. Shipping these PRs also doesn’t mean launching Gitlab, Bitbucket and Gitea, the POCs already proved they fit the interface. A provider only turns on when we give ctrl its config, register it, and add its descriptor to the dashboard map, so launching each one is up to us. We can add them whenever customers ask for it. The refactor itself:- First make the
pkg/scmpackage, theProviderinterface,Descriptorand the registry. - Then the db changes, backfills and migration scripts. We add
provider_repo_idand backfill it fromrepository_idfor the existing Github rows. - Then convert the existing Github code to the
pkg/scmshapes and protos. - Finally rename the table to
repo_connections.
- We build the per-connection webhook ingress, the
scm_webhook_keystable and the newest-first verification. The rotation job can wait, until we need it a rotation is an insert, one update-hook call and a delete. - We build
ScmConnectService. - We build the connect wizard on the dashboard, it renders whatever the descriptor map says.
Alternatives Considered
Do nothing and point people at the CLI
Everything already works today if the customer usesunkey deploy in their own CI, but it’s not the best DX.
Flat webhook routes with one shared secret per provider
This is what the POC did, the simplest possible ingress. A self-hosted Gitea admin can read the shared secret on their own instance and forge deliveries for every other customer. Full reasoning in Webhook ingress.One masterKey with derived per-connection secrets
Instead of storing a secret per connection we could derive it as hmac(masterKey, conn_id) and
store nothing at all. Verification would need no DB read, which was the appeal, and the Github
install state already uses that trick, see
v2_github_install_app/state.go.
We dropped it because rotation granularity matters more than the saved read. The provider holds
the derived secret, so swapping the masterKey still needs one update-hook call per connection.
Derivation saves the DB writes, not the fan-out. Worse, a single leaked secret, and Gitlab sends
its secret in a header on every delivery, can’t be repaired for one connection without moving
every other connection to a new key. The read we avoided is one cached point lookup, and the
worker loads the connection row a moment later anyway to resolve the app.