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:
1 parent
98bc0fd623
commit
f96f8c4c1d
10 files changed
+210
-18
No files matched your search
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user