aion-server 0.31.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! Runtime capability discovery (`GET /whoami`).
//!
//! The console asserts no authorization at build time. It DISCOVERS its
//! capabilities at runtime by asking the server who the caller is and what it
//! is allowed to do, then renders affordances from that. This endpoint runs
//! through the same [`HttpCaller`] extractor as every data route, so it
//! reflects exactly the identity the server resolved for the request — there is
//! no second auth path.
//!
//! It is safe to expose without additional gating: it only reflects the
//! caller's OWN grants. It never enumerates other subjects, never lists the
//! deployment's namespaces, and reveals nothing an authorized request to the
//! data API would not already reveal to the same caller.

use axum::{Json, extract::State};
use serde::Serialize;

use super::auth::HttpCaller;
use crate::ServerState;
use crate::namespace::grants::GRANT_WORDS;

/// Capability snapshot for the resolved caller, consumed by the ops console to
/// gate affordances at runtime.
#[derive(Debug, Serialize)]
pub(crate) struct WhoAmI {
    /// Caller subject as resolved by the transport (the audit label).
    subject: String,
    /// Whether the server has auth configured. When `false` the server is in
    /// single-tenant operator mode and the caller is the operator.
    auth_enabled: bool,
    /// Every grant word this deployment defines, and whether this caller
    /// holds it.
    ///
    /// Built by walking [`GRANT_WORDS`], never by a second hand-written list:
    /// a word that existed in the grammar and not here would be grantable and
    /// undiscoverable, which is exactly the defect this endpoint exists to
    /// prevent for namespaces.
    grants: Vec<GrantDescriptor>,
    /// Whether the caller holds access to every namespace (operator mode),
    /// rather than the explicit `namespaces` set.
    all_namespaces: bool,
    /// The caller's explicitly granted namespaces, sorted. Empty for an
    /// operator (whose all-access is signaled by `all_namespaces`).
    namespaces: Vec<String>,
}

/// One grant word as the console discovers it: what it is called, how it is
/// carried, what it authorises, and whether this caller holds it.
#[derive(Debug, Serialize)]
pub(crate) struct GrantDescriptor {
    /// The stable word an operator and an audit line spell.
    word: &'static str,
    /// The request header that carries it on the development paths.
    header: &'static str,
    /// The bearer-token claim that carries it when auth is enabled.
    claim: &'static str,
    /// One sentence naming what the word authorises.
    description: &'static str,
    /// Whether this caller holds it.
    granted: bool,
}

/// Reflect the resolved caller's identity and grants for runtime capability
/// discovery.
pub(crate) async fn whoami(
    State(state): State<ServerState>,
    HttpCaller(caller): HttpCaller,
) -> Json<WhoAmI> {
    Json(WhoAmI {
        subject: caller.subject().to_owned(),
        auth_enabled: state.runtime_config().auth.enabled,
        grants: GRANT_WORDS
            .iter()
            .map(|grant| GrantDescriptor {
                word: grant.word(),
                header: grant.header(),
                claim: grant.claim(),
                description: grant.description(),
                granted: grant.granted_for(&caller),
            })
            .collect(),
        all_namespaces: caller.all_namespaces(),
        namespaces: caller.namespaces(),
    })
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use aion::EngineBuilder;
    use aion_store::{EventStore, InMemoryStore};
    use axum::{body, http::Request, http::StatusCode};
    use serde_json::Value;
    use tower::ServiceExt;

    use super::super::router::workflow_router;
    use super::super::test_support::{read_json, runtime_config, server_state};
    use super::GRANT_WORDS;
    use crate::test_support::{EngineUnderTest, StateUnderTest};
    use crate::{
        NamespaceResolver, StaticScheduleNamespaces, StaticWorkflowNamespaces,
        config::NamespaceMode,
    };

    /// Auth-off state over a fresh engine, held by the caller so the engine is
    /// shut down when the test ends; `workflow_router(state.clone())` is the
    /// router.
    async fn auth_off_state() -> Result<StateUnderTest, Box<dyn std::error::Error>> {
        let store: Arc<dyn EventStore> = Arc::new(InMemoryStore::default());
        let engine = EngineUnderTest::new(Arc::new(
            EngineBuilder::new()
                .stop_drain_timeout(std::time::Duration::from_secs(5))
                .store_arc(store)
                .in_memory_visibility()
                .scheduler_threads(1)
                .build()
                .await?,
        ));
        let resolver = NamespaceResolver::from_parts(
            NamespaceMode::SharedEngine,
            Some(engine.handle()),
            Arc::new(StaticWorkflowNamespaces::default()),
            Arc::new(StaticScheduleNamespaces::default()),
        );
        let mut config = runtime_config();
        config.auth.enabled = false;
        server_state(engine, resolver, config).await
    }

    /// Auth-off operator mode: `/whoami` reports the operator's full access with
    /// no development headers on the request. This is the runtime signal the
    /// ops console reads to enable deploy/namespace affordances.
    #[tokio::test]
    async fn whoami_reports_operator_in_auth_off_mode() -> Result<(), Box<dyn std::error::Error>> {
        let state = auth_off_state().await?;
        let response = workflow_router(state.clone())
            .oneshot(
                Request::builder()
                    .uri("/whoami")
                    .body(body::Body::empty())?,
            )
            .await?;
        assert_eq!(response.status(), StatusCode::OK);
        let body: Value = read_json(response).await?;
        assert_eq!(body["auth_enabled"], serde_json::json!(false));
        assert_eq!(body["all_namespaces"], serde_json::json!(true));
        assert_eq!(body["subject"], serde_json::json!("operator"));
        assert_eq!(body["namespaces"], serde_json::json!([]));
        // The operator's grants live ONLY in the vocabulary walk now — the
        // per-word bools were duplicate truth and are gone. In auth-off mode
        // every word is held.
        let listed = body["grants"]
            .as_array()
            .ok_or("`/whoami` must carry a `grants` array")?;
        assert!(
            listed
                .iter()
                .all(|row| row["granted"] == serde_json::json!(true)),
            "auth-off mode grants the operator every word: {listed:?}"
        );
        Ok(())
    }

    /// THE VOCABULARY PIN. Every word in the grammar appears in `/whoami`'s
    /// `grants` list with its header, claim, and description.
    ///
    /// It walks [`GRANT_WORDS`] itself, not a copy of it. That is the whole
    /// point: `deploy` spent its life as a `bool`, a claim key, and a repeated
    /// header literal with no list anywhere, so a SECOND word could have been
    /// parsed, carried, and enforced while appearing in nothing an operator
    /// could read. A word added to the grammar and not to the descriptor is a
    /// grant that is enforceable and undiscoverable — it fails HERE.
    #[tokio::test]
    async fn whoami_lists_every_word_in_the_grant_vocabulary()
    -> Result<(), Box<dyn std::error::Error>> {
        // Vacuity control: an emptied vocabulary would satisfy the loop below.
        assert!(
            !GRANT_WORDS.is_empty(),
            "the grant vocabulary is empty, so this pin measures nothing"
        );

        let state = auth_off_state().await?;
        let response = workflow_router(state.clone())
            .oneshot(
                Request::builder()
                    .uri("/whoami")
                    .body(body::Body::empty())?,
            )
            .await?;
        assert_eq!(response.status(), StatusCode::OK);
        let body: Value = read_json(response).await?;
        let listed = body["grants"]
            .as_array()
            .ok_or("`/whoami` must carry a `grants` array")?;

        assert_eq!(
            listed.len(),
            GRANT_WORDS.len(),
            "`grants` lists {} words for a vocabulary of {}: {listed:?}",
            listed.len(),
            GRANT_WORDS.len()
        );
        for grant in GRANT_WORDS {
            let described = listed
                .iter()
                .find(|row| row["word"] == serde_json::json!(grant.word()))
                .ok_or_else(|| {
                    format!(
                        "`{}` is in the grant vocabulary but not in `/whoami`'s grants list",
                        grant.word()
                    )
                })?;
            assert_eq!(
                described["header"],
                serde_json::json!(grant.header()),
                "`{}` is described with the wrong header",
                grant.word()
            );
            assert_eq!(
                described["claim"],
                serde_json::json!(grant.claim()),
                "`{}` is described with the wrong claim",
                grant.word()
            );
            assert_eq!(
                described["description"],
                serde_json::json!(grant.description()),
                "`{}` is described with the wrong description",
                grant.word()
            );
            assert_eq!(
                described["granted"],
                serde_json::json!(true),
                "the auth-off operator must hold `{}`",
                grant.word()
            );
        }
        Ok(())
    }
}