feat(tools): the ingest credential — the event door, write-only

The agent token is deliberately barred from /v1/ingest: the events
are the inbound channel, and the model must not feed it itself. But
the same bar closes the door on every honest integration too — a
sidecar poller with events to hand in had to hold the human token,
which is wider than its job. This adds the missing class: a third
bearer, the ingest token, answering the event door and nothing else.

`ingest_allowed` is the whole credential in one function — one
route row, `POST /v1/ingest` — symmetric with `agent_allowed` and
suite-covered the same way: the mock and the daemon's real router
both answer the new checks (a push succeeds with the contract header;
a watch read and a consent answer are 403). The middleware classifier
gains the third arm, boot refuses an ingest token equal to either
other credential — one value playing two roles silently widens the
scoped one — and the daemon provisions it to tool shells as
`RHEMA_INGEST_TOKEN`, so a tool that only pushes events never sees
the wider agent token at all.

The door is the first half of the extensions story (rhema-tools spec
§8): a self-timed sidecar is now a tier-1 tool with a credential
narrow enough to hold.
This commit is contained in:
vhaudiquet committed 2026-10-24 01:17:43 +02:00
1 parent 98bc0fd623
commit f96f8c4c1d
10 files changed
+210 -18

No files matched your search

+39 -6
View File
@@ -9,12 +9,12 @@ use crate::{ApiError, Auth, CallRequest, CallResponse, now_millis};
/// The first failure is returned, named by the check that failed; `Ok`
/// means the transport speaks the contract.
///
/// The suite assumes a daemon configured with both tokens — a human
/// one and an agent one — because the agent-token class is the
/// contract's load-bearing guarantee. A daemon running an open surface
/// (no tokens) cannot pass it, by design: the credential section of
/// the spec (§5) makes the scoped agent token a precondition of the
/// client tools.
/// The suite assumes a daemon configured with the tokens — the human
/// one, the agent one, and the ingest one — because the scoped
/// credential classes are the contract's load-bearing guarantee. A
/// daemon running an open surface (no tokens) cannot pass it, by
/// design: the credential section of the spec (§5) makes the scoped
/// agent token a precondition of the client tools.
pub async fn conformance<F, Fut>(call: F) -> Result<(), String>
where
F: Fn(CallRequest) -> Fut,
@@ -335,6 +335,39 @@ where
)
.await?;
// — The ingest credential: the event door, write-only —
ctx.expect_success(
"the ingest credential hands one event in",
req(
"POST",
"/v1/ingest",
Auth::Ingest,
Some(1),
Some(serde_json::json!({
"source": "ciwatch", "key": "ci:door/1", "payload": {"text": "ping"}
})),
),
)
.await?;
ctx.expect(
"the ingest credential cannot read the watches",
req("GET", "/v1/watches", Auth::Ingest, Some(1), None),
403,
)
.await?;
ctx.expect(
"the ingest credential cannot answer a consent",
req(
"POST",
"/v1/consents/c-1",
Auth::Ingest,
Some(1),
Some(serde_json::json!({"grant": true})),
),
403,
)
.await?;
// — The one error shape —
let missing = ctx
.expect(
+20
View File
@@ -77,6 +77,23 @@ pub fn agent_allowed(method: &str, path: &str) -> bool {
.any(|(route_method, pattern)| *route_method == method && match_pattern(pattern, &segments))
}
/// Whether the ingest credential may call this route: the event door
/// and nothing else. The ingest class is write-only by construction —
/// it can hand events in, and read nothing back — so its allowlist is
/// one row, and `false` for every method it does not name.
pub fn ingest_allowed(method: &str, path: &str) -> bool {
// The routes are daemon-side (`/v1/...`), but a daemon whose auth
// middleware runs inside its `/v1` nest sees the stripped path —
// accept both spellings, one allowlist.
let path = if path.starts_with("/v1") {
path
} else {
&format!("/v1{path}")
};
let segments: Vec<&str> = path.trim_matches('/').split('/').collect();
method == "POST" && match_pattern(&["v1", "ingest"], &segments)
}
/// Matches one allowlist pattern against a request's path segments.
fn match_pattern(pattern: &[&str], segments: &[&str]) -> bool {
match (pattern.first(), segments.first()) {
@@ -192,6 +209,9 @@ pub enum Auth {
Human,
/// The agent token: the [`agent_allowed`] routes only.
Agent,
/// The ingest credential: the event door ([`ingest_allowed`])
/// only, and nothing readable.
Ingest,
/// A bearer that matches neither configured token.
Invalid,
}
+20 -2
View File
@@ -8,6 +8,7 @@ use serde_json::{Value, json};
use std::sync::PoisonError;
use crate::ingest_allowed;
use crate::{
Auth, CONTRACT_VERSION, CallRequest, CallResponse, NotificationOut, NotificationPush, TaskAdd,
TaskOut, agent_allowed, now_millis,
@@ -19,6 +20,7 @@ use crate::{
pub struct MockClaw {
human_token: Option<String>,
agent_token: Option<String>,
ingest_token: Option<String>,
notifications: Mutex<Vec<NotificationOut>>,
tasks: Mutex<Vec<TaskOut>>,
watches: Mutex<Vec<Value>>,
@@ -29,10 +31,15 @@ pub struct MockClaw {
impl MockClaw {
/// A mock with the daemon's two tokens; `None` means that class is
/// unconfigured and its bearers are strangers.
pub fn new(human_token: Option<&str>, agent_token: Option<&str>) -> Self {
pub fn new(
human_token: Option<&str>,
agent_token: Option<&str>,
ingest_token: Option<&str>,
) -> Self {
Self {
human_token: human_token.map(str::to_owned),
agent_token: agent_token.map(str::to_owned),
ingest_token: ingest_token.map(str::to_owned),
notifications: Mutex::new(Vec::new()),
tasks: Mutex::new(Vec::new()),
watches: Mutex::new(Vec::new()),
@@ -46,7 +53,8 @@ impl MockClaw {
pub async fn call(&self, request: CallRequest) -> Result<CallResponse, String> {
// The bearer gate. With no tokens configured the surface is
// open; with either, a stranger is a 401.
let open = self.human_token.is_none() && self.agent_token.is_none();
let open =
self.human_token.is_none() && self.agent_token.is_none() && self.ingest_token.is_none();
if !open && matches!(request.auth, Auth::None | Auth::Invalid) {
return Ok(self.error(401, "unauthorized"));
}
@@ -59,6 +67,16 @@ impl MockClaw {
return Ok(self.error(403, "this route is not the agent token's"));
}
}
// The ingest class: the event door, contract version required —
// an integration speaks the same handshake the tools do.
if request.auth == Auth::Ingest {
if request.contract != Some(CONTRACT_VERSION) {
return Ok(self.error(409, "contract version mismatch"));
}
if !ingest_allowed(request.method, &request.path) {
return Ok(self.error(403, "this route is not the ingest token's"));
}
}
let segments: Vec<&str> = request.path.trim_matches('/').split('/').collect();
match (request.method, segments.as_slice()) {
+6 -2
View File
@@ -6,7 +6,11 @@ use rhema_tools_contract::{CallRequest, MockClaw, conformance};
#[tokio::test]
async fn the_suite_passes_against_the_mock_router() {
let mock = MockClaw::new(Some("human-token"), Some("agent-token"));
let mock = MockClaw::new(
Some("human-token"),
Some("agent-token"),
Some("ingest-token"),
);
let result =
conformance(|request: CallRequest| async { Ok(mock.call(request).await.unwrap()) }).await;
assert_eq!(result, Ok(()), "the mock router speaks the contract");
@@ -16,7 +20,7 @@ async fn the_suite_passes_against_the_mock_router() {
async fn an_open_mock_fails_the_suite() {
// No tokens: the agent-token guarantees the suite exists to check
// are gone. The suite must say so, not sail through.
let mock = MockClaw::new(None, None);
let mock = MockClaw::new(None, None, None);
let result =
conformance(|request: CallRequest| async { Ok(mock.call(request).await.unwrap()) }).await;
assert!(result.is_err(), "an open surface cannot pass the suite");