Skip to main content

camel_cli/
lib.rs

1pub mod commands;
2pub mod template;
3
4use camel_api::CamelError;
5use camel_dsl::SecurityCompileContext;
6use std::sync::Arc;
7
8// ---------------------------------------------------------------------------
9// Lint catalog registration — handle-free mirror of `commands::run`
10// ---------------------------------------------------------------------------
11
12/// Register a `ComponentBundle` from an empty TOML table, dropping any handle.
13/// On empty-config build error, log at warn and skip — lint must degrade
14/// gracefully (the skipped scheme surfaces as `unverified-scheme`), never fail
15/// to construct its catalog.
16macro_rules! register_bundle_empty {
17    ($ctx:expr, $Bundle:ty) => {{
18        let key = <$Bundle as camel_component_api::ComponentBundle>::config_key();
19        match <$Bundle as camel_component_api::ComponentBundle>::from_toml(
20            ::toml::Value::Table(::toml::map::Map::new()),
21        ) {
22            Ok(bundle) => {
23                <$Bundle as camel_component_api::ComponentBundle>::register_all(
24                    bundle,
25                    &mut *$ctx,
26                );
27            }
28            Err(err) => {
29                tracing::warn!(
30                    bundle = %key,
31                    error = %err,
32                    "lint catalog: empty-config build failed; skipping bundle (surfaces as unverified-scheme)"
33                );
34            }
35        }
36    }};
37}
38
39/// Like [`register_bundle_empty!`], but wires an empty datasource catalog
40/// (sql/surrealdb bundles). An empty catalog is acceptable — metadata is
41/// queryable regardless of configured datasources.
42macro_rules! register_datasource_bundle_empty {
43    ($ctx:expr, $Bundle:ty, $catalog:expr) => {{
44        let key = <$Bundle as camel_component_api::ComponentBundle>::config_key();
45        match <$Bundle as camel_component_api::ComponentBundle>::from_toml(
46            ::toml::Value::Table(::toml::map::Map::new()),
47        ) {
48            Ok(bundle) => {
49                let bundle = bundle.with_catalog(::std::sync::Arc::clone(&$catalog));
50                <$Bundle as camel_component_api::ComponentBundle>::register_all(
51                    bundle,
52                    &mut *$ctx,
53                );
54            }
55            Err(err) => {
56                tracing::warn!(
57                    bundle = %key,
58                    error = %err,
59                    "lint catalog: empty-config build failed; skipping datasource bundle (surfaces as unverified-scheme)"
60                );
61            }
62        }
63    }};
64}
65
66/// Register the built-in components into `ctx` for lint catalog population.
67///
68/// This mirrors `commands::run`'s registration list but is HANDLE-FREE: it
69/// passes empty/default config to every bundle, registers bridge components
70/// without their runtime handles (no `xsd_bridge_backend` / `bridge_runtime`,
71/// no `BridgeCleanup`), and drops any pool returned by jms/cxf. It SKIPS the
72/// path/route-coupled bundles: `wasm` (needs a config-relative `base_dir`) and
73/// `exec` (route-conditional registration). Skipped schemes surface as
74/// `unverified-scheme` notes in lint output, which is the accepted
75/// graceful-degradation behaviour.
76///
77/// Because `Component::metadata()` has a trait default returning
78/// `ComponentMetadata::minimal(scheme)` and `Registry::register()` harvests
79/// it unconditionally, registering each builtin makes its scheme queryable by
80/// the lint catalog (rich metadata where the component opts into it, a
81/// minimal-but-present entry otherwise).
82///
83/// Drift tradeoff: this list and `run`'s list are kept separate on purpose —
84/// `run`'s registration is lifecycle-entangled with bridge/pool/datasource/
85/// path handles that lint has no use for. The drift is bounded and caught by
86/// the corpus baseline (`tests/lint_corpus.rs`); unification is tracked by a
87/// bd follow-up.
88pub fn register_builtin_components_for_lint(ctx: &mut camel_core::CamelContext) {
89    use camel_api::datasource::DatasourceCatalog;
90    use camel_core::datasource::RuntimeDatasourceCatalog;
91
92    // --- Config-independent components (handle-free) ---
93    ctx.register_component(camel_component_timer::TimerComponent::new());
94    ctx.register_component(camel_component_cron::CronComponent::new());
95    ctx.register_component(camel_component_log::LogComponent::new());
96    ctx.register_component(camel_component_direct::DirectComponent::new());
97    ctx.register_component(camel_component_seda::SedaComponent::new());
98    ctx.register_component(camel_component_mock::MockComponent::new());
99    ctx.register_component(camel_component_controlbus::ControlBusComponent::new());
100
101    // --- Bridge components WITHOUT their runtime handles ---
102    // validator: xsd_bridge_backend() not captured, no backend stored.
103    ctx.register_component(camel_component_validator::ValidatorComponent::new());
104    // xslt / xj: bridge_runtime() not captured, no BridgeCleanup installed.
105    // Lint is short-lived; no shutdown cleanup needed.
106    ctx.register_component(camel_xslt::XsltComponent::default());
107    ctx.register_component(camel_xj::XjComponent::default());
108
109    // --- Empty datasource catalog for sql/surrealdb bundles ---
110    let datasource_catalog: Arc<dyn DatasourceCatalog> = Arc::new(RuntimeDatasourceCatalog::new(
111        std::collections::HashMap::new(),
112    ));
113
114    // --- Always-on bundles with empty config ---
115    register_bundle_empty!(ctx, camel_component_http::HttpBundle);
116    #[cfg(feature = "http-static")]
117    register_bundle_empty!(ctx, camel_component_http::HttpStaticBundle);
118    register_bundle_empty!(ctx, camel_component_ws::WsBundle);
119    register_bundle_empty!(ctx, camel_component_file::FileBundle);
120    register_bundle_empty!(ctx, camel_component_container::ContainerBundle);
121    register_bundle_empty!(ctx, camel_template::TemplateBundle);
122    register_bundle_empty!(ctx, camel_master::MasterBundle);
123    register_bundle_empty!(ctx, camel_component_opensearch::OpenSearchBundle);
124    register_bundle_empty!(ctx, camel_component_redis::RedisBundle);
125
126    // jms / cxf: registered without capturing their pool handle (lint has no
127    // runtime use for a bridge pool; metadata is harvested at register time).
128    register_bundle_empty!(ctx, camel_component_jms::JmsBundle);
129    register_bundle_empty!(ctx, camel_component_cxf::CxfBundle);
130
131    // --- Datasource bundles (empty datasource catalog) ---
132    register_datasource_bundle_empty!(ctx, camel_component_sql::SqlBundle, datasource_catalog);
133    #[cfg(feature = "surrealdb")]
134    register_datasource_bundle_empty!(
135        ctx,
136        camel_component_surrealdb::SurrealDbBundle,
137        datasource_catalog
138    );
139
140    // --- Feature-gated bundles (empty config) ---
141    #[cfg(feature = "kafka")]
142    register_bundle_empty!(ctx, camel_component_kafka::KafkaBundle);
143    #[cfg(feature = "mqtt")]
144    register_bundle_empty!(ctx, camel_component_mqtt::MqttBundle);
145    #[cfg(feature = "grpc")]
146    register_bundle_empty!(ctx, camel_component_grpc::GrpcBundle);
147    #[cfg(feature = "llm")]
148    register_bundle_empty!(ctx, camel_component_llm::LlmBundle);
149
150    // --- Skipped (path/route-coupled) ---
151    // wasm: needs a config-relative `base_dir`; lint has no canonical config.
152    // exec: route-conditional; lint has no discovered routes to gate on.
153    // Both surface as `unverified-scheme` in lint output by design.
154}
155
156// ---------------------------------------------------------------------------
157// Auth helpers — shared between wasm and non-wasm build paths
158// ---------------------------------------------------------------------------
159
160fn native_authenticator(
161    native: &camel_config::config::NativeAuthConfig,
162) -> Result<Arc<dyn camel_auth::TokenAuthenticator>, CamelError> {
163    let token = native.bearer_token.clone().ok_or_else(|| {
164        CamelError::Config("security.native.bearer_token is required for route auth".into())
165    })?;
166    let principal = camel_api::security_policy::Principal {
167        subject: native.subject.clone(),
168        issuer: native.issuer.clone().unwrap_or_else(|| "native".into()),
169        audience: Vec::new(),
170        roles: native.roles.clone(),
171        scopes: native.scopes.clone(),
172        claims: serde_json::Value::Object(serde_json::Map::new()),
173    };
174    let store = camel_auth::native_auth::NativeCredentialStore::try_new(vec![
175        camel_auth::NativeCredential {
176            secret: camel_auth::NativeCredentialSecret::Plaintext {
177                value: token.into(),
178            },
179            principal,
180        },
181    ])?;
182    Ok(Arc::new(camel_auth::StaticTokenAuthenticator::new(store)))
183}
184
185async fn keycloak_authenticator(
186    keycloak: &camel_config::config::KeycloakSecurityConfig,
187) -> Result<Arc<dyn camel_auth::TokenAuthenticator>, CamelError> {
188    let realm = camel_component_keycloak::KeycloakRealmConfig::new(
189        keycloak.server_url.clone(),
190        keycloak.realm.clone(),
191        keycloak.client_id.clone(),
192    )
193    .with_client_secret(keycloak.client_secret.clone())
194    .with_allow_internal(keycloak.allow_internal);
195
196    match keycloak.validation.method.as_str() {
197        "local" => {
198            let jwks = Arc::new(
199                camel_auth::RemoteJwksProvider::new(realm.jwks_uri(), realm.policy())
200                    .await
201                    .map_err(|e| CamelError::Config(e.to_string()))?,
202            );
203            let mapper = Arc::new(camel_auth::JsonPointerClaimsMapper::new(
204                camel_component_keycloak::keycloak_claim_paths(&keycloak.client_id),
205            ));
206            Ok(Arc::new(camel_auth::LocalJwtValidator::new(
207                keycloak.validation.audience.clone(),
208                realm.realm_url(),
209                jwks,
210                mapper,
211            )))
212        }
213        "introspection" => {
214            let opts = camel_auth::IntrospectionCacheOptions {
215                max_entries: keycloak.introspection.max_entries,
216                default_ttl: std::time::Duration::from_secs(
217                    keycloak.introspection.default_ttl_secs,
218                ),
219                negative_ttl: std::time::Duration::from_secs(
220                    keycloak.introspection.negative_ttl_secs,
221                ),
222            };
223            let auth = realm.introspection_authenticator(opts).await?;
224            Ok(Arc::new(auth))
225        }
226        other => Err(CamelError::Config(format!(
227            "unsupported security.keycloak.validation.method: {other}"
228        ))),
229    }
230}
231
232/// Resolve the authenticator from `[security.*]`.
233///
234/// Chooses at most one of `keycloak`, `oidc`, `native`.  Returns `None` if
235/// none is configured (anonymous routes are allowed).  Errors if more than
236/// one is present.
237async fn resolve_authenticator(
238    security: &camel_config::config::SecurityConfig,
239) -> Result<Option<Arc<dyn camel_auth::TokenAuthenticator>>, CamelError> {
240    let has_keycloak = security.keycloak.is_some();
241    let has_oidc = security.oidc.is_some();
242    let has_native = security.native.is_some();
243
244    let count = [has_keycloak, has_oidc, has_native]
245        .iter()
246        .filter(|&&x| x)
247        .count();
248    if count > 1 {
249        return Err(CamelError::Config(
250            "configure only one of security.keycloak, security.oidc, security.native for route authentication"
251                .into(),
252        ));
253    }
254
255    if let Some(ref keycloak) = security.keycloak {
256        Ok(Some(keycloak_authenticator(keycloak).await?))
257    } else if let Some(ref native) = security.native {
258        Ok(Some(native_authenticator(native)?))
259    } else {
260        // oidc alone: leave authenticator None for now (scope creep avoidance)
261        Ok(None)
262    }
263}
264
265/// Register Keycloak UMA permission evaluator from `[security.keycloak.uma]`
266/// config.  No-ops when no UMA config is present.
267async fn register_keycloak_uma_evaluator(
268    camel_config: &camel_config::config::CamelConfig,
269    evaluator_registry: &camel_auth::PermissionEvaluatorRegistry,
270) -> Result<(), CamelError> {
271    if let Some(ref keycloak) = camel_config.security.keycloak
272        && let Some(ref uma) = keycloak.uma
273    {
274        let realm = camel_component_keycloak::KeycloakRealmConfig::new(
275            keycloak.server_url.clone(),
276            keycloak.realm.clone(),
277            keycloak.client_id.clone(),
278        )
279        .with_client_secret(keycloak.client_secret.clone())
280        .with_allow_internal(keycloak.allow_internal);
281        let evaluator = realm
282            .uma_evaluator()
283            .await
284            .map_err(|e| CamelError::Config(e.to_string()))?;
285        evaluator_registry.register(uma.provider.clone(), evaluator);
286    }
287    Ok(())
288}
289
290// ---------------------------------------------------------------------------
291// Public entry-point (cfg-gated) — matches the existing signature
292// ---------------------------------------------------------------------------
293
294#[cfg(feature = "wasm")]
295pub async fn build_security_compile_context_from_config(
296    camel_config: &camel_config::config::CamelConfig,
297    registry: Arc<std::sync::Mutex<camel_core::Registry>>,
298) -> Result<SecurityCompileContext, CamelError> {
299    let wasm_ctx: Arc<dyn camel_component_api::ComponentContext> =
300        Arc::new(camel_core::RegistryComponentContext::new(registry));
301    let authenticator = resolve_authenticator(&camel_config.security).await?;
302    let mut security_ctx = SecurityCompileContext::new(authenticator, None);
303
304    let evaluator_registry = camel_auth::PermissionEvaluatorRegistry::new();
305
306    if let Some(ref policies) = camel_config.security.policies {
307        let policy_registry =
308            camel_component_wasm::build_security_policy_registry(&policies.wasm, wasm_ctx.clone())
309                .await
310                .map_err(|e| CamelError::Config(e.to_string()))?;
311        if !policy_registry.is_empty() {
312            security_ctx = security_ctx.with_security_policy_registry(Arc::new(policy_registry));
313        }
314    }
315
316    if let Some(ref permissions) = camel_config.security.permissions {
317        let wasm_registry = camel_component_wasm::build_permission_registry(permissions, wasm_ctx)
318            .await
319            .map_err(|e| CamelError::Config(e.to_string()))?;
320        for (name, evaluator) in wasm_registry.entries() {
321            evaluator_registry.register(name, evaluator);
322        }
323    }
324
325    register_keycloak_uma_evaluator(camel_config, &evaluator_registry).await?;
326
327    if !evaluator_registry.is_empty() {
328        security_ctx = security_ctx.with_evaluator_registry(Arc::new(evaluator_registry));
329    }
330
331    Ok(security_ctx)
332}
333
334#[cfg(not(feature = "wasm"))]
335pub async fn build_security_compile_context_from_config(
336    camel_config: &camel_config::config::CamelConfig,
337    _registry: Arc<std::sync::Mutex<camel_core::Registry>>,
338) -> Result<SecurityCompileContext, CamelError> {
339    if camel_config.security.permissions.is_some() {
340        return Err(CamelError::Config(
341            "security.permissions requires camel-cli wasm feature".into(),
342        ));
343    }
344
345    if camel_config.security.policies.is_some() {
346        return Err(CamelError::Config(
347            "security.policies requires camel-cli wasm feature".into(),
348        ));
349    }
350
351    let authenticator = resolve_authenticator(&camel_config.security).await?;
352    let mut security_ctx = SecurityCompileContext::new(authenticator, None);
353
354    let evaluator_registry = camel_auth::PermissionEvaluatorRegistry::new();
355
356    register_keycloak_uma_evaluator(camel_config, &evaluator_registry).await?;
357
358    if !evaluator_registry.is_empty() {
359        security_ctx = security_ctx.with_evaluator_registry(Arc::new(evaluator_registry));
360    }
361
362    Ok(security_ctx)
363}
364
365// ---------------------------------------------------------------------------
366// Tests
367// ---------------------------------------------------------------------------
368
369#[cfg(test)]
370mod tests {
371    use std::sync::Arc;
372
373    #[tokio::test]
374    async fn native_static_token_builds_authenticator() {
375        let cfg: camel_config::config::CamelConfig = toml::from_str(
376            r#"
377        [security.native]
378        subject = "dev-user"
379        issuer = "native"
380        bearer_token = "dev-token"
381        roles = ["admin"]
382        scopes = ["read"]
383        "#,
384        )
385        .expect("config parses");
386
387        let registry = Arc::new(std::sync::Mutex::new(camel_core::Registry::new()));
388        let ctx = crate::build_security_compile_context_from_config(&cfg, registry)
389            .await
390            .expect("security context builds");
391
392        assert!(ctx.authenticator.is_some());
393    }
394
395    #[cfg(feature = "wasm")]
396    #[tokio::test]
397    async fn security_permissions_config_is_consumed_when_building_compile_context() {
398        let cfg: camel_config::config::CamelConfig = toml::from_str(
399            r#"
400            [security.permissions.invoice-policy]
401            provider = "wasm"
402            "#,
403        )
404        .expect("config parses");
405
406        let registry = Arc::new(std::sync::Mutex::new(camel_core::Registry::new()));
407        let err = match crate::build_security_compile_context_from_config(&cfg, registry).await {
408            Ok(_) => {
409                panic!("wasm permission provider without path must fail during registry build")
410            }
411            Err(err) => err,
412        };
413
414        assert!(
415            err.to_string().contains("requires 'path'"),
416            "unexpected error: {err}"
417        );
418    }
419
420    #[tokio::test]
421    async fn multiple_auth_providers_returns_config_error() {
422        let cfg: camel_config::config::CamelConfig = toml::from_str(
423            r#"
424        [security.keycloak]
425        server_url = "https://kc.example.com"
426        realm = "camel"
427        client_id = "camel-api"
428        client_secret = "secret"
429
430        [security.native]
431        subject = "dev-user"
432        issuer = "native"
433        bearer_token = "dev-token"
434        "#,
435        )
436        .expect("config parses");
437
438        let registry = Arc::new(std::sync::Mutex::new(camel_core::Registry::new()));
439        let err = match crate::build_security_compile_context_from_config(&cfg, registry).await {
440            Ok(_) => panic!("multiple providers should fail"),
441            Err(err) => err,
442        };
443
444        assert!(
445            err.to_string().contains("configure only one"),
446            "unexpected error: {err}"
447        );
448    }
449}