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

+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,
}