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: 1 addition & 1 deletion .github/scripts/next-version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# next-version.sh — compute THIS repo's next release tag for release-on-upstream.yml.
#
# Single source of truth for the version math, exercised in CI by release-selftest.yml so the
# release automation can't silently rot (guard #135.8). Prints "v<MAJOR>.<MINOR>.<PATCH>" to stdout.
# release automation can't silently rot. Prints "v<MAJOR>.<MINOR>.<PATCH>" to stdout.
#
# Inputs (env, all optional):
# INPUT_VERSION explicit version to cut (leading "v" tolerated) -> used verbatim.
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,9 @@ jobs:
plugin_crate: busbar-auth-oidc-plugin
plugin_kind: auth
plugin_alias: oidc
busbar_ref: ${{ github.ref_name }} # same-branch: qa builds core qa, dev builds core dev (no stale main/tag)
busbar_ref: ${{ github.base_ref || github.ref_name }}
# same-branch: qa builds core qa, dev builds core dev (no stale main/tag). base_ref FIRST:
# on a pull_request `github.ref_name` is '<number>/merge', not a branch name, so core could
# not resolve it and every PR silently fell back to busbar@dev instead of the branch this PR
# actually targets.
service: none
2 changes: 1 addition & 1 deletion .github/workflows/release-selftest.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# CI self-test for the release-on-upstream version-compute logic (guard #135.8).
# CI self-test for the release-on-upstream version-compute logic.
# Runs the REAL .github/scripts/next-version.sh against synthetic repos and asserts it produces a
# valid next version for BOTH the has-prior-tag and no-prior-tag cases — WITHOUT publishing anything.
# This is what keeps the release automation from silently rotting before the fleet fan-out is armed.
Expand Down
37 changes: 25 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ explicitly in production; do not assume they move together.

## What it is for

- **Verifying who's calling**: add `oidc` to `auth.chain` with its
`settings:` pointed at an IdP (Entra ID, Okta, Auth0, Keycloak, or any
standards-compliant OIDC provider) — `auth: { chain: [{ oidc: {
settings: {...} } }] }`. Every request's bearer token is verified
- **Verifying who's calling**: define the provider once under
`identity-providers:` with its `settings:` pointed at an IdP (Entra ID,
Okta, Auth0, Keycloak, or any standards-compliant OIDC provider), then
reference it by bare name from `auth.chain` — `identity-providers: {
oidc: { module: oidc, settings: {...} } }` + `auth: { chain: [oidc] }`.
Every request's bearer token is verified
against the IdP's live JWKS — the plugin never trusts an unsigned
claim.
- **Mapping claims to busbar identity**: the configured role claim (e.g.
Expand Down Expand Up @@ -128,17 +130,28 @@ Drop the resulting tarball into busbar's configured `plugins.dir` and
set:

```yaml
identity-providers: # define each IdP ONCE (busbar 1.5.3)
oidc:
module: oidc # this plugin, by its packed alias
settings:
issuer: "https://login.microsoftonline.com/<tenant-id>/v2.0"
audience: "<client-id>"
role_claim: groups
# max_admin_scope: read-only | full — OMITTED means read-only, the
# most restrictive ceiling, which is what an external IdP wants.
auth:
chain:
- oidc:
settings:
issuer: "https://login.microsoftonline.com/<tenant-id>/v2.0"
audience: "<client-id>"
role_claim: groups
chain: [oidc] # reference the provider by BARE NAME
role_bindings: # role → policy, nested by provider name
oidc:
"<idp-group>": { group: engineering }
```

— see [`docs/configuration.md`](https://github.com/GetBusbar/busbar/blob/main/docs/configuration.md#auth-plugins)
for the full `auth.chain` config reference.
busbar 1.5.3 retired the inline form (`auth.chain: [{ oidc: { settings:
… } }]`, `auth.methods:`, `auth.modules:`) and refuses to boot on a
config that still uses it; `busbar --migrate-config config.yaml` rewrites
an older config into the shape above. See
[`docs/configuration.md`](https://github.com/GetBusbar/busbar/blob/main/docs/configuration.md#auth-plugins)
for the full `identity-providers:` / `auth.chain` config reference.

## Config

Expand Down
5 changes: 3 additions & 2 deletions auth-oidc-plugin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@

//! The **OIDC auth module as a droppable busbar plugin** — a `cdylib` that exports the auth C ABI
//! ([`busbar_plugin_abi::auth`]). Build it, drop the resulting `.so`/`.dll`/`.dylib` into the engine's
//! plugins folder, add `oidc` to `auth.chain`, and configure `auth.modules.oidc.config`; the engine
//! loads it in-process at boot over the auth ABI.
//! plugins folder, define it once under `identity-providers:` (`module: oidc` plus its `settings:`),
//! and reference that name from `auth.chain`; the engine loads it in-process at boot over the auth
//! ABI.
//!
//! All the OIDC logic (JWKS, JWT verification on `ring`, claim policy) lives in the `busbar-auth-oidc`
//! `lib` crate (which a custom build can also link statically). Here we only adapt the engine's JSON
Expand Down
129 changes: 95 additions & 34 deletions auth-oidc-plugin/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ use busbar_plugin_loader::{auth::load_auth_from_bytes, plugin_library_filename};
/// Checks BOTH the "uplifted" `<profile_dir>/<name>` copy (only refreshed when `[lib]` is a ROOT
/// build target, e.g. `cargo build --all-targets`) and the raw `<profile_dir>/deps/<name>` compiler
/// output (refreshed on every build that recompiles the lib). A bare `cargo test --release` (what
/// `release-check.sh`'s Phase 4 runs, and what cargo-mutants runs) does NOT uplift the cdylib to
/// the top-level profile dir, only to `target/deps` checking only `profile_dir` silently finds
/// nothing even though the cdylib really was built. Same fix already applied to
/// `release-check.sh`'s Phase 4 runs) does NOT uplift the cdylib to the top-level profile dir, only
/// to `target/deps` - checking only `profile_dir` silently finds nothing even though the cdylib
/// really was built. Same fix already applied to
/// store-postgres-plugin's and webrequest-hook's equivalent `plugin_path()` helpers.
fn plugin_path() -> Option<std::path::PathBuf> {
let candidate = (|| {
Expand Down Expand Up @@ -320,16 +320,79 @@ fn free_port() -> u16 {
.port()
}

/// A spawned `busbar` whose stdout and stderr are CAPTURED rather than discarded.
///
/// The previous harness spawned every boot with `Stdio::null()`, so when busbar refused to boot the
/// only evidence that reached the test log was `exit status: 1` — the actual reason (a config error
/// printed on stderr milliseconds before exit) was thrown on the floor, turning a one-line diagnosis
/// into an hour of guesswork. Both streams are drained on background threads instead of read at
/// panic time because a pipe holds only a page or two: leaving it unread would block the child once
/// it filled, deadlocking the very boot the test is waiting on.
struct CapturedChild {
child: std::process::Child,
output: std::sync::Arc<std::sync::Mutex<String>>,
}

impl CapturedChild {
/// Spawn `cmd` with both streams piped and drained. `what` names the boot in the panic message
/// raised when the spawn itself fails.
fn spawn(cmd: &mut std::process::Command, what: &str) -> Self {
let mut child = cmd
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap_or_else(|e| panic!("spawn {what}: {e}"));
let output = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let streams: [Box<dyn std::io::Read + Send>; 2] = [
Box::new(child.stdout.take().expect("stdout was piped")),
Box::new(child.stderr.take().expect("stderr was piped")),
];
// Interleaving both streams into one buffer matches how an operator reads a terminal, and
// ordering between them is not something any assertion here depends on.
for stream in streams {
let output = std::sync::Arc::clone(&output);
std::thread::spawn(move || {
use std::io::BufRead;
let mut reader = std::io::BufReader::new(stream);
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
output.lock().expect("output buffer").push_str(&line);
line.clear();
}
});
}
Self { child, output }
}

/// Everything busbar has printed so far, for embedding in a failure message.
fn output(&self) -> String {
self.output.lock().expect("output buffer").clone()
}

fn kill(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}

/// Poll the admin API's `GET /api/v1/admin/plugins` until it answers (or the child exits early).
/// Either failure mode reports what busbar actually said — see `CapturedChild`'s doc comment.
fn wait_for_admin_ready(
client: &reqwest::blocking::Client,
admin_addr: &str,
admin_token: &str,
child: &mut std::process::Child,
child: &mut CapturedChild,
) -> bool {
for _ in 0..150 {
if let Ok(Some(status)) = child.try_wait() {
panic!("busbar exited early during admin-readiness poll: {status}");
if let Ok(Some(status)) = child.child.try_wait() {
// The drain threads may still be flushing the final lines the process wrote on its way
// out; a short settle beats reporting an empty reason for an early exit.
std::thread::sleep(std::time::Duration::from_millis(100));
panic!(
"busbar exited early during admin-readiness poll: {status}\n\
---- busbar stdout+stderr ----\n{}\n------------------------------",
child.output()
);
}
if client
.get(format!(
Expand All @@ -346,7 +409,7 @@ fn wait_for_admin_ready(
false
}

/// THE REAL END-TO-END PROOF Matthew asked for by name: "we called oidc... for EVERY plugin." Not a
/// THE REAL END-TO-END PROOF that oidc is exercised the way an operator exercises it. Not a
/// direct ABI `load_auth_from_bytes` call (the tests above already cover that seam) and not a
/// file-drop — an operator installing a NEW auth plugin onto a LIVE gateway does it over the real
/// Admin API (`POST /api/v1/admin/plugins`), then the auth chain picks it up on the next boot (auth
Expand Down Expand Up @@ -416,19 +479,19 @@ fn install_oidc_plugin_via_admin_api_and_authenticate() {
.unwrap();
let admin_addr1 = format!("127.0.0.1:{admin_port1}");

let mut child1 = std::process::Command::new(&busbar_bin)
.env("BUSBAR_CONFIG", &config1)
.env("BUSBAR_PROVIDERS", &providers)
.env("BUSBAR_ADMIN_TOKEN", ADMIN_TOKEN)
.env("MOCK_KEY", "unused-mock-provider-key")
.env("BUSBAR_STATE_FILE", "")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn boot 1 (empty auth chain, admin listener up)");
let mut child1 = CapturedChild::spawn(
std::process::Command::new(&busbar_bin)
.env("BUSBAR_CONFIG", &config1)
.env("BUSBAR_PROVIDERS", &providers)
.env("BUSBAR_ADMIN_TOKEN", ADMIN_TOKEN)
.env("MOCK_KEY", "unused-mock-provider-key")
.env("BUSBAR_STATE_FILE", ""),
"boot 1 (empty auth chain, admin listener up)",
);
assert!(
wait_for_admin_ready(&client, &admin_addr1, ADMIN_TOKEN, &mut child1),
"boot 1's admin API must become ready within 15s"
"boot 1's admin API must become ready within 15s:\n{}",
child1.output()
);

// ── REAL ADMIN-API INSTALL: POST the packed auth-oidc plugin tarball to /api/v1/admin/plugins. ──
Expand Down Expand Up @@ -476,8 +539,7 @@ fn install_oidc_plugin_via_admin_api_and_authenticate() {
.expect("the just-installed auth-oidc plugin appears in the auth catalog");
assert_eq!(listed["valid"], true);

let _ = child1.kill();
let _ = child1.wait();
child1.kill();

// ── BOOT 2: auth.chain: [oidc], over the SAME plugins dir the admin API wrote into above.
// Restart-to-apply, mirroring store's own documented mechanism. ──
Expand All @@ -500,7 +562,7 @@ fn install_oidc_plugin_via_admin_api_and_authenticate() {
plugins_dir.display(),
cert_pem
.lines()
.map(|l| format!(" {l}"))
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n"),
),
Expand All @@ -509,20 +571,20 @@ fn install_oidc_plugin_via_admin_api_and_authenticate() {
let admin_addr2 = format!("127.0.0.1:{admin_port2}");
let data_addr2 = format!("127.0.0.1:{data_port2}");

let mut child2 = std::process::Command::new(&busbar_bin)
.env("BUSBAR_CONFIG", &config2)
.env("BUSBAR_PROVIDERS", &providers)
.env("BUSBAR_ADMIN_TOKEN", ADMIN_TOKEN)
.env("MOCK_KEY", "unused-mock-provider-key")
.env("BUSBAR_STATE_FILE", "")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawn boot 2 (auth.chain: [oidc], picking up the admin-API-installed tarball)");
let mut child2 = CapturedChild::spawn(
std::process::Command::new(&busbar_bin)
.env("BUSBAR_CONFIG", &config2)
.env("BUSBAR_PROVIDERS", &providers)
.env("BUSBAR_ADMIN_TOKEN", ADMIN_TOKEN)
.env("MOCK_KEY", "unused-mock-provider-key")
.env("BUSBAR_STATE_FILE", ""),
"boot 2 (auth.chain: [oidc], picking up the admin-API-installed tarball)",
);
assert!(
wait_for_admin_ready(&client, &admin_addr2, ADMIN_TOKEN, &mut child2),
"boot 2's admin API must become ready within 15s (proves the oidc plugin loaded, not just \
that the process is alive, since a load failure is a boot-time die())"
that the process is alive, since a load failure is a boot-time die()):\n{}",
child2.output()
);

// ── THE REAL CALL: a genuine signed bearer JWT through the live data plane. ──
Expand Down Expand Up @@ -575,8 +637,7 @@ fn install_oidc_plugin_via_admin_api_and_authenticate() {
"a token signed by the wrong key must be rejected by the live installed plugin"
);

let _ = child2.kill();
let _ = child2.wait();
child2.kill();
let _ = std::fs::remove_dir_all(&work);
}

Expand Down
10 changes: 5 additions & 5 deletions auth-oidc/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
//! Connect JWT (ID or access token) a caller presents as its bearer credential and maps it to a
//! [`busbar_api::Principal`]: verify the signature against the provider's JWKS, check `iss`/`aud`/
//! `exp`/`nbf`, and read the configured role claim (`groups` by default, or `roles` for Entra
//! app-roles) into the principal's ROLES. busbar's own `group_map:` / `auth.modules.oidc:` config
//! then resolves those roles to governance grants and admin scope — the module asserts identity
//! only, never policy.
//! app-roles) into the principal's ROLES. busbar's own `auth.role_bindings.<provider>:` config then
//! resolves those roles to governance grants and admin scope — the module asserts identity only,
//! never policy.
//!
//! This crate is the reusable LOGIC (usable statically). The dynamic `cdylib` that exports the auth C
//! ABI is the sibling `busbar-auth-oidc-plugin` crate.
Expand Down Expand Up @@ -73,8 +73,8 @@ const MAX_CACHE_TTL_SECS: i64 = 300;
/// fine; it only needs to stay behind "now".
const CLOCK_SANITY_FLOOR_UNIX: i64 = 1_767_225_600; // 2026-01-01T00:00:00Z

/// The operator's `auth.modules.oidc.config` settings, deserialized from the JSON the engine passes to
/// the plugin's `open`.
/// The operator's `identity-providers.<name>.settings:` block, deserialized from the JSON the engine
/// passes to the plugin's `open`.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OidcConfig {
Expand Down
Loading