idcat is a service for safely delegating GitHub App permissions
It allows authenticated callers to get GitHub App installation access tokens without having direct access to the app’s private signing key. Instead, services and workflows are authenticated with idcat using JWT bearer tokens. Based on the issuer and claims in those tokens, idcat can issue installation tokens with precisely the permissions needed for a particular use case.
This makes it possible to grant applications and services controlled access to GitHub through a GitHub App, while keeping the app’s signing credentials centralised and protected.
- A High-performance, high-availability service written in Rust using Tokio and Axum
- Signing keys can optionally be stored in AWS KMS, making it more difficult for an attacker to get hold of sensitive material
- The centralised configuration setup encourages granting only permissions that are actually needed
- The ability to bridge webhook notifications from GitHub into the NATS messaging service
private-key-directory = "/var/run/secrets/idcat"
[[role]]
name = "kubernetes-default"
audience = "idcat"
issuer = "https://kubernetes.default.svc"
claims = {sub = "system:serviceaccount:default:default"}
[[role]]
name = "github-workflow"
audience = "idcat"
issuer = "https://token.actions.githubusercontent.com"
[[github-app]]
name = "deployments"
app-id = 123456
secret-key = "deployments-private-key.pem"
allowed-roles = ["kubernetes-default"]
[[installation-policy]]
github-app = "deployments"
repositories = ["myorg/alfa", "myorg/beta"]
role = "github-workflow"
required-claims = { repository = "myorg/gamma" }
permissions = { contents = "read" }See idcat.toml.example for a fuller configuration with multiple roles and GitHub Apps.
List multiple entries in allowed-roles to allow alternative authentication methods or
role trust requirements for the same GitHub App.
Use [[installation-policy]] to grant a role only for one installed app/repository
combination, with additional required token claims. Set either repository = "owner/name"
or repositories = ["owner/name", "owner/other"]; the request may match any configured
repository pattern. For example, a request for deployments on myorg/alfa can require
the token to satisfy github-workflow and also
carry repository = "myorg/gamma". Every [[installation-policy]] must also declare a
non-empty permissions table naming the GitHub permissions the minted token gets, so a
policy can never hand out the App's full installation permissions. App-level
allowed-roles still grant access to every repository installation for that GitHub App.
Use [[owner-policy]] to grant a role an installation-wide token for an account, for
account-level resources (such as org-owned packages) that a single-repository token cannot
read. It is served by a separate endpoint that names no repository — POST /installation-token/{github-app}/{owner} — so the token's scope always matches the request
path. Set either owner = "myorg" or owners = ["myorg", "otherorg"], and note that
allow-self-access here constrains the token's repository_owner claim to the requested
owner rather than its repository claim. [owner-policy.permissions] is required, because
permissions are the only thing keeping an owner-wide token narrower than one granted by
allowed-roles:
[[owner-policy]]
github-app = "deployments"
owner = "myorg"
role = "github-workflow"
allow-self-access = true
[owner-policy.permissions]
packages = "read"An [[installation-policy]] never widens beyond the requested repository, and an
[[owner-policy]] grants nothing on the repository endpoint; the two are independent.
Mount the private keys as files. For example, in Kubernetes this could be a Secret volume mounted at private-key-directory, but idcat only reads files from the filesystem.
kubectl create secret generic idcat \
--from-file=private-key.pem=/path/to/github-app-private-key.pemThe application does not need Kubernetes API permissions to read private keys.
When built with the kms feature, key-source may be set to kms. In that mode,
secret-key selects an AWS KMS alias instead of a filesystem path. Values without
the alias/ prefix are treated as alias names, so secret-key = "deployments"
uses alias/deployments. AWS credentials and region are loaded from the ambient
AWS SDK configuration.
curl -X POST \
-H "Authorization: Bearer $KUBERNETES_JWT" \
http://localhost:8080/installation-token/deployments/github_user/repo_nameThe response body is the GitHub installation token:
ghs_...
Omit the repository to request an installation-wide token for the account instead. This
requires a matching [[owner-policy]], and returns a token covering every repository the
installation can access, limited to the policy's permissions:
curl -X POST \
-H "Authorization: Bearer $GITHUB_WORKFLOW_JWT" \
http://localhost:8080/installation-token/deployments/myorgidcat resolves the installation from the account itself, trying the organization first and
falling back to the user installation, so {owner} may name either.
To proxy a repository-scoped GitHub API request through an installation token, prefix the GitHub
/repos/{owner}/{repo} path with /proxy/{github-app}. The GitHub app name selects the
configured GitHub App and its allowed roles, while the owner/repo pair comes from the proxied
GitHub API path:
curl -X GET \
-H "Authorization: Bearer $KUBERNETES_JWT" \
http://localhost:8080/proxy/deployments/repos/github_user/repo_name/contents/README.mdidcat can receive GitHub webhook callbacks and bridge them into NATS. GitHub
should be configured to deliver callbacks to /webhook/{github-app}, where
{github-app} matches a configured [[github-app]] name:
https://idcat.example.com/webhook/deployments
Each GitHub App opts in to bridging independently by setting webhook-target
in its config block. The NATS connection itself is configured once in a
top-level [nats] block and shared by every app that opts in:
[nats]
endpoint = "nats://nats.example.com:4222"
subject-base = "idcat.github.webhook"
token-path = "/var/run/secrets/idcat/nats-token"
[[github-app]]
name = "deployments"
app-id = 123456
secret-key = "deployments-private-key.pem"
webhook-target = "nats"If the GitHub App is configured with a webhook secret, set
webhook-validation-secret-file to the path of a file containing that secret.
idcat then verifies the X-Hub-Signature-256 header on each delivery
(GitHub docs)
and rejects deliveries that do not match. The secret is read from the file on
every delivery, so it can be rotated without restarting idcat.
[[github-app]]
name = "deployments"
app-id = 123456
secret-key = "deployments-private-key.pem"
webhook-target = "nats"
webhook-validation-secret-file = "/var/run/secrets/idcat/deployments-webhook-secret"cargo run -- --config-file idcat.tomlBy default, idcat serves HTTP on bind-address. To serve HTTPS instead, configure PEM-formatted
certificate and private key files:
bind-address = "0.0.0.0:8443"
[tls]
certificate-file = "/var/run/secrets/idcat/tls.crt"
private-key-file = "/var/run/secrets/idcat/tls.key"When [tls] is present, idcat serves HTTPS only. The certificate file should include any
intermediate certificates required by clients.
For local testing, authentication and role checks can be bypassed:
cargo run -- --config-file idcat.toml --disable-authUse --debug to log detailed installation-token flow steps:
cargo run -- --config-file idcat.toml --debug--validate-config checks the config and exits, without binding a socket,
reaching KMS or contacting GitHub. It exits 0 for a valid config and non-zero
with the reason otherwise, which is what manifest-validator runs this image for
before a config change reaches a cluster:
cargo run -- --config-file idcat.toml --validate-config--disable-auth composes with it, because a config that is only valid with auth
disabled must not pass a check run without it.
GitHub App installation IDs are cached in memory after the first lookup. Installation tokens are cached in memory for 50 minutes per GitHub app and repo.
MIT