Skip to main content

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 add unkey 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 implements Provider 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.
See below for the existing Github setup if you need a refresher or if you haven’t touched any Github related code.

Where the Github code lives

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:
  • fillFromGitHub and hasAuth in create_deployment.go assume 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 is github_repo_connection_list_deploy_context.sql, we call it from handle_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
For Github it was easy because Github already covers some of these steps, but the others, at least the ones I POCed, don’t offer the connect, repo access grant, and repo selection steps. So we have to build some of these steps ourselves. To achieve this goal, the first step is to make the 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

Most of the columns are pretty clear, but two of them need a proper explanation, 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.
The connect ops live on the provider too, so 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.
Dashboard uses Descriptor for connect wizard:
The registry itself is a map in pkg/scm:
Ctrl fills it at startup for every provider that has config, and this is the “one line in the registry” from the definition of done.
And ctrl mounts webhook routes by iterating registered providers:
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 of ProviderDescriptor in the dashboard. The first way, ScmConnectService on ctrl exposes a ListProviders RPC, and the dashboard calls it via tRPC or the API:
The second way would look like this:
We go with the map in the dashboard. There are four providers on the table and no reason to expect many more, a descriptor is a connect style plus two booleans, and the wizard already renders every connect style, so a new provider is one entry and no new UI. The cost is that turning on a provider needs a dashboard release next to the ctrl config.

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 ctrl ScmConnectService 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.
I believe we can understand these better if we map them to the Github flow we already run today:
  • Start is basically our createGithubConnection, 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.
  • Callback is 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.
  • ListRepos is the “select repositories” screen of the Github install. Github renders that picker for us, here we render it ourselves using the session’s token.
  • Complete is the connectRepo write we already do today, plus creating the webhook on the repo.
  • Disconnect is the delete we already do today, plus removing the webhook from the repo first.
  • Validate has no Github example, it replaces the whole redirect dance for token+url providers.
The oauth flow (Gitlab, Bitbucket): The token+url flow (Gitea, Gitlab self-managed and Forgejo) skips the redirect process completely. There is no OAuth app here, the user already created a token on their own instance. They give us {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.
The newest key is the one that sits in the provider’s hook config. The older ones stay valid until we delete them. We need that list because rotation is not atomic on the provider side, there is always a window where deliveries are still signed with the old secret. Rotating one connection looks like this:
  • 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.
Nothing breaks if step two fails, the old key still verifies, so we retry. And rotating one connection touches one connection, so a leaked secret on customer A’s self-hosted Gitea never forces a rotation for customer B. This is the same shape vault already uses for workspace keys, encrypt with the newest key, decrypt with any of them. Verification needs the keys, so ingress does one point lookup on 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 in scm_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 another Provider. 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/scm package, the Provider interface, Descriptor and the registry.
  • Then the db changes, backfills and migration scripts. We add provider_repo_id and backfill it from repository_id for the existing Github rows.
  • Then convert the existing Github code to the pkg/scm shapes and protos.
  • Finally rename the table to repo_connections.
When we decide to launch the first non-Github provider:
  • We build the per-connection webhook ingress, the scm_webhook_keys table 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 uses unkey 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.

A ListProviders RPC for the descriptors

Ctrl owns the providers, so it could serve their descriptors and the dashboard would need no provider knowledge at all, not even one line per provider. We skipped it because the payload is four static rows that change about never, and the proto, the ctrl handler and the tRPC hop cost more than the entry in the map they replace. Full reasoning in How the dashboard receives the descriptor.

A separate scm_credentials table

This would move the tokens into their own table instead of a column on the connection. But every connection gets its own token from the provider and two connections can never share one, especially the rotating Bitbucket tokens, so the extra table and join don’t give us anything. Webhook signing keys are the opposite case, one connection can hold several of them at once during a rotation, which is why those do get their own table.