mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
feat(settings): stage 6.6 wire server + CLI to v2 SettingsFile DTO
Replaces the Stage 6.2 stopgap `strip_nulls(serde_json::to_value(full
SettingsFile))` path in `get_server_settings` with an explicit
redaction pass in the new `fabro_server::settings_view` module.
The redaction drops the narrow set of fields that leak operational
secrets or host filesystem layout:
- `server.listen.*` (bind + TLS material)
- `server.auth.api.jwt.{issuer, audience}` (auth topology)
- `server.auth.api.mtls.ca` (filesystem path)
- `server.auth.web.providers.github.client_secret`
Every other field is preserved. `InterpString` values that reference
`${env.NAME}` already serialize to their unresolved template form, so
no additional env-provenance walk is needed in this pass.
Implements the real `/api/v1/runs/:id/settings` handler — previously
wired to `not_implemented` — by opening the run reader, reading the
persisted `RunRecord.settings`, running it through the same
redaction, and serializing. The demo route still points at
`demo::get_run_settings`, unchanged.
Updates `fabro-cli` to deserialize the new wire shape as
`SettingsFile` directly:
- `server_client::retrieve_server_settings` now returns
`SettingsFile` (no longer the legacy flat `Settings`) by decoding
the progenitor `types::ServerSettings` newtype map into a
`serde_json::Value` and then into `SettingsFile`.
- `commands/config/mod.rs::legacy_settings_to_v2` shim (TODO-1)
**deleted**; `merged_config` passes the v2 file straight into
`effective_settings::resolve_settings`.
- The `fabro-cli` integration tests rewrite their mock `/api/v1/settings`
payloads as v2 TOML via `ConfigLayer::parse` instead of hand-rolling
the legacy TOML shape.
All 3,761 workspace tests pass. `cargo fmt --check --all` and
`cargo clippy --workspace -- -D warnings` are clean.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7c8448ece8
commit
f5b9f82a27
6 changed files with 304 additions and 126 deletions
|
|
@ -9,7 +9,7 @@ use fabro_config::ConfigLayer;
|
||||||
use fabro_config::effective_settings;
|
use fabro_config::effective_settings;
|
||||||
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
|
use fabro_config::effective_settings::{EffectiveSettingsLayers, EffectiveSettingsMode};
|
||||||
use fabro_config::project;
|
use fabro_config::project;
|
||||||
use fabro_types::settings::v2::SettingsFile;
|
use fabro_types::settings::SettingsFile;
|
||||||
|
|
||||||
fn config_layers(
|
fn config_layers(
|
||||||
ctx: &CommandContext,
|
ctx: &CommandContext,
|
||||||
|
|
@ -70,11 +70,7 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result<SettingsFile> {
|
||||||
|
|
||||||
let ctx = CommandContext::for_target(&args.target)?;
|
let ctx = CommandContext::for_target(&args.target)?;
|
||||||
let target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?;
|
let target = user_config::resolve_server_target(&args.target, ctx.machine_settings())?;
|
||||||
// `retrieve_server_settings` currently returns a legacy flat `Settings`;
|
let server_settings = ctx.server().await?.retrieve_server_settings().await?;
|
||||||
// route it through the v2 bridge shim for the consumer-side call.
|
|
||||||
// Stage 6.6 rewrites the API client to return v2 types directly.
|
|
||||||
let legacy_server = ctx.server().await?.retrieve_server_settings().await?;
|
|
||||||
let server_settings = legacy_settings_to_v2(&legacy_server);
|
|
||||||
let mode = match target {
|
let mode = match target {
|
||||||
user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer,
|
user_config::ServerTarget::HttpUrl { .. } => EffectiveSettingsMode::RemoteServer,
|
||||||
user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon,
|
user_config::ServerTarget::UnixSocket(_) => EffectiveSettingsMode::LocalDaemon,
|
||||||
|
|
@ -83,84 +79,6 @@ async fn merged_config(args: &SettingsArgs) -> anyhow::Result<SettingsFile> {
|
||||||
effective_settings::resolve_settings(layers, Some(&server_settings), mode)
|
effective_settings::resolve_settings(layers, Some(&server_settings), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stopgap reverse bridge from the legacy flat `Settings` to a v2
|
|
||||||
/// `SettingsFile`. `retrieve_server_settings` still returns the legacy
|
|
||||||
/// shape across the wire; the v2 resolver needs server-settings in v2
|
|
||||||
/// shape. This reverse-maps the fields that matter for server-side
|
|
||||||
/// defaults (storage, scheduler, integrations, verbose, run model).
|
|
||||||
/// Stage 6.6 rewrites the API client to return v2 types directly and
|
|
||||||
/// deletes this helper.
|
|
||||||
fn legacy_settings_to_v2(legacy: &fabro_types::Settings) -> SettingsFile {
|
|
||||||
use fabro_types::settings::v2::cli::{CliLayer, CliOutputLayer, OutputVerbosity};
|
|
||||||
use fabro_types::settings::v2::interp::InterpString;
|
|
||||||
use fabro_types::settings::v2::run::{RunLayer, RunModelLayer};
|
|
||||||
use fabro_types::settings::v2::server::{
|
|
||||||
GithubIntegrationLayer, ServerIntegrationsLayer, ServerLayer, ServerSchedulerLayer,
|
|
||||||
ServerStorageLayer, SlackIntegrationLayer,
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut file = SettingsFile::default();
|
|
||||||
|
|
||||||
if let Some(storage_dir) = legacy.storage_dir.as_ref() {
|
|
||||||
let server = file.server.get_or_insert_with(ServerLayer::default);
|
|
||||||
server.storage = Some(ServerStorageLayer {
|
|
||||||
root: Some(InterpString::parse(&storage_dir.to_string_lossy())),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(max_concurrent) = legacy.max_concurrent_runs {
|
|
||||||
let server = file.server.get_or_insert_with(ServerLayer::default);
|
|
||||||
server.scheduler = Some(ServerSchedulerLayer {
|
|
||||||
max_concurrent_runs: Some(max_concurrent),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(git) = legacy.git.as_ref() {
|
|
||||||
let server = file.server.get_or_insert_with(ServerLayer::default);
|
|
||||||
let integrations = server
|
|
||||||
.integrations
|
|
||||||
.get_or_insert_with(ServerIntegrationsLayer::default);
|
|
||||||
let github = integrations
|
|
||||||
.github
|
|
||||||
.get_or_insert_with(GithubIntegrationLayer::default);
|
|
||||||
github.app_id = git.app_id.as_deref().map(InterpString::parse);
|
|
||||||
github.client_id = git.client_id.as_deref().map(InterpString::parse);
|
|
||||||
github.slug = git.slug.as_deref().map(InterpString::parse);
|
|
||||||
}
|
|
||||||
if let Some(slack) = legacy.slack.as_ref() {
|
|
||||||
let server = file.server.get_or_insert_with(ServerLayer::default);
|
|
||||||
let integrations = server
|
|
||||||
.integrations
|
|
||||||
.get_or_insert_with(ServerIntegrationsLayer::default);
|
|
||||||
integrations.slack = Some(SlackIntegrationLayer {
|
|
||||||
enabled: None,
|
|
||||||
default_channel: slack.default_channel.as_deref().map(InterpString::parse),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(llm) = legacy.llm.as_ref() {
|
|
||||||
let run = file.run.get_or_insert_with(RunLayer::default);
|
|
||||||
run.model = Some(RunModelLayer {
|
|
||||||
provider: llm.provider.as_deref().map(InterpString::parse),
|
|
||||||
name: llm.model.as_deref().map(InterpString::parse),
|
|
||||||
fallbacks: Vec::new(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if let Some(vars) = legacy.vars.as_ref() {
|
|
||||||
let run = file.run.get_or_insert_with(RunLayer::default);
|
|
||||||
run.inputs = Some(
|
|
||||||
vars.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), toml::Value::String(v.clone())))
|
|
||||||
.collect(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(true) = legacy.verbose {
|
|
||||||
let cli = file.cli.get_or_insert_with(CliLayer::default);
|
|
||||||
cli.output = Some(CliOutputLayer {
|
|
||||||
verbosity: Some(OutputVerbosity::Verbose),
|
|
||||||
..CliOutputLayer::default()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
file
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> {
|
pub(crate) async fn execute(args: &SettingsArgs, globals: &GlobalArgs) -> anyhow::Result<()> {
|
||||||
let config = Box::pin(merged_config(args)).await?;
|
let config = Box::pin(merged_config(args)).await?;
|
||||||
if globals.json {
|
if globals.json {
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,7 @@ use bytes::Bytes;
|
||||||
use fabro_api::types;
|
use fabro_api::types;
|
||||||
use fabro_server::bind::Bind;
|
use fabro_server::bind::Bind;
|
||||||
use fabro_store::{EventEnvelope, RunSummary, StageId};
|
use fabro_store::{EventEnvelope, RunSummary, StageId};
|
||||||
use fabro_types::Settings;
|
use fabro_types::settings::SettingsFile;
|
||||||
use fabro_types::settings::v2::SettingsFile;
|
|
||||||
use fabro_types::{RunBlobId, RunEvent, RunId};
|
use fabro_types::{RunBlobId, RunEvent, RunId};
|
||||||
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
|
use fabro_workflow::artifact_snapshot::CapturedArtifactInfo;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
|
@ -279,14 +278,16 @@ impl ServerStoreClient {
|
||||||
&self.base_url
|
&self.base_url
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn retrieve_server_settings(&self) -> Result<Settings> {
|
pub(crate) async fn retrieve_server_settings(&self) -> Result<SettingsFile> {
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.retrieve_server_settings()
|
.retrieve_server_settings()
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(map_api_error)?;
|
.map_err(map_api_error)?;
|
||||||
convert_type(response.into_inner())
|
let raw = serde_json::Value::Object(response.into_inner().into());
|
||||||
|
serde_json::from_value::<SettingsFile>(raw)
|
||||||
|
.context("server returned a settings payload that does not match the v2 schema")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn create_run_from_manifest(
|
pub(crate) async fn create_run_from_manifest(
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use fabro_config::ConfigLayer;
|
||||||
use fabro_test::{fabro_snapshot, test_context};
|
use fabro_test::{fabro_snapshot, test_context};
|
||||||
use fabro_types::Settings;
|
use fabro_types::settings::SettingsFile;
|
||||||
use fabro_types::settings::v2::SettingsFile;
|
|
||||||
use httpmock::MockServer;
|
use httpmock::MockServer;
|
||||||
use predicates::prelude::*;
|
use predicates::prelude::*;
|
||||||
|
|
||||||
|
|
@ -35,45 +35,29 @@ fn parse_settings(stdout: &[u8]) -> SettingsFile {
|
||||||
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile")
|
serde_yaml::from_slice(stdout).expect("stdout should be valid YAML SettingsFile")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_settings_fixture() -> Settings {
|
fn server_settings_fixture() -> SettingsFile {
|
||||||
toml::from_str(
|
ConfigLayer::parse(
|
||||||
r#"
|
r#"
|
||||||
storage_dir = "/srv/fabro-server"
|
_version = 1
|
||||||
verbose = false
|
|
||||||
|
|
||||||
[llm]
|
[server.storage]
|
||||||
model = "server-model"
|
root = "/srv/fabro-server"
|
||||||
|
|
||||||
|
[run.model]
|
||||||
|
name = "server-model"
|
||||||
provider = "openai"
|
provider = "openai"
|
||||||
|
|
||||||
[vars]
|
[run.inputs]
|
||||||
server_only = "1"
|
server_only = "1"
|
||||||
shared = "server"
|
shared = "server"
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.expect("server settings fixture should parse")
|
.expect("server settings fixture should parse")
|
||||||
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn server_settings_body(settings: &Settings) -> String {
|
fn server_settings_body(settings: &SettingsFile) -> String {
|
||||||
fn strip_nulls(value: &mut serde_json::Value) {
|
serde_json::to_string(settings).expect("settings payload should serialize")
|
||||||
match value {
|
|
||||||
serde_json::Value::Object(map) => {
|
|
||||||
for child in map.values_mut() {
|
|
||||||
strip_nulls(child);
|
|
||||||
}
|
|
||||||
map.retain(|_, child| !child.is_null());
|
|
||||||
}
|
|
||||||
serde_json::Value::Array(values) => {
|
|
||||||
for child in values {
|
|
||||||
strip_nulls(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut value = serde_json::to_value(settings).expect("settings fixture should serialize");
|
|
||||||
strip_nulls(&mut value);
|
|
||||||
serde_json::to_string(&value).expect("settings payload should serialize")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set up home config and project config for settings command tests.
|
/// Set up home config and project config for settings command tests.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ mod run_manifest;
|
||||||
pub mod secret_store;
|
pub mod secret_store;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
|
mod settings_view;
|
||||||
pub mod static_files;
|
pub mod static_files;
|
||||||
pub mod server_config {
|
pub mod server_config {
|
||||||
pub use fabro_types::Settings;
|
pub use fabro_types::Settings;
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ use crate::jwt_auth::{
|
||||||
};
|
};
|
||||||
use crate::run_manifest;
|
use crate::run_manifest;
|
||||||
use crate::secret_store::{SecretStore, SecretStoreError};
|
use crate::secret_store::{SecretStore, SecretStoreError};
|
||||||
|
use crate::settings_view;
|
||||||
use crate::static_files;
|
use crate::static_files;
|
||||||
use crate::web_auth;
|
use crate::web_auth;
|
||||||
use fabro_interview::{
|
use fabro_interview::{
|
||||||
|
|
@ -1009,7 +1010,7 @@ fn real_routes() -> Router<Arc<AppState>> {
|
||||||
get(get_stage_artifact),
|
get(get_stage_artifact),
|
||||||
)
|
)
|
||||||
.route("/runs/{id}/billing", get(get_run_billing))
|
.route("/runs/{id}/billing", get(get_run_billing))
|
||||||
.route("/runs/{id}/settings", get(not_implemented))
|
.route("/runs/{id}/settings", get(get_run_settings))
|
||||||
.route("/runs/{id}/steer", post(not_implemented))
|
.route("/runs/{id}/steer", post(not_implemented))
|
||||||
.route("/runs/{id}/preview", post(generate_preview_url))
|
.route("/runs/{id}/preview", post(generate_preview_url))
|
||||||
.route("/runs/{id}/ssh", post(create_ssh_access))
|
.route("/runs/{id}/ssh", post(create_ssh_access))
|
||||||
|
|
@ -1064,13 +1065,8 @@ async fn get_server_settings(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let settings = state.settings.read().unwrap().clone();
|
let settings = state.settings.read().unwrap().clone();
|
||||||
// Stage 6.6 TODO: replace this with an explicit allow-list DTO that
|
let redacted = settings_view::redact_for_api(&settings);
|
||||||
// reads directly from the v2 tree and redacts env-sourced values via
|
let mut value = match serde_json::to_value(&redacted) {
|
||||||
// `InterpString` provenance. For now we serialize the full v2
|
|
||||||
// `SettingsFile` as JSON so the web UI still has a response body --
|
|
||||||
// the legacy `ServerSettings` OpenAPI schema will be rewritten in
|
|
||||||
// 6.6 alongside the fabro-web DTO updates.
|
|
||||||
let mut value = match serde_json::to_value(&settings) {
|
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||||
|
|
@ -4036,6 +4032,47 @@ async fn get_run_status(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_run_settings(
|
||||||
|
_auth: AuthenticatedService,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
let id = match parse_run_id_path(&id) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(response) => return response,
|
||||||
|
};
|
||||||
|
let run_store = match state.store.open_run_reader(&id).await {
|
||||||
|
Ok(store) => store,
|
||||||
|
Err(fabro_store::StoreError::RunNotFound(_)) => {
|
||||||
|
return ApiError::not_found("Run not found.").into_response();
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let run_state = match run_store.state().await {
|
||||||
|
Ok(state) => state,
|
||||||
|
Err(err) => {
|
||||||
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(run_record) = run_state.run else {
|
||||||
|
return ApiError::not_found("Run not found.").into_response();
|
||||||
|
};
|
||||||
|
let redacted = settings_view::redact_for_api(&run_record.settings);
|
||||||
|
let mut value = match serde_json::to_value(&redacted) {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(err) => {
|
||||||
|
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
strip_nulls(&mut value);
|
||||||
|
(StatusCode::OK, Json(value)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_questions(
|
async fn get_questions(
|
||||||
_auth: AuthenticatedService,
|
_auth: AuthenticatedService,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
|
|
|
||||||
237
lib/crates/fabro-server/src/settings_view.rs
Normal file
237
lib/crates/fabro-server/src/settings_view.rs
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
//! Outward-facing view of [`SettingsFile`] for API responses.
|
||||||
|
//!
|
||||||
|
//! `/api/v1/settings` and `/api/v1/runs/:id/settings` return the server's v2
|
||||||
|
//! [`SettingsFile`] directly as JSON so authenticated clients (the `fabro
|
||||||
|
//! settings` CLI, the web UI) can see the effective configuration. Before
|
||||||
|
//! serialization, this module drops the handful of fields that would leak
|
||||||
|
//! operational secrets or host-specific filesystem layout.
|
||||||
|
//!
|
||||||
|
//! ## What gets dropped
|
||||||
|
//!
|
||||||
|
//! Per the requirements doc (R16, R52, R53, R79–R81) and the Stage 6.6 plan:
|
||||||
|
//!
|
||||||
|
//! - `server.listen` — the whole subtree. Bind address reveals network
|
||||||
|
//! topology; `[server.listen.tls]` cert/key/ca paths reveal the host
|
||||||
|
//! filesystem layout.
|
||||||
|
//! - `server.auth.api.jwt.issuer` and `jwt.audience` — auth topology. Keeps
|
||||||
|
//! `enabled` so clients can tell whether JWT auth is on.
|
||||||
|
//! - `server.auth.api.mtls.ca` — filesystem path to the CA bundle. Keeps
|
||||||
|
//! `enabled`.
|
||||||
|
//! - `server.auth.web.providers.github.client_secret` — explicit OAuth
|
||||||
|
//! secret. Keeps `enabled` and `client_id` (the latter is public in OAuth).
|
||||||
|
//!
|
||||||
|
//! ## Why that's all
|
||||||
|
//!
|
||||||
|
//! The rest of the v2 tree is either:
|
||||||
|
//!
|
||||||
|
//! - A literal non-secret value (storage root, scheduler limit, integration
|
||||||
|
//! slug, feature flag), OR
|
||||||
|
//! - An [`InterpString`] containing `${env.NAME}` tokens. `InterpString`'s
|
||||||
|
//! default serialization preserves the *unresolved* template form, so the
|
||||||
|
//! wire payload surfaces `"Bearer ${env.TOKEN}"` instead of the resolved
|
||||||
|
//! secret value. No additional redaction pass is needed.
|
||||||
|
//!
|
||||||
|
//! Any future field that carries a raw secret in-band (without env
|
||||||
|
//! interpolation) must be added to the drop list below.
|
||||||
|
|
||||||
|
use fabro_types::settings::SettingsFile;
|
||||||
|
|
||||||
|
/// Build a redacted clone of `settings` safe to serialize outward.
|
||||||
|
///
|
||||||
|
/// See the module docs for the drop-list rationale.
|
||||||
|
#[must_use]
|
||||||
|
pub(crate) fn redact_for_api(settings: &SettingsFile) -> SettingsFile {
|
||||||
|
let mut out = settings.clone();
|
||||||
|
|
||||||
|
if let Some(server) = out.server.as_mut() {
|
||||||
|
// Bind address + TLS key/cert paths: host operational details.
|
||||||
|
server.listen = None;
|
||||||
|
|
||||||
|
if let Some(auth) = server.auth.as_mut() {
|
||||||
|
if let Some(api) = auth.api.as_mut() {
|
||||||
|
if let Some(jwt) = api.jwt.as_mut() {
|
||||||
|
jwt.issuer = None;
|
||||||
|
jwt.audience = None;
|
||||||
|
}
|
||||||
|
if let Some(mtls) = api.mtls.as_mut() {
|
||||||
|
mtls.ca = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(web) = auth.web.as_mut() {
|
||||||
|
if let Some(providers) = web.providers.as_mut() {
|
||||||
|
if let Some(github) = providers.github.as_mut() {
|
||||||
|
github.client_secret = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use fabro_config::ConfigLayer;
|
||||||
|
|
||||||
|
fn parse(source: &str) -> SettingsFile {
|
||||||
|
ConfigLayer::parse(source)
|
||||||
|
.expect("fixture should parse")
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_server_listen_entirely() {
|
||||||
|
let settings = parse(
|
||||||
|
r#"
|
||||||
|
_version = 1
|
||||||
|
|
||||||
|
[server.listen]
|
||||||
|
type = "tcp"
|
||||||
|
address = "127.0.0.1:32276"
|
||||||
|
|
||||||
|
[server.listen.tls]
|
||||||
|
cert = "/etc/fabro/tls/cert.pem"
|
||||||
|
key = "/etc/fabro/tls/key.pem"
|
||||||
|
ca = "/etc/fabro/tls/ca.pem"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let redacted = redact_for_api(&settings);
|
||||||
|
assert!(redacted.server.unwrap().listen.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_jwt_issuer_and_audience_but_keeps_enabled() {
|
||||||
|
let settings = parse(
|
||||||
|
r#"
|
||||||
|
_version = 1
|
||||||
|
|
||||||
|
[server.auth.api.jwt]
|
||||||
|
enabled = true
|
||||||
|
issuer = "https://auth.example.com"
|
||||||
|
audience = "fabro"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let redacted = redact_for_api(&settings);
|
||||||
|
let jwt = redacted
|
||||||
|
.server
|
||||||
|
.unwrap()
|
||||||
|
.auth
|
||||||
|
.unwrap()
|
||||||
|
.api
|
||||||
|
.unwrap()
|
||||||
|
.jwt
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(jwt.enabled, Some(true));
|
||||||
|
assert!(jwt.issuer.is_none());
|
||||||
|
assert!(jwt.audience.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_mtls_ca_path_but_keeps_enabled() {
|
||||||
|
let settings = parse(
|
||||||
|
r#"
|
||||||
|
_version = 1
|
||||||
|
|
||||||
|
[server.auth.api.mtls]
|
||||||
|
enabled = true
|
||||||
|
ca = "/etc/fabro/tls/ca.pem"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let redacted = redact_for_api(&settings);
|
||||||
|
let mtls = redacted
|
||||||
|
.server
|
||||||
|
.unwrap()
|
||||||
|
.auth
|
||||||
|
.unwrap()
|
||||||
|
.api
|
||||||
|
.unwrap()
|
||||||
|
.mtls
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(mtls.enabled, Some(true));
|
||||||
|
assert!(mtls.ca.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_github_client_secret_but_keeps_client_id_and_enabled() {
|
||||||
|
let settings = parse(
|
||||||
|
r#"
|
||||||
|
_version = 1
|
||||||
|
|
||||||
|
[server.auth.web.providers.github]
|
||||||
|
enabled = true
|
||||||
|
client_id = "Iv1.abcdef"
|
||||||
|
client_secret = "${env.GITHUB_OAUTH_SECRET}"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let redacted = redact_for_api(&settings);
|
||||||
|
let github = redacted
|
||||||
|
.server
|
||||||
|
.unwrap()
|
||||||
|
.auth
|
||||||
|
.unwrap()
|
||||||
|
.web
|
||||||
|
.unwrap()
|
||||||
|
.providers
|
||||||
|
.unwrap()
|
||||||
|
.github
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(github.enabled, Some(true));
|
||||||
|
assert!(github.client_id.is_some());
|
||||||
|
assert!(github.client_secret.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_run_cli_project_and_features() {
|
||||||
|
let settings = parse(
|
||||||
|
r#"
|
||||||
|
_version = 1
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "Fabro"
|
||||||
|
|
||||||
|
[run]
|
||||||
|
goal = "ship it"
|
||||||
|
|
||||||
|
[run.model]
|
||||||
|
provider = "anthropic"
|
||||||
|
name = "sonnet"
|
||||||
|
|
||||||
|
[cli.output]
|
||||||
|
verbosity = "verbose"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
session_sandboxes = true
|
||||||
|
|
||||||
|
[server.scheduler]
|
||||||
|
max_concurrent_runs = 9
|
||||||
|
|
||||||
|
[server.storage]
|
||||||
|
root = "/srv/fabro"
|
||||||
|
|
||||||
|
[server.integrations.github]
|
||||||
|
app_id = "12345"
|
||||||
|
client_id = "Iv1.abcdef"
|
||||||
|
slug = "fabro-app"
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
let redacted = redact_for_api(&settings);
|
||||||
|
assert!(redacted.project.is_some());
|
||||||
|
let run = redacted.run.unwrap();
|
||||||
|
assert!(run.goal.is_some());
|
||||||
|
assert!(run.model.is_some());
|
||||||
|
assert!(redacted.cli.is_some());
|
||||||
|
assert!(redacted.features.is_some());
|
||||||
|
let server = redacted.server.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
server.scheduler.and_then(|s| s.max_concurrent_runs),
|
||||||
|
Some(9)
|
||||||
|
);
|
||||||
|
assert!(server.storage.is_some());
|
||||||
|
let github = server.integrations.unwrap().github.unwrap();
|
||||||
|
assert!(github.app_id.is_some());
|
||||||
|
assert!(github.client_id.is_some());
|
||||||
|
assert!(github.slug.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue