Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ aws-sdk-kms = { version = "1", default-features = false, features = ["behavior-v
axum = "0.8"
base64 = "0.22"
clap = { version = "4.6", features = ["derive"] }
hex = "0.4"
hmac = "0.12"
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
rsa = { version = "0.9", features = ["sha2"] }
serde = { version = "1.0", features = ["derive"] }
Expand Down
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,49 @@ curl -X GET \
http://localhost:8080/proxy/deployments/repos/github_user/repo_name/contents/README.md
```

## Webhooks

idcat 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:

```text
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:

```toml
[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](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries))
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.

```toml
[[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"
```

## Running

```sh
Expand Down
14 changes: 12 additions & 2 deletions idcat.toml.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
bind-address = "0.0.0.0:8080"
key-source = "local"
private-key-directory = "/var/run/secrets/idcat"
# Optional: publish GitHub webhook callbacks to NATS.
# webhook-target = "nats"
# Optional: shared NATS connection used by any github-app that opts into
# publishing its webhook callbacks (see webhook-target below). The connection
# block is defined once here; each github-app decides whether to use it.
#
# [nats]
# endpoint = "nats://nats.example.com:4222"
Expand Down Expand Up @@ -44,6 +45,15 @@ name = "deployments"
app-id = 123456
secret-key = "deployments-private-key.pem"
allowed-roles = ["buildkite-deploy-idcat", "kubernetes-default"]
# Optional: publish this app's webhook callbacks to the shared [nats] connection.
# GitHub should be configured to POST deliveries to /webhook/deployments.
# webhook-target = "nats"
# Optional: validate incoming webhook deliveries using the shared secret
# configured on the GitHub App. The value is a path to a file containing that
# secret; deliveries whose X-Hub-Signature-256 header does not match are
# rejected. See
# https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries
# webhook-validation-secret-file = "/var/run/secrets/idcat/deployments-webhook-secret"

[[github-app]]
name = "release-bot"
Expand Down
80 changes: 68 additions & 12 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ pub struct Config {
pub key_source: KeySource,
#[serde(default = "default_private_key_directory")]
pub private_key_directory: String,
pub webhook_target: Option<WebhookTarget>,
pub nats: Option<NatsConfig>,
#[serde(rename = "role", default)]
pub roles: Vec<authzoo::RoleConfig>,
Expand Down Expand Up @@ -73,6 +72,8 @@ pub struct GithubAppConfig {
pub name: String,
pub app_id: u64,
pub secret_key: String,
pub webhook_target: Option<WebhookTarget>,
pub webhook_validation_secret_file: Option<String>,
#[serde(default)]
pub allowed_roles: Vec<String>,
}
Expand Down Expand Up @@ -159,7 +160,11 @@ impl Config {
if self.key_source == KeySource::Kms && !cfg!(feature = "kms") {
anyhow::bail!("key-source 'kms' requires idcat to be built with the 'kms' feature");
}
if matches!(self.webhook_target, Some(WebhookTarget::Nats)) && self.nats.is_none() {
let any_nats_webhook_target = self
.github_apps
.iter()
.any(|github_app| matches!(github_app.webhook_target, Some(WebhookTarget::Nats)));
if any_nats_webhook_target && self.nats.is_none() {
anyhow::bail!("webhook-target 'nats' requires a [nats] config block");
}
if let Some(nats) = &self.nats {
Expand All @@ -175,9 +180,9 @@ impl Config {
if matches!(nats.token_path.as_deref(), Some("")) {
anyhow::bail!("nats token-path must not be empty when set");
}
if self.webhook_target.is_none() {
if !any_nats_webhook_target {
warn!(
"nats config is present but webhook-target is not set; nats will not be used"
"nats config is present but no github-app sets webhook-target = \"nats\"; nats will not be used"
);
}
}
Expand Down Expand Up @@ -215,6 +220,15 @@ impl Config {
github_app.name
);
}
if matches!(
github_app.webhook_validation_secret_file.as_deref(),
Some("")
) {
anyhow::bail!(
"github-app '{}' webhook-validation-secret-file must not be empty when set",
github_app.name
);
}
if !github_apps.insert(github_app.name.clone()) {
anyhow::bail!("duplicate github-app '{}'", github_app.name);
}
Expand Down Expand Up @@ -911,16 +925,14 @@ sub = "system:serviceaccount:idelephant:default"
assert_eq!(config.bind_address, "0.0.0.0:8080");
assert_eq!(config.key_source, KeySource::Local);
assert_eq!(config.private_key_directory, "/var/run/secrets/idcat");
assert_eq!(config.webhook_target, None);
assert_eq!(config.github_apps[0].webhook_target, None);
assert_eq!(config.nats, None);
}

#[test]
fn parses_nats_webhook_target_config() {
let config: Config = toml::from_str(
r#"
webhook-target = "nats"

[nats]
endpoint = "nats://nats.example.com:4222"
subject-base = "idcat.github.webhook"
Expand All @@ -930,12 +942,16 @@ token-path = "/var/run/secrets/idcat/nats-token"
name = "default"
app-id = 42
secret-key = "private-key.pem"
webhook-target = "nats"
"#,
)
.unwrap();

config.validate(true).unwrap();
assert_eq!(config.webhook_target, Some(WebhookTarget::Nats));
assert_eq!(
config.github_apps[0].webhook_target,
Some(WebhookTarget::Nats)
);
let nats = config.nats.as_ref().unwrap();
assert_eq!(nats.endpoint, "nats://nats.example.com:4222");
assert_eq!(nats.subject_base, "idcat.github.webhook");
Expand All @@ -949,12 +965,11 @@ secret-key = "private-key.pem"
fn rejects_nats_webhook_target_without_nats_config() {
let config: Config = toml::from_str(
r#"
webhook-target = "nats"

[[github-app]]
name = "default"
app-id = 42
secret-key = "private-key.pem"
webhook-target = "nats"
"#,
)
.unwrap();
Expand All @@ -970,8 +985,6 @@ secret-key = "private-key.pem"
fn rejects_empty_nats_token_path() {
let config: Config = toml::from_str(
r#"
webhook-target = "nats"

[nats]
endpoint = "nats://nats.example.com:4222"
subject-base = "idcat.github.webhook"
Expand All @@ -981,6 +994,7 @@ token-path = ""
name = "default"
app-id = 42
secret-key = "private-key.pem"
webhook-target = "nats"
"#,
)
.unwrap();
Expand All @@ -989,6 +1003,48 @@ secret-key = "private-key.pem"
assert_eq!(error, "nats token-path must not be empty when set");
}

#[test]
fn parses_webhook_validation_secret_file() {
let config: Config = toml::from_str(
r#"
[[github-app]]
name = "default"
app-id = 42
secret-key = "private-key.pem"
webhook-validation-secret-file = "/var/run/secrets/idcat/webhook-secret"
"#,
)
.unwrap();

config.validate(true).unwrap();
assert_eq!(
config.github_apps[0]
.webhook_validation_secret_file
.as_deref(),
Some("/var/run/secrets/idcat/webhook-secret")
);
}

#[test]
fn rejects_empty_webhook_validation_secret_file() {
let config: Config = toml::from_str(
r#"
[[github-app]]
name = "default"
app-id = 42
secret-key = "private-key.pem"
webhook-validation-secret-file = ""
"#,
)
.unwrap();

let error = config.validate(true).unwrap_err().to_string();
assert_eq!(
error,
"github-app 'default' webhook-validation-secret-file must not be empty when set"
);
}

#[test]
#[cfg(feature = "kms")]
fn accepts_kms_key_source_when_kms_feature_is_enabled() {
Expand Down
2 changes: 2 additions & 0 deletions src/github.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ mod tests {
name: "default".to_string(),
app_id: 42,
secret_key: "private-key.pem".to_string(),
webhook_target: None,
webhook_validation_secret_file: None,
allowed_roles: Vec::new(),
}
}
Expand Down
Loading