Compare commits
5 Commits
feature/ra
...
feature/op
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b234443324 | ||
|
|
fe57e9e9bc | ||
|
|
c61497420a | ||
| 0cb06d3d6d | |||
| 42cabf0582 |
11
.env.example
11
.env.example
@@ -37,3 +37,14 @@ GIT_CLONE_BASE_PATH=/tmp/compliance-scanner/repos
|
|||||||
# Dashboard
|
# Dashboard
|
||||||
DASHBOARD_PORT=8080
|
DASHBOARD_PORT=8080
|
||||||
AGENT_API_URL=http://localhost:3001
|
AGENT_API_URL=http://localhost:3001
|
||||||
|
|
||||||
|
# Keycloak (required for authentication)
|
||||||
|
KEYCLOAK_URL=http://localhost:8080
|
||||||
|
KEYCLOAK_REALM=compliance
|
||||||
|
KEYCLOAK_CLIENT_ID=compliance-dashboard
|
||||||
|
REDIRECT_URI=http://localhost:8080/auth/callback
|
||||||
|
APP_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# OpenTelemetry (optional - omit to disable)
|
||||||
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||||
|
# OTEL_SERVICE_NAME=compliance-agent
|
||||||
|
|||||||
284
Cargo.lock
generated
284
Cargo.lock
generated
@@ -167,7 +167,7 @@ dependencies = [
|
|||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.28.0",
|
"tokio-tungstenite 0.28.0",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -555,6 +555,7 @@ dependencies = [
|
|||||||
"git2",
|
"git2",
|
||||||
"hex",
|
"hex",
|
||||||
"hmac",
|
"hmac",
|
||||||
|
"jsonwebtoken",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
"octocrab",
|
"octocrab",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -582,11 +583,18 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-appender-tracing",
|
||||||
|
"opentelemetry-otlp",
|
||||||
|
"opentelemetry_sdk",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
"tracing-opentelemetry",
|
||||||
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -595,6 +603,7 @@ name = "compliance-dashboard"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"compliance-core",
|
"compliance-core",
|
||||||
"dioxus",
|
"dioxus",
|
||||||
@@ -605,14 +614,19 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"gloo-timers",
|
"gloo-timers",
|
||||||
"mongodb",
|
"mongodb",
|
||||||
|
"rand 0.9.2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"secrecy",
|
"secrecy",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
|
"tower-sessions",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"url",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -792,7 +806,12 @@ version = "0.18.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64",
|
||||||
|
"hmac",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
"time",
|
"time",
|
||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
@@ -1316,7 +1335,7 @@ dependencies = [
|
|||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tokio-tungstenite 0.27.0",
|
"tokio-tungstenite 0.27.0",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1607,7 +1626,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.27.0",
|
"tokio-tungstenite 0.27.0",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-futures",
|
"tracing-futures",
|
||||||
@@ -2104,6 +2123,12 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "glob"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "gloo-net"
|
name = "gloo-net"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -3472,7 +3497,7 @@ dependencies = [
|
|||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"snafu",
|
"snafu",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -3519,6 +3544,98 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e87237e2775f74896f9ad219d26a2081751187eb7c9f5c58dde20a23b95d16c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"js-sys",
|
||||||
|
"pin-project-lite",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-appender-tracing"
|
||||||
|
version = "0.29.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e716f864eb23007bdd9dc4aec381e188a1cee28eecf22066772b5fd822b9727d"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-subscriber",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-http"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46d7ab32b827b5b495bd90fa95a6cb65ccc293555dcc3199ae2937d2d237c8ed"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"opentelemetry",
|
||||||
|
"reqwest",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-otlp"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d899720fe06916ccba71c01d04ecd77312734e2de3467fd30d9d580c8ce85656"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"http",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry-http",
|
||||||
|
"opentelemetry-proto",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"reqwest",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tonic",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-proto"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c40da242381435e18570d5b9d50aca2a4f4f4d8e146231adb4e7768023309b3"
|
||||||
|
dependencies = [
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"prost",
|
||||||
|
"tonic",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry_sdk"
|
||||||
|
version = "0.29.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "afdefb21d1d47394abc1ba6c57363ab141be19e27cc70d0e422b7f303e4d290b"
|
||||||
|
dependencies = [
|
||||||
|
"futures-channel",
|
||||||
|
"futures-executor",
|
||||||
|
"futures-util",
|
||||||
|
"glob",
|
||||||
|
"opentelemetry",
|
||||||
|
"percent-encoding",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ownedbytes"
|
name = "ownedbytes"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -3752,6 +3869,29 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"prost-derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prost-derive"
|
||||||
|
version = "0.13.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"itertools",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psl-types"
|
name = "psl-types"
|
||||||
version = "2.0.11"
|
version = "2.0.11"
|
||||||
@@ -4007,6 +4147,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"cookie",
|
"cookie",
|
||||||
"cookie_store",
|
"cookie_store",
|
||||||
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"http",
|
"http",
|
||||||
@@ -4030,7 +4171,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"url",
|
"url",
|
||||||
@@ -5211,6 +5352,52 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tonic"
|
||||||
|
version = "0.12.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"base64",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-timeout",
|
||||||
|
"hyper-util",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project",
|
||||||
|
"prost",
|
||||||
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
|
"tower 0.4.13",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower"
|
||||||
|
version = "0.4.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"indexmap 1.9.3",
|
||||||
|
"pin-project",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -5228,6 +5415,22 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-cookies"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
|
||||||
|
dependencies = [
|
||||||
|
"axum-core",
|
||||||
|
"cookie",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"parking_lot",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.8"
|
version = "0.6.8"
|
||||||
@@ -5250,7 +5453,7 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower 0.5.3",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5268,6 +5471,57 @@ version = "0.3.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"http",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tower-cookies",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
"tower-sessions-core",
|
||||||
|
"tower-sessions-memory-store",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions-core"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum-core",
|
||||||
|
"base64",
|
||||||
|
"futures",
|
||||||
|
"http",
|
||||||
|
"parking_lot",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-sessions-memory-store"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tower-sessions-core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
@@ -5322,6 +5576,24 @@ dependencies = [
|
|||||||
"tracing-core",
|
"tracing-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-opentelemetry"
|
||||||
|
version = "0.30.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd8e764bd6f5813fd8bebc3117875190c5b0415be8f7f8059bffb6ecd979c444"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"once_cell",
|
||||||
|
"opentelemetry",
|
||||||
|
"opentelemetry_sdk",
|
||||||
|
"smallvec",
|
||||||
|
"tracing",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing-subscriber"
|
name = "tracing-subscriber"
|
||||||
version = "0.3.22"
|
version = "0.3.22"
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
#[allow(clippy::expect_used)]
|
#[allow(clippy::expect_used)]
|
||||||
fn main() {
|
fn main() {
|
||||||
dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger");
|
|
||||||
|
|
||||||
#[cfg(feature = "web")]
|
#[cfg(feature = "web")]
|
||||||
{
|
{
|
||||||
|
dioxus_logger::init(tracing::Level::DEBUG).expect("Failed to init logger");
|
||||||
dioxus::web::launch::launch_cfg(
|
dioxus::web::launch::launch_cfg(
|
||||||
compliance_dashboard::App,
|
compliance_dashboard::App,
|
||||||
dioxus::web::Config::new().hydrate(true),
|
dioxus::web::Config::new().hydrate(true),
|
||||||
@@ -14,6 +13,9 @@ fn main() {
|
|||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
{
|
{
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
let _telemetry_guard = compliance_core::telemetry::init_telemetry("compliance-dashboard");
|
||||||
|
|
||||||
compliance_dashboard::infrastructure::server_start(compliance_dashboard::App)
|
compliance_dashboard::infrastructure::server_start(compliance_dashboard::App)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
tracing::error!("Unable to start server: {e}");
|
tracing::error!("Unable to start server: {e}");
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ edition = "2021"
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
compliance-core = { workspace = true, features = ["mongodb"] }
|
compliance-core = { workspace = true, features = ["mongodb", "telemetry"] }
|
||||||
compliance-graph = { path = "../compliance-graph" }
|
compliance-graph = { path = "../compliance-graph" }
|
||||||
compliance-dast = { path = "../compliance-dast" }
|
compliance-dast = { path = "../compliance-dast" }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -35,3 +35,4 @@ walkdir = "2"
|
|||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
|||||||
113
compliance-agent/src/api/auth_middleware.rs
Normal file
113
compliance-agent/src/api/auth_middleware.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Request,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, DecodingKey, Validation};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
/// Cached JWKS from Keycloak for token validation.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JwksState {
|
||||||
|
pub jwks: Arc<RwLock<Option<JwkSet>>>,
|
||||||
|
pub jwks_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
sub: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUBLIC_ENDPOINTS: &[&str] = &["/api/v1/health"];
|
||||||
|
|
||||||
|
/// Middleware that validates Bearer JWT tokens against Keycloak's JWKS.
|
||||||
|
///
|
||||||
|
/// Skips validation for health check endpoints.
|
||||||
|
/// If `JwksState` is not present as an extension (keycloak not configured),
|
||||||
|
/// all requests pass through.
|
||||||
|
pub async fn require_jwt_auth(request: Request, next: Next) -> Response {
|
||||||
|
let path = request.uri().path();
|
||||||
|
|
||||||
|
if PUBLIC_ENDPOINTS.contains(&path) {
|
||||||
|
return next.run(request).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let jwks_state = match request.extensions().get::<JwksState>() {
|
||||||
|
Some(s) => s.clone(),
|
||||||
|
None => return next.run(request).await,
|
||||||
|
};
|
||||||
|
|
||||||
|
let auth_header = match request.headers().get("authorization") {
|
||||||
|
Some(h) => h,
|
||||||
|
None => return (StatusCode::UNAUTHORIZED, "Missing authorization header").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = match auth_header.to_str() {
|
||||||
|
Ok(s) if s.starts_with("Bearer ") => &s[7..],
|
||||||
|
_ => return (StatusCode::UNAUTHORIZED, "Invalid authorization header").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
match validate_token(token, &jwks_state).await {
|
||||||
|
Ok(()) => next.run(request).await,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("JWT validation failed: {e}");
|
||||||
|
(StatusCode::UNAUTHORIZED, "Invalid token").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn validate_token(token: &str, state: &JwksState) -> Result<(), String> {
|
||||||
|
let header = decode_header(token).map_err(|e| format!("failed to decode JWT header: {e}"))?;
|
||||||
|
|
||||||
|
let kid = header
|
||||||
|
.kid
|
||||||
|
.ok_or_else(|| "JWT missing kid header".to_string())?;
|
||||||
|
|
||||||
|
let jwks = fetch_or_get_jwks(state).await?;
|
||||||
|
|
||||||
|
let jwk = jwks
|
||||||
|
.keys
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.common.key_id.as_deref() == Some(&kid))
|
||||||
|
.ok_or_else(|| "no matching key found in JWKS".to_string())?;
|
||||||
|
|
||||||
|
let decoding_key =
|
||||||
|
DecodingKey::from_jwk(jwk).map_err(|e| format!("failed to create decoding key: {e}"))?;
|
||||||
|
|
||||||
|
let mut validation = Validation::new(header.alg);
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.validate_aud = false;
|
||||||
|
|
||||||
|
decode::<Claims>(token, &decoding_key, &validation)
|
||||||
|
.map_err(|e| format!("token validation failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_or_get_jwks(state: &JwksState) -> Result<JwkSet, String> {
|
||||||
|
{
|
||||||
|
let cached = state.jwks.read().await;
|
||||||
|
if let Some(ref jwks) = *cached {
|
||||||
|
return Ok(jwks.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = reqwest::get(&state.jwks_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to fetch JWKS: {e}"))?;
|
||||||
|
|
||||||
|
let jwks: JwkSet = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to parse JWKS: {e}"))?;
|
||||||
|
|
||||||
|
let mut cached = state.jwks.write().await;
|
||||||
|
*cached = Some(jwks.clone());
|
||||||
|
|
||||||
|
Ok(jwks)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod auth_middleware;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::Extension;
|
use axum::{middleware, Extension};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
use tower_http::cors::CorsLayer;
|
use tower_http::cors::CorsLayer;
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
use crate::agent::ComplianceAgent;
|
use crate::agent::ComplianceAgent;
|
||||||
|
use crate::api::auth_middleware::{require_jwt_auth, JwksState};
|
||||||
use crate::api::routes;
|
use crate::api::routes;
|
||||||
use crate::error::AgentError;
|
use crate::error::AgentError;
|
||||||
|
|
||||||
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
|
pub async fn start_api_server(agent: ComplianceAgent, port: u16) -> Result<(), AgentError> {
|
||||||
let app = routes::build_router()
|
let mut app = routes::build_router()
|
||||||
.layer(Extension(Arc::new(agent)))
|
.layer(Extension(Arc::new(agent.clone())))
|
||||||
.layer(CorsLayer::permissive())
|
.layer(CorsLayer::permissive())
|
||||||
.layer(TraceLayer::new_for_http());
|
.layer(TraceLayer::new_for_http());
|
||||||
|
|
||||||
|
if let (Some(kc_url), Some(kc_realm)) =
|
||||||
|
(&agent.config.keycloak_url, &agent.config.keycloak_realm)
|
||||||
|
{
|
||||||
|
let jwks_url = format!("{kc_url}/realms/{kc_realm}/protocol/openid-connect/certs");
|
||||||
|
let jwks_state = JwksState {
|
||||||
|
jwks: Arc::new(RwLock::new(None)),
|
||||||
|
jwks_url,
|
||||||
|
};
|
||||||
|
tracing::info!("Keycloak JWT auth enabled for realm '{kc_realm}'");
|
||||||
|
app = app
|
||||||
|
.layer(Extension(jwks_state))
|
||||||
|
.layer(middleware::from_fn(require_jwt_auth));
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Keycloak not configured - API endpoints are unprotected");
|
||||||
|
}
|
||||||
|
|
||||||
let addr = format!("0.0.0.0:{port}");
|
let addr = format!("0.0.0.0:{port}");
|
||||||
let listener = tokio::net::TcpListener::bind(&addr)
|
let listener = tokio::net::TcpListener::bind(&addr)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -45,5 +45,7 @@ pub fn load_config() -> Result<AgentConfig, AgentError> {
|
|||||||
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
|
.unwrap_or_else(|| "0 0 0 * * *".to_string()),
|
||||||
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
git_clone_base_path: env_var_opt("GIT_CLONE_BASE_PATH")
|
||||||
.unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()),
|
.unwrap_or_else(|| "/tmp/compliance-scanner/repos".to_string()),
|
||||||
|
keycloak_url: env_var_opt("KEYCLOAK_URL"),
|
||||||
|
keycloak_realm: env_var_opt("KEYCLOAK_REALM"),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
use tracing_subscriber::EnvFilter;
|
|
||||||
|
|
||||||
mod agent;
|
mod agent;
|
||||||
mod api;
|
mod api;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -15,14 +13,10 @@ mod webhooks;
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
tracing_subscriber::fmt()
|
|
||||||
.with_env_filter(
|
|
||||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
|
||||||
)
|
|
||||||
.init();
|
|
||||||
|
|
||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let _telemetry_guard = compliance_core::telemetry::init_telemetry("compliance-agent");
|
||||||
|
|
||||||
tracing::info!("Loading configuration...");
|
tracing::info!("Loading configuration...");
|
||||||
let config = config::load_config()?;
|
let config = config::load_config()?;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ workspace = true
|
|||||||
[features]
|
[features]
|
||||||
default = ["mongodb"]
|
default = ["mongodb"]
|
||||||
mongodb = ["dep:mongodb"]
|
mongodb = ["dep:mongodb"]
|
||||||
|
telemetry = [
|
||||||
|
"dep:opentelemetry",
|
||||||
|
"dep:opentelemetry_sdk",
|
||||||
|
"dep:opentelemetry-otlp",
|
||||||
|
"dep:opentelemetry-appender-tracing",
|
||||||
|
"dep:tracing-opentelemetry",
|
||||||
|
"dep:tracing-subscriber",
|
||||||
|
"dep:tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
@@ -21,3 +30,10 @@ uuid = { workspace = true }
|
|||||||
secrecy = { workspace = true }
|
secrecy = { workspace = true }
|
||||||
bson = { version = "2", features = ["chrono-0_4"] }
|
bson = { version = "2", features = ["chrono-0_4"] }
|
||||||
mongodb = { workspace = true, optional = true }
|
mongodb = { workspace = true, optional = true }
|
||||||
|
opentelemetry = { version = "0.29", optional = true }
|
||||||
|
opentelemetry_sdk = { version = "0.29", features = ["rt-tokio"], optional = true }
|
||||||
|
opentelemetry-otlp = { version = "0.29", features = ["grpc-tonic"], optional = true }
|
||||||
|
opentelemetry-appender-tracing = { version = "0.29", optional = true }
|
||||||
|
tracing-opentelemetry = { version = "0.30", optional = true }
|
||||||
|
tracing-subscriber = { workspace = true, optional = true }
|
||||||
|
tracing = { workspace = true, optional = true }
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ pub struct AgentConfig {
|
|||||||
pub scan_schedule: String,
|
pub scan_schedule: String,
|
||||||
pub cve_monitor_schedule: String,
|
pub cve_monitor_schedule: String,
|
||||||
pub git_clone_base_path: String,
|
pub git_clone_base_path: String,
|
||||||
|
pub keycloak_url: Option<String>,
|
||||||
|
pub keycloak_realm: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
#[cfg(feature = "telemetry")]
|
||||||
|
pub mod telemetry;
|
||||||
pub mod traits;
|
pub mod traits;
|
||||||
|
|
||||||
pub use config::{AgentConfig, DashboardConfig};
|
pub use config::{AgentConfig, DashboardConfig};
|
||||||
|
|||||||
14
compliance-core/src/models/auth.rs
Normal file
14
compliance-core/src/models/auth.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Authentication state returned by the `check_auth` server function.
|
||||||
|
///
|
||||||
|
/// When no valid session exists, `authenticated` is `false` and all
|
||||||
|
/// other fields are empty strings.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||||
|
pub struct AuthInfo {
|
||||||
|
pub authenticated: bool,
|
||||||
|
pub sub: String,
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: String,
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod auth;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod cve;
|
pub mod cve;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
@@ -9,6 +10,7 @@ pub mod repository;
|
|||||||
pub mod sbom;
|
pub mod sbom;
|
||||||
pub mod scan;
|
pub mod scan;
|
||||||
|
|
||||||
|
pub use auth::AuthInfo;
|
||||||
pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference};
|
pub use chat::{ChatMessage, ChatRequest, ChatResponse, SourceReference};
|
||||||
pub use cve::{CveAlert, CveSource};
|
pub use cve::{CveAlert, CveSource};
|
||||||
pub use dast::{
|
pub use dast::{
|
||||||
|
|||||||
150
compliance-core/src/telemetry.rs
Normal file
150
compliance-core/src/telemetry.rs
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
//! OpenTelemetry initialization for traces and logs.
|
||||||
|
//!
|
||||||
|
//! Exports traces and logs via OTLP (gRPC) when `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||||
|
//! is set. Always includes a `tracing_subscriber::fmt` layer for console output.
|
||||||
|
//!
|
||||||
|
//! Compatible with SigNoz, Grafana Tempo/Loki, Jaeger, and any OTLP-compatible
|
||||||
|
//! collector.
|
||||||
|
//!
|
||||||
|
//! # Environment Variables
|
||||||
|
//!
|
||||||
|
//! | Variable | Description | Default |
|
||||||
|
//! |---|---|---|
|
||||||
|
//! | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP collector endpoint (e.g. `http://localhost:4317`) | *(disabled)* |
|
||||||
|
//! | `OTEL_SERVICE_NAME` | Service name for resource | `service_name` param |
|
||||||
|
//! | `RUST_LOG` / standard `EnvFilter` | Log level filter | `info` |
|
||||||
|
|
||||||
|
use opentelemetry::global;
|
||||||
|
use opentelemetry::trace::TracerProvider as _;
|
||||||
|
use opentelemetry::KeyValue;
|
||||||
|
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
||||||
|
use opentelemetry_otlp::{LogExporter, SpanExporter, WithExportConfig};
|
||||||
|
use opentelemetry_sdk::{logs::SdkLoggerProvider, trace::SdkTracerProvider, Resource};
|
||||||
|
use tracing_opentelemetry::OpenTelemetryLayer;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer as _};
|
||||||
|
|
||||||
|
/// Guard that shuts down OTel providers on drop.
|
||||||
|
///
|
||||||
|
/// Must be held for the lifetime of the application. When dropped,
|
||||||
|
/// flushes and shuts down the tracer and logger providers.
|
||||||
|
pub struct TelemetryGuard {
|
||||||
|
tracer_provider: Option<SdkTracerProvider>,
|
||||||
|
logger_provider: Option<SdkLoggerProvider>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TelemetryGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Some(tp) = self.tracer_provider.take() {
|
||||||
|
if let Err(e) = tp.shutdown() {
|
||||||
|
eprintln!("Failed to shutdown tracer provider: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(lp) = self.logger_provider.take() {
|
||||||
|
if let Err(e) = lp.shutdown() {
|
||||||
|
eprintln!("Failed to shutdown logger provider: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_resource(service_name: &str) -> Resource {
|
||||||
|
let name = std::env::var("OTEL_SERVICE_NAME").unwrap_or_else(|_| service_name.to_string());
|
||||||
|
Resource::builder()
|
||||||
|
.with_service_name(name)
|
||||||
|
.with_attributes([KeyValue::new("service.version", env!("CARGO_PKG_VERSION"))])
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize telemetry (tracing + logging).
|
||||||
|
///
|
||||||
|
/// If `OTEL_EXPORTER_OTLP_ENDPOINT` is set, traces and logs are exported
|
||||||
|
/// via OTLP/gRPC. Console fmt output is always enabled.
|
||||||
|
///
|
||||||
|
/// Returns a [`TelemetryGuard`] that must be held alive for the application
|
||||||
|
/// lifetime. Dropping it triggers a graceful shutdown of OTel providers.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// Panics if the tracing subscriber cannot be initialized (e.g. called twice).
|
||||||
|
pub fn init_telemetry(service_name: &str) -> TelemetryGuard {
|
||||||
|
let otel_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok();
|
||||||
|
|
||||||
|
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
|
||||||
|
let fmt_layer = tracing_subscriber::fmt::layer();
|
||||||
|
|
||||||
|
match otel_endpoint {
|
||||||
|
Some(ref endpoint) => {
|
||||||
|
let resource = build_resource(service_name);
|
||||||
|
|
||||||
|
// Traces
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
let span_exporter = SpanExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.build()
|
||||||
|
.expect("failed to create OTLP span exporter");
|
||||||
|
|
||||||
|
let tracer_provider = SdkTracerProvider::builder()
|
||||||
|
.with_batch_exporter(span_exporter)
|
||||||
|
.with_resource(resource.clone())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
global::set_tracer_provider(tracer_provider.clone());
|
||||||
|
let tracer = tracer_provider.tracer(service_name.to_string());
|
||||||
|
let otel_trace_layer = OpenTelemetryLayer::new(tracer);
|
||||||
|
|
||||||
|
// Logs
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
let log_exporter = LogExporter::builder()
|
||||||
|
.with_tonic()
|
||||||
|
.with_endpoint(endpoint)
|
||||||
|
.build()
|
||||||
|
.expect("failed to create OTLP log exporter");
|
||||||
|
|
||||||
|
let logger_provider = SdkLoggerProvider::builder()
|
||||||
|
.with_batch_exporter(log_exporter)
|
||||||
|
.with_resource(resource)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let otel_log_layer = OpenTelemetryTracingBridge::new(&logger_provider);
|
||||||
|
|
||||||
|
// Filter to prevent telemetry-induced-telemetry loops
|
||||||
|
let otel_filter = EnvFilter::new("info")
|
||||||
|
.add_directive("hyper=off".parse().unwrap_or_default())
|
||||||
|
.add_directive("tonic=off".parse().unwrap_or_default())
|
||||||
|
.add_directive("h2=off".parse().unwrap_or_default())
|
||||||
|
.add_directive("reqwest=off".parse().unwrap_or_default());
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt_layer)
|
||||||
|
.with(otel_trace_layer)
|
||||||
|
.with(otel_log_layer.with_filter(otel_filter))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
endpoint = endpoint.as_str(),
|
||||||
|
service = service_name,
|
||||||
|
"OpenTelemetry OTLP export enabled"
|
||||||
|
);
|
||||||
|
|
||||||
|
TelemetryGuard {
|
||||||
|
tracer_provider: Some(tracer_provider),
|
||||||
|
logger_provider: Some(logger_provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(env_filter)
|
||||||
|
.with(fmt_layer)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("OpenTelemetry disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)");
|
||||||
|
|
||||||
|
TelemetryGuard {
|
||||||
|
tracer_provider: None,
|
||||||
|
logger_provider: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ server = [
|
|||||||
"dioxus/router",
|
"dioxus/router",
|
||||||
"dioxus/fullstack",
|
"dioxus/fullstack",
|
||||||
"compliance-core/mongodb",
|
"compliance-core/mongodb",
|
||||||
|
"compliance-core/telemetry",
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
"dep:mongodb",
|
"dep:mongodb",
|
||||||
"dep:reqwest",
|
"dep:reqwest",
|
||||||
@@ -27,6 +28,12 @@ server = [
|
|||||||
"dep:dioxus-cli-config",
|
"dep:dioxus-cli-config",
|
||||||
"dep:dioxus-fullstack",
|
"dep:dioxus-fullstack",
|
||||||
"dep:tokio",
|
"dep:tokio",
|
||||||
|
"dep:tower-sessions",
|
||||||
|
"dep:time",
|
||||||
|
"dep:rand",
|
||||||
|
"dep:url",
|
||||||
|
"dep:sha2",
|
||||||
|
"dep:base64",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
@@ -54,3 +61,9 @@ dotenvy = { version = "0.15", optional = true }
|
|||||||
tokio = { workspace = true, optional = true }
|
tokio = { workspace = true, optional = true }
|
||||||
dioxus-cli-config = { version = "=0.7.3", optional = true }
|
dioxus-cli-config = { version = "=0.7.3", optional = true }
|
||||||
dioxus-fullstack = { version = "=0.7.3", optional = true }
|
dioxus-fullstack = { version = "=0.7.3", optional = true }
|
||||||
|
tower-sessions = { version = "0.15", default-features = false, features = ["axum-core", "memory-store", "signed"], optional = true }
|
||||||
|
time = { version = "0.3", default-features = false, optional = true }
|
||||||
|
rand = { version = "0.9", optional = true }
|
||||||
|
url = { version = "2", optional = true }
|
||||||
|
sha2 = { workspace = true, optional = true }
|
||||||
|
base64 = { version = "0.22", optional = true }
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ use dioxus::prelude::*;
|
|||||||
use crate::app::Route;
|
use crate::app::Route;
|
||||||
use crate::components::sidebar::Sidebar;
|
use crate::components::sidebar::Sidebar;
|
||||||
use crate::components::toast::{ToastContainer, Toasts};
|
use crate::components::toast::{ToastContainer, Toasts};
|
||||||
|
use crate::infrastructure::auth_check::check_auth;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn AppShell() -> Element {
|
pub fn AppShell() -> Element {
|
||||||
use_context_provider(Toasts::new);
|
use_context_provider(Toasts::new);
|
||||||
|
|
||||||
|
let auth = use_server_future(check_auth)?;
|
||||||
|
|
||||||
|
match auth() {
|
||||||
|
Some(Ok(info)) if info.authenticated => {
|
||||||
|
use_context_provider(|| Signal::new(info.clone()));
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "app-shell",
|
div { class: "app-shell",
|
||||||
Sidebar {}
|
Sidebar {}
|
||||||
@@ -16,4 +23,37 @@ pub fn AppShell() -> Element {
|
|||||||
ToastContainer {}
|
ToastContainer {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Some(Ok(_)) => {
|
||||||
|
rsx! { LoginPage {} }
|
||||||
|
}
|
||||||
|
Some(Err(e)) => {
|
||||||
|
tracing::error!("Auth check failed: {e}");
|
||||||
|
rsx! { LoginPage {} }
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
rsx! {
|
||||||
|
div { class: "flex items-center justify-center h-screen bg-gray-950",
|
||||||
|
p { class: "text-gray-400", "Loading..." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn LoginPage() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "flex items-center justify-center h-screen bg-gray-950",
|
||||||
|
div { class: "text-center",
|
||||||
|
h1 { class: "text-3xl font-bold text-white mb-4", "Compliance Scanner" }
|
||||||
|
p { class: "text-gray-400 mb-8", "Sign in to access the dashboard" }
|
||||||
|
a {
|
||||||
|
href: "/auth",
|
||||||
|
class: "px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-500 transition-colors font-medium",
|
||||||
|
"Sign in with Keycloak"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use compliance_core::models::auth::AuthInfo;
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use dioxus_free_icons::icons::bs_icons::*;
|
use dioxus_free_icons::icons::bs_icons::*;
|
||||||
use dioxus_free_icons::Icon;
|
use dioxus_free_icons::Icon;
|
||||||
@@ -114,8 +115,32 @@ pub fn Sidebar() -> Element {
|
|||||||
Icon { icon: BsChevronLeft, width: 14, height: 14 }
|
Icon { icon: BsChevronLeft, width: 14, height: 14 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
let auth_info = use_context::<Signal<AuthInfo>>();
|
||||||
|
let info = auth_info();
|
||||||
|
let initials = info.name.chars().next().unwrap_or('U').to_uppercase().to_string();
|
||||||
|
rsx! {
|
||||||
|
div { class: "sidebar-user",
|
||||||
|
div { class: "user-avatar",
|
||||||
|
if info.avatar_url.is_empty() {
|
||||||
|
span { class: "avatar-initials", "{initials}" }
|
||||||
|
} else {
|
||||||
|
img { src: "{info.avatar_url}", alt: "avatar", class: "avatar-img" }
|
||||||
|
}
|
||||||
|
}
|
||||||
if !collapsed() {
|
if !collapsed() {
|
||||||
div { class: "sidebar-footer", "v0.1.0" }
|
div { class: "user-info",
|
||||||
|
span { class: "user-name", "{info.name}" }
|
||||||
|
a {
|
||||||
|
href: "/logout",
|
||||||
|
class: "logout-link",
|
||||||
|
Icon { icon: BsBoxArrowRight, width: 14, height: 14 }
|
||||||
|
" Logout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
228
compliance-dashboard/src/infrastructure/auth.rs
Normal file
228
compliance-dashboard/src/infrastructure/auth.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
sync::{Arc, RwLock},
|
||||||
|
};
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::Query,
|
||||||
|
response::{IntoResponse, Redirect},
|
||||||
|
Extension,
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
error::DashboardError,
|
||||||
|
server_state::ServerState,
|
||||||
|
user_state::{User, UserStateInner},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const LOGGED_IN_USER_SESS_KEY: &str = "logged-in-user";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PendingOAuthEntry {
|
||||||
|
pub(crate) redirect_url: Option<String>,
|
||||||
|
pub(crate) code_verifier: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct PendingOAuthStore(Arc<RwLock<HashMap<String, PendingOAuthEntry>>>);
|
||||||
|
|
||||||
|
impl PendingOAuthStore {
|
||||||
|
pub(crate) fn insert(&self, state: String, entry: PendingOAuthEntry) {
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
self.0
|
||||||
|
.write()
|
||||||
|
.expect("pending oauth store lock poisoned")
|
||||||
|
.insert(state, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take(&self, state: &str) -> Option<PendingOAuthEntry> {
|
||||||
|
#[allow(clippy::expect_used)]
|
||||||
|
self.0
|
||||||
|
.write()
|
||||||
|
.expect("pending oauth store lock poisoned")
|
||||||
|
.remove(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_state() -> String {
|
||||||
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
|
bytes.iter().fold(String::with_capacity(64), |mut acc, b| {
|
||||||
|
use std::fmt::Write;
|
||||||
|
let _ = write!(acc, "{b:02x}");
|
||||||
|
acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_code_verifier() -> String {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
let bytes: [u8; 32] = rand::rng().random();
|
||||||
|
URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn derive_code_challenge(verifier: &str) -> String {
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let digest = Sha256::digest(verifier.as_bytes());
|
||||||
|
URL_SAFE_NO_PAD.encode(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn auth_login(
|
||||||
|
Extension(state): Extension<ServerState>,
|
||||||
|
Extension(pending): Extension<PendingOAuthStore>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<impl IntoResponse, DashboardError> {
|
||||||
|
let kc = state.keycloak;
|
||||||
|
let csrf_state = generate_state();
|
||||||
|
let code_verifier = generate_code_verifier();
|
||||||
|
let code_challenge = derive_code_challenge(&code_verifier);
|
||||||
|
|
||||||
|
let redirect_url = params.get("redirect_url").cloned();
|
||||||
|
pending.insert(
|
||||||
|
csrf_state.clone(),
|
||||||
|
PendingOAuthEntry {
|
||||||
|
redirect_url,
|
||||||
|
code_verifier,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut url = Url::parse(&kc.auth_endpoint())
|
||||||
|
.map_err(|e| DashboardError::Other(format!("invalid auth endpoint URL: {e}")))?;
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("client_id", &kc.client_id)
|
||||||
|
.append_pair("redirect_uri", &kc.redirect_uri)
|
||||||
|
.append_pair("response_type", "code")
|
||||||
|
.append_pair("scope", "openid profile email")
|
||||||
|
.append_pair("state", &csrf_state)
|
||||||
|
.append_pair("code_challenge", &code_challenge)
|
||||||
|
.append_pair("code_challenge_method", "S256");
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct TokenResponse {
|
||||||
|
access_token: String,
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct UserinfoResponse {
|
||||||
|
sub: String,
|
||||||
|
email: Option<String>,
|
||||||
|
preferred_username: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
picture: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn auth_callback(
|
||||||
|
session: Session,
|
||||||
|
Extension(state): Extension<ServerState>,
|
||||||
|
Extension(pending): Extension<PendingOAuthStore>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Result<impl IntoResponse, DashboardError> {
|
||||||
|
let kc = state.keycloak;
|
||||||
|
|
||||||
|
let returned_state = params
|
||||||
|
.get("state")
|
||||||
|
.ok_or_else(|| DashboardError::Other("missing state parameter".into()))?;
|
||||||
|
|
||||||
|
let entry = pending
|
||||||
|
.take(returned_state)
|
||||||
|
.ok_or_else(|| DashboardError::Other("unknown or expired oauth state".into()))?;
|
||||||
|
|
||||||
|
let code = params
|
||||||
|
.get("code")
|
||||||
|
.ok_or_else(|| DashboardError::Other("missing code parameter".into()))?;
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let token_resp = client
|
||||||
|
.post(kc.token_endpoint())
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("client_id", kc.client_id.as_str()),
|
||||||
|
("redirect_uri", kc.redirect_uri.as_str()),
|
||||||
|
("code", code),
|
||||||
|
("code_verifier", &entry.code_verifier),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("token request failed: {e}")))?;
|
||||||
|
|
||||||
|
if !token_resp.status().is_success() {
|
||||||
|
let body = token_resp.text().await.unwrap_or_default();
|
||||||
|
return Err(DashboardError::Other(format!(
|
||||||
|
"token exchange failed: {body}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens: TokenResponse = token_resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("token parse failed: {e}")))?;
|
||||||
|
|
||||||
|
let userinfo: UserinfoResponse = client
|
||||||
|
.get(kc.userinfo_endpoint())
|
||||||
|
.bearer_auth(&tokens.access_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("userinfo request failed: {e}")))?
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("userinfo parse failed: {e}")))?;
|
||||||
|
|
||||||
|
let display_name = userinfo
|
||||||
|
.name
|
||||||
|
.or(userinfo.preferred_username)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let user_state = UserStateInner {
|
||||||
|
sub: userinfo.sub,
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token.unwrap_or_default(),
|
||||||
|
user: User {
|
||||||
|
email: userinfo.email.unwrap_or_default(),
|
||||||
|
name: display_name,
|
||||||
|
avatar_url: userinfo.picture.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(LOGGED_IN_USER_SESS_KEY, user_state)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("session insert failed: {e}")))?;
|
||||||
|
|
||||||
|
let target = entry
|
||||||
|
.redirect_url
|
||||||
|
.filter(|u| !u.is_empty())
|
||||||
|
.unwrap_or_else(|| "/".into());
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(&target))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[axum::debug_handler]
|
||||||
|
pub async fn logout(
|
||||||
|
session: Session,
|
||||||
|
Extension(state): Extension<ServerState>,
|
||||||
|
) -> Result<impl IntoResponse, DashboardError> {
|
||||||
|
let kc = state.keycloak;
|
||||||
|
|
||||||
|
session
|
||||||
|
.flush()
|
||||||
|
.await
|
||||||
|
.map_err(|e| DashboardError::Other(format!("session flush failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut url = Url::parse(&kc.logout_endpoint())
|
||||||
|
.map_err(|e| DashboardError::Other(format!("invalid logout endpoint URL: {e}")))?;
|
||||||
|
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("client_id", &kc.client_id)
|
||||||
|
.append_pair("post_logout_redirect_uri", &kc.app_url);
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(url.as_str()))
|
||||||
|
}
|
||||||
32
compliance-dashboard/src/infrastructure/auth_check.rs
Normal file
32
compliance-dashboard/src/infrastructure/auth_check.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use compliance_core::models::auth::AuthInfo;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
/// Check the current user's authentication state.
|
||||||
|
///
|
||||||
|
/// Reads the tower-sessions session on the server and returns an
|
||||||
|
/// [`AuthInfo`] describing the logged-in user. When no valid session
|
||||||
|
/// exists, `authenticated` is `false` and all other fields are empty.
|
||||||
|
#[server(endpoint = "check-auth")]
|
||||||
|
pub async fn check_auth() -> Result<AuthInfo, ServerFnError> {
|
||||||
|
use super::auth::LOGGED_IN_USER_SESS_KEY;
|
||||||
|
use super::user_state::UserStateInner;
|
||||||
|
use dioxus_fullstack::FullstackContext;
|
||||||
|
|
||||||
|
let session: tower_sessions::Session = FullstackContext::extract().await?;
|
||||||
|
|
||||||
|
let user_state: Option<UserStateInner> = session
|
||||||
|
.get(LOGGED_IN_USER_SESS_KEY)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("session read failed: {e}")))?;
|
||||||
|
|
||||||
|
match user_state {
|
||||||
|
Some(u) => Ok(AuthInfo {
|
||||||
|
authenticated: true,
|
||||||
|
sub: u.sub,
|
||||||
|
email: u.user.email,
|
||||||
|
name: u.user.name,
|
||||||
|
avatar_url: u.user.avatar_url,
|
||||||
|
}),
|
||||||
|
None => Ok(AuthInfo::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
33
compliance-dashboard/src/infrastructure/auth_middleware.rs
Normal file
33
compliance-dashboard/src/infrastructure/auth_middleware.rs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::Request,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use reqwest::StatusCode;
|
||||||
|
use tower_sessions::Session;
|
||||||
|
|
||||||
|
use super::auth::LOGGED_IN_USER_SESS_KEY;
|
||||||
|
use super::user_state::UserStateInner;
|
||||||
|
|
||||||
|
const PUBLIC_API_ENDPOINTS: &[&str] = &["/api/check-auth"];
|
||||||
|
|
||||||
|
/// Axum middleware that enforces authentication on `/api/` server
|
||||||
|
/// function endpoints.
|
||||||
|
pub async fn require_auth(session: Session, request: Request, next: Next) -> Response {
|
||||||
|
let path = request.uri().path();
|
||||||
|
|
||||||
|
if path.starts_with("/api/") && !PUBLIC_API_ENDPOINTS.contains(&path) {
|
||||||
|
let is_authed = session
|
||||||
|
.get::<UserStateInner>(LOGGED_IN_USER_SESS_KEY)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some();
|
||||||
|
|
||||||
|
if !is_authed {
|
||||||
|
return (StatusCode::UNAUTHORIZED, "Authentication required").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.run(request).await
|
||||||
|
}
|
||||||
@@ -24,3 +24,14 @@ impl From<DashboardError> for ServerFnError {
|
|||||||
ServerFnError::new(err.to_string())
|
ServerFnError::new(err.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
impl axum::response::IntoResponse for DashboardError {
|
||||||
|
fn into_response(self) -> axum::response::Response {
|
||||||
|
(
|
||||||
|
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
self.to_string(),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
56
compliance-dashboard/src/infrastructure/keycloak_config.rs
Normal file
56
compliance-dashboard/src/infrastructure/keycloak_config.rs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
use super::error::DashboardError;
|
||||||
|
|
||||||
|
/// Keycloak OpenID Connect settings.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct KeycloakConfig {
|
||||||
|
pub url: String,
|
||||||
|
pub realm: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub redirect_uri: String,
|
||||||
|
pub app_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeycloakConfig {
|
||||||
|
pub fn from_env() -> Result<Self, DashboardError> {
|
||||||
|
Ok(Self {
|
||||||
|
url: required_env("KEYCLOAK_URL")?,
|
||||||
|
realm: required_env("KEYCLOAK_REALM")?,
|
||||||
|
client_id: required_env("KEYCLOAK_CLIENT_ID")?,
|
||||||
|
redirect_uri: required_env("REDIRECT_URI")?,
|
||||||
|
app_url: required_env("APP_URL")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn auth_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/auth",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn token_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/token",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn userinfo_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/userinfo",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn logout_endpoint(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}/realms/{}/protocol/openid-connect/logout",
|
||||||
|
self.url, self.realm
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_env(name: &str) -> Result<String, DashboardError> {
|
||||||
|
std::env::var(name)
|
||||||
|
.map_err(|_| DashboardError::Config(format!("{name} is required but not set")))
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Server function modules (compiled for both web and server;
|
// Server function modules (compiled for both web and server;
|
||||||
// the #[server] macro generates client stubs for the web target)
|
// the #[server] macro generates client stubs for the web target)
|
||||||
|
pub mod auth_check;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod dast;
|
pub mod dast;
|
||||||
pub mod findings;
|
pub mod findings;
|
||||||
@@ -12,15 +13,27 @@ pub mod stats;
|
|||||||
|
|
||||||
// Server-only modules
|
// Server-only modules
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
mod auth;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod auth_middleware;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
pub mod config;
|
pub mod config;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod database;
|
pub mod database;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod error;
|
pub mod error;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server;
|
pub mod keycloak_config;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod server;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub mod server_state;
|
pub mod server_state;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
mod user_state;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub use auth::{auth_callback, auth_login, logout, PendingOAuthStore};
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub use auth_middleware::require_auth;
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use server::server_start;
|
pub use server::server_start;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
use axum::routing::get;
|
||||||
|
use axum::{middleware, Extension};
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use time::Duration;
|
||||||
|
use tower_sessions::{cookie::Key, MemoryStore, SessionManagerLayer};
|
||||||
|
|
||||||
use super::config;
|
use super::config;
|
||||||
use super::database::Database;
|
use super::database::Database;
|
||||||
use super::error::DashboardError;
|
use super::error::DashboardError;
|
||||||
|
use super::keycloak_config::KeycloakConfig;
|
||||||
use super::server_state::{ServerState, ServerStateInner};
|
use super::server_state::{ServerState, ServerStateInner};
|
||||||
|
use super::{auth_callback, auth_login, logout, require_auth, PendingOAuthStore};
|
||||||
|
|
||||||
pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
||||||
tokio::runtime::Runtime::new()
|
tokio::runtime::Runtime::new()
|
||||||
@@ -12,15 +18,29 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
|||||||
dotenvy::dotenv().ok();
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
let config = config::load_config()?;
|
let config = config::load_config()?;
|
||||||
|
let keycloak: &'static KeycloakConfig =
|
||||||
|
Box::leak(Box::new(KeycloakConfig::from_env()?));
|
||||||
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
let db = Database::connect(&config.mongodb_uri, &config.mongodb_database).await?;
|
||||||
|
|
||||||
|
tracing::info!("Keycloak configured for realm '{}'", keycloak.realm);
|
||||||
|
|
||||||
let server_state: ServerState = ServerStateInner {
|
let server_state: ServerState = ServerStateInner {
|
||||||
agent_api_url: config.agent_api_url.clone(),
|
agent_api_url: config.agent_api_url.clone(),
|
||||||
db,
|
db,
|
||||||
config,
|
config,
|
||||||
|
keycloak,
|
||||||
}
|
}
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
|
// Session layer
|
||||||
|
let key = Key::generate();
|
||||||
|
let store = MemoryStore::default();
|
||||||
|
let session = SessionManagerLayer::new(store)
|
||||||
|
.with_secure(false)
|
||||||
|
.with_same_site(tower_sessions::cookie::SameSite::Lax)
|
||||||
|
.with_expiry(tower_sessions::Expiry::OnInactivity(Duration::hours(24)))
|
||||||
|
.with_signed(key);
|
||||||
|
|
||||||
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
let addr = dioxus_cli_config::fullstack_address_or_localhost();
|
||||||
let listener = tokio::net::TcpListener::bind(addr)
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
.await
|
.await
|
||||||
@@ -29,8 +49,14 @@ pub fn server_start(app: fn() -> Element) -> Result<(), DashboardError> {
|
|||||||
tracing::info!("Dashboard server listening on {addr}");
|
tracing::info!("Dashboard server listening on {addr}");
|
||||||
|
|
||||||
let router = axum::Router::new()
|
let router = axum::Router::new()
|
||||||
|
.route("/auth", get(auth_login))
|
||||||
|
.route("/auth/callback", get(auth_callback))
|
||||||
|
.route("/logout", get(logout))
|
||||||
.serve_dioxus_application(ServeConfig::new(), app)
|
.serve_dioxus_application(ServeConfig::new(), app)
|
||||||
.layer(axum::Extension(server_state));
|
.layer(Extension(PendingOAuthStore::default()))
|
||||||
|
.layer(Extension(server_state))
|
||||||
|
.layer(middleware::from_fn(require_auth))
|
||||||
|
.layer(session);
|
||||||
|
|
||||||
axum::serve(listener, router.into_make_service())
|
axum::serve(listener, router.into_make_service())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
|||||||
use compliance_core::DashboardConfig;
|
use compliance_core::DashboardConfig;
|
||||||
|
|
||||||
use super::database::Database;
|
use super::database::Database;
|
||||||
|
use super::keycloak_config::KeycloakConfig;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ServerState(Arc<ServerStateInner>);
|
pub struct ServerState(Arc<ServerStateInner>);
|
||||||
@@ -19,6 +20,7 @@ pub struct ServerStateInner {
|
|||||||
pub db: Database,
|
pub db: Database,
|
||||||
pub config: DashboardConfig,
|
pub config: DashboardConfig,
|
||||||
pub agent_api_url: String,
|
pub agent_api_url: String,
|
||||||
|
pub keycloak: &'static KeycloakConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<ServerStateInner> for ServerState {
|
impl From<ServerStateInner> for ServerState {
|
||||||
|
|||||||
18
compliance-dashboard/src/infrastructure/user_state.rs
Normal file
18
compliance-dashboard/src/infrastructure/user_state.rs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Per-session user data stored in the tower-sessions session store.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct UserStateInner {
|
||||||
|
pub sub: String,
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub user: User,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Basic user profile stored alongside the session.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct User {
|
||||||
|
pub email: String,
|
||||||
|
pub name: String,
|
||||||
|
pub avatar_url: String,
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ services:
|
|||||||
- "3001:3001"
|
- "3001:3001"
|
||||||
- "3002:3002"
|
- "3002:3002"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
|
||||||
|
OTEL_SERVICE_NAME: compliance-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongo
|
- mongo
|
||||||
volumes:
|
volumes:
|
||||||
@@ -29,6 +32,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
environment:
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4317
|
||||||
|
OTEL_SERVICE_NAME: compliance-dashboard
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongo
|
- mongo
|
||||||
- agent
|
- agent
|
||||||
@@ -43,6 +49,15 @@ services:
|
|||||||
PREBOOT_CHROME: "true"
|
PREBOOT_CHROME: "true"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:latest
|
||||||
|
ports:
|
||||||
|
- "4317:4317"
|
||||||
|
- "4318:4318"
|
||||||
|
volumes:
|
||||||
|
- ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mongo_data:
|
mongo_data:
|
||||||
repos_data:
|
repos_data:
|
||||||
|
|||||||
52
otel-collector-config.yaml
Normal file
52
otel-collector-config.yaml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc:
|
||||||
|
endpoint: 0.0.0.0:4317
|
||||||
|
http:
|
||||||
|
endpoint: 0.0.0.0:4318
|
||||||
|
|
||||||
|
processors:
|
||||||
|
batch:
|
||||||
|
timeout: 5s
|
||||||
|
send_batch_size: 1024
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
# Log to stdout for debugging
|
||||||
|
debug:
|
||||||
|
verbosity: basic
|
||||||
|
|
||||||
|
# Configure your backend below. Examples:
|
||||||
|
#
|
||||||
|
# SigNoz:
|
||||||
|
# otlp/signoz:
|
||||||
|
# endpoint: "signoz-otel-collector:4317"
|
||||||
|
# tls:
|
||||||
|
# insecure: true
|
||||||
|
#
|
||||||
|
# Grafana Tempo (traces):
|
||||||
|
# otlp/tempo:
|
||||||
|
# endpoint: "tempo:4317"
|
||||||
|
# tls:
|
||||||
|
# insecure: true
|
||||||
|
#
|
||||||
|
# Grafana Loki (logs):
|
||||||
|
# loki:
|
||||||
|
# endpoint: "http://loki:3100/loki/api/v1/push"
|
||||||
|
#
|
||||||
|
# Jaeger:
|
||||||
|
# otlp/jaeger:
|
||||||
|
# endpoint: "jaeger:4317"
|
||||||
|
# tls:
|
||||||
|
# insecure: true
|
||||||
|
|
||||||
|
service:
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [batch]
|
||||||
|
exporters: [debug]
|
||||||
|
logs:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [batch]
|
||||||
|
exporters: [debug]
|
||||||
Reference in New Issue
Block a user