Extension Points (SPI)¶
Rift's storage and observation seams are traits in rift-mock-core. An embedding host implements a
trait and injects it through a builder method on ImposterManager; if you don't, Rift uses its
built-in in-memory implementation. The built-ins never fail — a custom
backend may, and Rift surfaces that failure explicitly (see Backend errors).
All injection is via ImposterManager builder methods:
use std::sync::Arc;
use rift_mock_core::imposter::ImposterManager;
let manager = ImposterManager::new()
.with_flow_store_provider(Arc::new(MyFlowStores))
.with_sequencer(Arc::new(MySequencer))
.with_request_journal(Arc::new(MyJournal))
.with_proxy_store(Arc::new(MyProxyStore))
.with_event_listener(Arc::new(MyListener))
.with_response_decorator(Arc::new(MyDecorator))
.with_no_match_interceptor(Arc::new(MyRescue))
.with_exchange_inspector_provider(Arc::new(MyInspectors));
Then pass Arc::new(manager) to ServerBuilder::manager(...) (see
Embeddable Server).
FlowStoreProvider — custom flow-state backend¶
Provide a flow-state store per imposter, or return None
to defer to the built-ins (in-memory).
pub trait FlowStoreProvider: Send + Sync {
/// Return a store for this imposter, or `None` to defer to the built-ins.
fn provide(&self, config: &ImposterConfig) -> Option<Arc<dyn FlowStore>>;
}
Inject with .with_flow_store_provider(Arc<dyn FlowStoreProvider>).
A provider overrides the imposter's own _rift.flowState: when it returns a store, that store is
used. It cannot report a failure — provide returns Option, so declining falls through to the
built-in in-memory store. It is the only flow-store seam; _rift.flowState has no backend name to
select a store by.
Implementing FlowStore¶
A provider hands back an Arc<dyn FlowStore> (rift_mock_core::flow_state::FlowStore). Required:
get, set, exists, delete, increment, set_ttl. Defaulted, so an older
implementation keeps compiling: is_blocking, increment_by, set_key_ttl, clear_flow, compare_and_set (a non-atomic get-then-set; a real backend should override it),
flow_ids, entry_count.
is_blocking()(defaultfalse): returntrueif calls do network or disk I/O. The request path then runs flow-store calls onspawn_blockingso a slow backend cannot stall a tokio worker. Annotations your store records there reach theResponseDecorator(#987 fixed them being dropped on that thread).flow_ids()/entry_count(flow_id)(issue #962): list the flow ids that have live state, and count the live keys under one. Both returnResult<Option<_>>, and the default isOk(None), meaning "this store cannot enumerate". That is different fromSome(vec![])/Some(0), which mean "it can, and there are none". The built-in in-memory store enumerates. A networked store may deliberately returnNone, because an admin screen should not trigger a scan over a shared keyspace. Keep the two answers distinct, so a UI shows "unsupported" rather than "empty".
ResponseSequencer — custom response cycling¶
Owns the per-stub cursor that drives multiple-response cycling and repeat (see
Behaviors → repeat).
pub trait ResponseSequencer: Send + Sync {
/// Atomically advance and return the response index, honoring per-response repeats.
fn next(&self, key: SequenceKey<'_>, response_count: usize, repeats: &[u32]) -> Result<usize>;
/// Return the upcoming response index without advancing.
fn peek(&self, key: SequenceKey<'_>, response_count: usize, repeats: &[u32]) -> Result<usize>;
/// Reset cursors: one stub's (`Some(stub_key)`) or every cursor on the port (`None`).
/// Also the GC hook — called on stub delete, a stub replace by index, bulk stub replace, and
/// imposter teardown.
fn reset_scope(&self, port: u16, stub_key: Option<&str>);
}
Inject with .with_sequencer(Arc<dyn ResponseSequencer>).
response_count and the returned index are cycle positions. They equal response indices
except after a proxyAlways recording was appended to a stub in the middle of its cycle (#1513):
the stub then cycles in Mountebank's queue order, and Rift hands the sequencer that order's length
with every repeat 1 and maps the position it returns to a response. The order starts at the
queue's head, and Rift calls reset_scope(port, Some(<old stub_key>)) whenever a stub's order is
built, changed or dropped, so the cursor restarts there whether the backend keys by slot or by
stub_key. A sequencer that only cycles opaque indices needs no change. The append itself reads
the cursor with peek and assumes the
start of a repeat run, so an append in the middle of an authored repeat run under a custom
sequencer splits it one answer later than Mountebank would. Embedders that place recordings
themselves append with ImposterManager::append_stub_responses_by_id to get this order; a
replace_stub_by_id with the extended list serves it in list order.
RequestJournal — custom recorded-requests store¶
Backs recordRequests, numberOfRequests, and the savedRequests admin surface.
pub trait RequestJournal: Send + Sync {
/// Called for EVERY request (even when body recording is off) — backs `numberOfRequests`.
fn note_request(&self, port: u16);
/// `flow_id` is the request's resolved flow (per the imposter's `flowIdSource`).
fn record(&self, port: u16, flow_id: &str, req: RecordedRequest);
fn read(&self, port: u16) -> JournalRead;
/// Clears entries AND resets the request count. Fallible — a remote store may fail.
fn clear(&self, port: u16) -> anyhow::Result<()>;
fn retain(&self, port: u16, keep: &dyn Fn(&RecordedRequest) -> bool);
/// Clear just one flow's entries. Fallible.
fn clear_flow(&self, port: u16, flow_id: &str) -> anyhow::Result<()>;
fn count(&self, port: u16) -> u64;
// Defaulted — override for richer behaviour:
fn read_filtered(&self, port: u16, keep: &dyn Fn(&RecordedRequest) -> bool) -> JournalRead;
/// Stable per-port indices (the `since=` cursor). `None` = unsupported (the default).
fn read_since(&self, port: u16, since: Option<u64>,
keep: &dyn Fn(&RecordedRequest) -> bool) -> Option<JournalReadSince>;
/// Default calls `record` and returns `None`. Never point `record` back at it without overriding it.
fn record_indexed(&self, port: u16, flow_id: &str, req: RecordedRequest) -> Option<u64>;
/// Second writes after the entry exists (no-ops by default).
fn attach_match(&self, port: u16, index: u64, outcome: MatchOutcome);
fn attach_response(&self, port: u16, index: u64, status: u16, latency_ms: u64);
}
The attach_* hooks fill in matchOutcome, and since issue #940 also status and latencyMs, on
an entry recorded earlier. A backend without stable indices cannot address an entry, so it never
carries these fields. That is not an error. RecordedRequest also has a node: Option<String>
that the engine never sets: a journal spanning several nodes stamps it in its own record.
Note that clear and clear_flow are fallible (anyhow::Result<()>): clearing is a correctness
operation whose postcondition ("the data is gone") a remote backend can fail to guarantee, so the
failure propagates rather than being swallowed. Inject with .with_request_journal(Arc<dyn RequestJournal>).
ProxyRecordingStore — custom proxy-recording store¶
Backs proxy record/replay: claims the right to record a response once per request signature, then stores and looks up recordings.
pub trait ProxyRecordingStore: Send + Sync {
/// First caller per `(port, signature)` wins the right to record once.
/// `Err` = backend unavailable (built-ins never fail).
fn try_claim(&self, port: u16, sig: &RequestSignature) -> Result<ClaimOutcome>;
/// The claim the engine takes: `mode` is the proxy response's own. Defaults to `try_claim`.
fn try_claim_as(&self, port: u16, sig: &RequestSignature, mode: ProxyMode)
-> Result<ClaimOutcome> { /* -> try_claim */ }
/// Release a claim the engine will not settle, so the signature is retryable.
fn release_claim(&self, port: u16, sig: &RequestSignature, token: ClaimToken);
fn record(&self, /* port, sig, response, token */) -> Result<()>;
/// Settle the claim once the generated stub exists, before it is published.
/// Defaults to `record`, so implementing it is optional.
fn complete(&self, /* port, sig, token, response */
publication: &StubPublication<'_>) -> Result<()> { /* -> record */ }
/// `true` = this store publishes generated stubs itself; the engine then inserts none.
fn publishes_stubs(&self) -> bool { false }
fn lookup(&self, port: u16, sig: &RequestSignature) -> Option<RecordedResponse>;
/// Forget the proxyOnce recording made under `sig`: a stub edit removed its stub (#1386).
/// Defaults to a no-op.
fn forget_recorded(&self, port: u16, sig: &RequestSignature) -> Result<()> { Ok(()) }
fn clear(&self, port: u16);
}
Its typed error is ProxyStoreError, #[non_exhaustive]. Inject with
.with_proxy_store(Arc<dyn ProxyRecordingStore>).
A recording lives as long as its stub (issue #1386). When a delete, a replace or a reload
removes a stub the engine inserted for a proxyOnce recording, it calls
forget_recorded(port, sig) with the signature that recording was claimed under. The next
request with that signature is then claimed, forwarded and recorded again, as in Mountebank. A
claim still in flight must be left to settle. The default does nothing, so a store that cannot
forget keeps replaying, as before. The engine logs an Err and does not undo the stub edit. A
store that publishes stubs itself sees no such call, because the engine inserted nothing; it
forgets on its own when the published stub goes.
The engine claims with try_claim_as, passing the mode of the proxy response being served
(issue #1387). Each proxy stub has its own mode, as in Mountebank, so only a proxyOnce request
is gated: a proxyAlways request is always granted a claim, even on an imposter whose other proxy
stub is proxyOnce, and a proxyOnce stub added after the imposter was created gates like one
declared with it. The default implementation calls try_claim, so a store that decides the mode
itself keeps doing so; override try_claim_as to gate on the mode the engine names. A store that
wraps another must forward try_claim_as, or the wrapped store applies its constructor's mode. The built-in
LocalProxyStore overrides it, keeps proxyAlways recordings apart from proxyOnce ones so a
proxyAlways recording never answers a proxyOnce claim, and applies its constructor's mode only
to a direct try_claim.
RequestSignature is #[non_exhaustive]: build it with RequestSignature::new (and .with_body(&[u8])
or .with_predicates(&[Value])), not a struct literal. It carries two optional hashes, each omitted
from the serialized form when None:
body_hash: Option<u64>— FNV-1a 64 of the request body,Nonefor a body-less request. The engine sets it only for a proxy withoutpredicateGenerators(issue #1317).predicates_hash: Option<u64>— FNV-1a 64 of the predicates the proxy'spredicateGeneratorsproduced for the request,Nonewithout generators (issue #1333). A request that reaches aproxyOnceproxy stub with generators missed every stub recorded before it, so it has to be forwarded and recorded, not answered with another identity's recording; keying the claim on the generated predicates does that and keeps each identity recorded exactly once. When generation fails the engine does not consult the store at all: it forwards and records nothing, then answers500(or, for an invalid generated predicate, serves the upstream response taggedx-rift-generator-error).
A store that serializes the signature as its key misses pre-upgrade recordings once: requests that
had a body (since #1317), and every recording made under predicateGenerators (since #1333). With
generators that miss is visible only for a request no recorded stub matches; it is forwarded and
recorded again under the new key. During a rolling upgrade an old and a new node use different keys
for the same generated identity, so each may record it once; an old node that decodes a new node's
signature drops predicates_hash and keys it the old way, so until every node is upgraded a miss
can still be answered with another identity's recording.
| Variant | Engine response |
|---|---|
Unavailable(String) |
Degrade: forward upstream without recording. Use it when the store only helps persistence and the engine still enforces exactly-once itself. |
Refused(BackendUnavailable) (issue #990) |
Fail the request without calling the upstream. Use it when the store is the exactly-once arbiter (shared or clustered) and could not decide. Forwarding would let every request during the outage reach the upstream. Both the stub proxy and defaultForward answer 503 through backend_error_response (see Backend errors). |
ClaimOutcome::InFlight is unaffected: a claim was serialized, so that request still forwards.
release_claim can be called from a destructor. The engine holds a won claim in a guard that
releases it when the request is dropped — a client that disconnected mid-request, an imposter
stopped while a request was in flight, a panic — as well as after a failed forward or a failed
behavior (issue #1193). So release_claim runs synchronously on the dropping thread, possibly
while the runtime is shutting down: it must not block for long, must not assume an async context,
and must ignore a token whose claim is already settled or re-taken (the built-in store does). A
store that releases over the network should hand the release off rather than wait for it.
Publishing stubs from the store¶
record is called before the engine has generated the stub the recording will be replayed from,
which is fine for an in-process store but not for one that must publish that stub somewhere durable
or replicated: it would have to commit "this signature is Recorded" against a stub that does not
exist yet, and a publication that then failed would leave the signature permanently recorded with
nothing to replay.
complete is the seam for that. It is called instead of record whenever a stub was generated
(predicateGenerators, addWaitBehavior or addDecorateBehavior configured, and generation
succeeded), and always before the stub is published — so a store can make its own commit
conditional on its publication ack. Returning Err releases the claim, leaving the signature
retryable; the client still receives the upstream response, because the upstream call succeeded and
only recording failed. When nothing is configured to generate a stub there is nothing to publish
and the engine calls record as before. When predicate generation fails the engine takes no claim
at all, so neither is called.
publishes_stubs() == true additionally makes the engine skip its own in-process stub insertion, so
the store is the sole publisher. It must then honour the position the engine resolved, which arrives
alongside the stub:
pub struct StubPublication<'a> {
pub stub: &'a Stub, // the generated stub, exactly as the engine would insert it
pub placement: StubPlacement, // where it belongs relative to the anchor
pub proxy_to: &'a str, // `proxy.to` of the proxy stub that answered
pub anchor_index: Option<usize>, // that stub's index when the recording was made (#1389)
}
pub enum StubPlacement {
/// proxyOnce: insert BEFORE the anchor, so the recording matches first next time.
BeforeProxy,
/// proxyAlways: merge the responses into the first stub AFTER the anchor with structurally
/// equal predicates (an empty list included), else append at the END of the list.
AfterProxyMerging,
}
The anchor is the stub that answered, as Mountebank's stubIndex() (issue #1389). Use
anchor_index when it is Some. When it is None, because the stub was edited away mid-request
or no stub answered, fall back to the first stub proxying to proxy_to, which is what the engine
does. With neither, a proxyOnce recording goes last.
Both additions are defaulted, so a store written against the pre-complete trait keeps compiling
and behaving identically. StubPublication and StubPlacement are #[non_exhaustive] — match the
placement with a _ arm and treat an unknown one as unpublishable rather than guessing a position.
Two consequences worth knowing before you set publishes_stubs() == true:
- Skipping is unconditional. When no claim was won — a concurrent
proxyOnceloser, ortry_claimreporting the backend unavailable — there is nocompletecall and no local insertion, so that request's stub is published nowhere. The engine will not quietly keep a private copy your store's peers do not have. - Predicates are generated before the claim. With
predicateGeneratorsthey are part of the signature, sotry_claimruns after them and the claim is not held across apredicateGenerators.injectscript (issue #1333).
ImposterEventListener — observe reconciliation¶
Get a callback whenever the imposter set changes (startup load, POST /admin/reload, admin CRUD).
See Hot Reload for how the incremental diff produces these.
pub enum ImposterEvent {
Created(u16), // port created
Replaced(u16), // port replaced (imposter-level change)
StubsChanged(u16), // in-place stub patch
EnabledChanged { port: u16, enabled: bool }, // serve/pause flag flipped
Deleted(u16), // port deleted
AllDeleted, // every imposter removed (a partial delete-all emits Deleted per port instead)
}
pub struct EventContext {
pub principal: Option<String>, // who caused the change
}
pub trait ImposterEventListener: Send + Sync {
fn on_event(&self, event: &ImposterEvent, ctx: &EventContext);
}
on_event is called synchronously on the mutating path — keep implementations fast and
non-blocking. Inject with .with_event_listener(Arc<dyn ImposterEventListener>).
Attribution — who changed it¶
An event says what changed; ctx.principal says who (issue #855). It is populated from
AuthzDecision::Allow { principal } when an AdminAuthorizer
is installed, which is what makes these events usable as an audit trail instead of something you
have to correlate against request logs out of band.
principal is None whenever there is genuinely nobody to name, and it is never guessed:
| Situation | ctx.principal |
|---|---|
| No authorizer installed | None — the seam stays inert by default |
Authorizer returned AuthzDecision::allow() |
None — allowed, but nobody identified |
Authorizer returned Allow { principal: Some(p) } |
Some(p) |
POST /admin/reload (re-reads --configfile) |
Some(p) — it is an admin request like any other |
The startup config read, or your own direct ImposterManager call |
None — no request behind the change |
Note the third and fourth rows together: what decides attribution is whether a request caused the
change, not where the config came from. The same --configfile yields None when read at boot and
Some(p) when re-read through POST /admin/reload.
Attribution rides on the listener signature, not on ImposterEvent. The enum is unchanged, so
an existing match over it keeps compiling untouched. EventContext is #[non_exhaustive] so a
later attribution field is not another break — construct one in your own tests from
EventContext::default() and assign fields.
Three bounds worth knowing:
AllDeletedcarries no port, so a fleet-wide delete records the actor but no per-resource target. That is the shape, not a defect. It is emitted only when every imposter went: if one could not be deleted (its--datadirfile could not be removed), each port that was deleted gets aDeletedinstead, andImposterManager::delete_allreturns aDeleteAllReportlisting thedeletedconfigs and thefailedports (#1124).- The principal travels as a
tokiotask-local scoped to the admin request. A mutation you drive from atokio::spawned task of your own does not inherit that scope and will reportNone. - Only this listener is attributed. The admin SSE bus (
GET /events) publishes the same lifecycle changes with no principal, so an audit collector pointed at/eventsgets what but not who. If you need attribution, consume it here.
ResponseDecorator — cross-cutting response headers¶
A hook to add operational headers to outgoing responses based on the request phase and per-request annotations.
pub trait ResponseDecorator: Send + Sync {
fn decorate(
&self,
phase: ResponsePhase,
req_port: Option<u16>,
annotations: &[(&'static str, String)],
headers: &mut HeaderMap,
);
}
Inject with .with_response_decorator(Arc<dyn ResponseDecorator>).
NoMatchInterceptor — rescue the no-match path¶
Consulted when a request matched no stub, before the defaultForward / defaultResponse /
empty-200 fallthrough (issue #819). Its purpose is a safety net on the data plane: an embedder
whose replicated config is momentarily behind can wait a bounded interval and retry the match once,
paying nothing on requests that already matched.
use rift_mock_core::extensions::no_match::{
NoMatchContext, NoMatchDirective, NoMatchInterceptor,
};
struct WaitForCatchUp;
impl NoMatchInterceptor for WaitForCatchUp {
fn on_no_match<'a>(
&'a self,
ctx: NoMatchContext<'a>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = NoMatchDirective> + Send + 'a>> {
Box::pin(async move {
if caught_up_within(ctx.port, Duration::from_millis(500)).await {
NoMatchDirective::RetryMatch
} else {
NoMatchDirective::Proceed
}
})
}
}
let manager = ImposterManager::new()
.with_no_match_interceptor(Arc::new(WaitForCatchUp));
Contract:
- Only on a genuine no-match. Never for matched requests, disabled imposters, matcher errors, or the debug path — the hot path pays nothing when nothing missed.
- It fires even when a default IS configured, deliberately. Under replication lag the right stub
may be momentarily missing, and forwarding upstream would misdirect the request; rescue outranks
forwarding.
Proceedthen falls through exactly as today. - At most one retry per request. A rescued hit is served as a normal match — indistinguishable
downstream — and a second miss falls through exactly as
Proceedwould. "Downstream" is precise: the retry re-evaluates predicates, so a predicateinjectscript runs a second time and itsstatemutations andloggeroutput are committed twice; a rescued request can also spend twoscriptEngine.timeoutMsbudgets in matching. - Implementations must be bounded: the request is parked while the future runs.
- Annotations (
extensions::decorate::annotate) are visible wherever aResponseDecoratoris wired — the serve loop. On the/__rift/gateway they are inert, since that path has neither an annotation scope nor a decorator. - Unbound ports are out of scope. A request to a port with no imposter never reaches an imposter
handler (no listener, or the gateway
404s first), so there is nothing to hang a hook on. Cover that window with your own readiness gating.
Registering no interceptor leaves behaviour byte-identical, on both the serve loop and the gateway.
ExchangeInspector — policy on live exchanges¶
A synchronous hook pair on an imposter's live traffic (issue #966). It can reject a request before
matching and replace a response before it is written. Use it for request linting, contract
validation, compliance capture or a chaos veto. It sits where no other seam does: NoMatchInterceptor
fires only on a miss, and ResponseDecorator may only add headers.
use rift_mock_core::extensions::exchange_inspector::{
ExchangeInspector, ExchangeInspectorProvider, InspectRequest, InspectResponse, InspectVerdict,
};
pub trait ExchangeInspector: Send + Sync {
/// After body collection and journaling, before stub matching.
fn inspect_request(&self, req: &InspectRequest<'_>) -> InspectVerdict;
/// After the response is built, before it is written and decorated.
fn inspect_response(&self, req: &InspectRequest<'_>, resp: &InspectResponse<'_>) -> InspectVerdict;
}
pub trait ExchangeInspectorProvider: Send + Sync {
/// Consulted once per imposter, at creation. `None` = no hooks for that imposter.
fn provide(&self, config: &ImposterConfig) -> Option<Arc<dyn ExchangeInspector>>;
}
pub enum InspectVerdict {
Proceed,
Reject { status: u16, content_type: String, body: Bytes },
}
Inject with .with_exchange_inspector_provider(Arc<dyn ExchangeInspectorProvider>).
InspectRequestborrowsport,method,path,query(without the?),headers(every value),body(text, or base64 whenmodeis binary) andmode.InspectResponseborrowsstatus,headersandbodybytes.- Request-side rejection happens before matching, so a rejected request never advances a response cycler, a scenario or a match count. It is still journaled, with the rejection's status.
- The response hook runs on every path: the serve loop, the
/__rift/gateway and in-process dispatch. It runs before the decorator and CORS. It is not called for a response the request hook produced. - Early exits see neither hook: a disabled imposter, a
413, a body-read error. - Inert by default. With no provider, or a provider returning
None, the cost is oneis_nonecheck per phase. The hooks are synchronous on purpose: keep I/O off them.
Detecting a TCP fault in-process¶
A program that calls handle_imposter_request directly receives a placeholder carrier response
when a stub injects a TCP fault, where a socket client would see the connection aborted. Classify it
with rift_mock_core::tcp_fault_carrier(&response), which returns the canonical fault name or None.
To branch per fault, read the #[non_exhaustive] TcpFaultKind extension (issue #984). Both are
also re-exported from rift_http_proxy. See
Fault injection → Detecting a fault in-process.
Do not classify on the x-rift-fault header.
dispatch_to_port returns the same carrier untouched. A listener you own can instead apply it as the
real fault, the way the admin gateway and the front door do (issue #1234): wrap each accepted
TcpStream in FaultIo::new(stream, cell) with a fresh FaultCell::new(), and pass each finished
response through rift_http_proxy::gateway::apply_tcp_fault(response, request_version, &cell). On
HTTP/1 that arms the cell and FaultIo aborts the socket on the write. On HTTP/2 it returns
Err(InjectedFault), which your service returns so hyper resets that one stream. is_injected_fault
recognises the resulting connection error anywhere in an error's source chain, so it need not be
logged as a failure. Pass each response through arm_linger_if_requested(&response, request_version,
&cell) too: a 413/503 body refusal carries LingerOnClose, and on HTTP/1 this makes FaultIo
drain the unread body before closing, so the client gets the refusal instead of a reset (issue
1460). The drain is timed with tokio::time, so an armed FaultIo must be driven on a Tokio¶
runtime with its time driver enabled, and it can hold a graceful shutdown for up to 2 s. FaultCell, FaultIo, InjectedFault, is_injected_fault, LingerOnClose and
arm_linger_if_requested are exported from both rift_mock_core and rift_http_proxy. See
Fault injection → Serving from your own listener.
Observing front-door route dispatches¶
RouteObserver (rift_http_proxy::front_door) is called once for each request a route claims, with
the route id. It is meant for a per-route hit counter (issue #961). Pass it to
bind_front_door_with_observer(addr, manager, routes, Some(observer)). bind_front_door is the same
call without an observer. See
Front door → Observing dispatches.
AdminAuthorizer — per-request admin authorization¶
The built-in --api-key gate yields access, not an identity: every caller that presents the key
is equivalent. AdminAuthorizer lets an embedder decide per request, with the route already parsed.
AuthzRequest.credential is the credential the built-in gate checked: the Authorization header
value verbatim or, when that header is absent, the x-api-key value (Mountebank's --apikey
spelling; Authorization wins when both are sent). A raw token in x-api-key carries no scheme
prefix. Treat it as a secret and never log it.
use rift_mock_core::extensions::authz::{
AdminAuthorizer, AuthzDecision, AuthzRequest, actions,
};
struct TenantAuthorizer;
impl AdminAuthorizer for TenantAuthorizer {
fn authorize(&self, req: AuthzRequest<'_>) -> AuthzDecision {
let principal = match req.credential.and_then(lookup_principal) {
Some(p) => p,
None => return AuthzDecision::Deny { reason: "unknown principal" },
};
match req.action {
actions::IMPOSTER_DELETE if !principal.may_delete => {
AuthzDecision::Deny { reason: "delete not permitted" }
}
_ => AuthzDecision::Allow { principal: Some(principal.name) },
}
}
}
let server = ServerBuilder::from_cli(cli)
.admin_authorizer(Arc::new(TenantAuthorizer))
.start()
.await?;
Install nothing, change nothing. With no authorizer registered the api-key comparison decides alone, exactly as before.
Ordering is part of the contract¶
Authentication runs first and unconditionally; only then is the route parsed and the hook
consulted. That order is load-bearing — if authentication ran after route parsing, an
unauthenticated request to an unknown path would answer 404 instead of 401 and unknown-path
responses would become a route-existence oracle for anonymous callers.
- Missing or invalid credential →
401, and the hook is not consulted. Denyon an authenticated request →403with the standard error envelope.- A path matching no route → the ordinary
404; the hook is not consulted, because nothing runs.
Actions¶
action is a stable string rather than an enum, so an embedder can extend its own vocabulary
without waiting for an upstream release. The values upstream emits are constants in
extensions::authz::actions:
| Action | Routes |
|---|---|
system.read |
GET /, /health, /config, /logs, /metrics |
system.write |
POST /admin/reload |
imposter.read |
any GET under /imposters, and the per-imposter SSE alias |
imposter.write |
mutating POST/PUT on an imposter, its stubs, scenarios or flow state |
imposter.delete |
any DELETE under /imposters — and PUT /imposters, which reconciles the whole set and so removes everything not in the payload |
imposter.verify |
POST /imposters/:port/verify, which mutates nothing |
events.read |
GET /events, the cross-imposter stream |
intercept.read / intercept.write |
/intercept and below |
events.read is separate from imposter.read on purpose: /events carries recorded requests from
every imposter, so granting read on one port must not implicitly grant all of them.
Targeting: port, space, params, scope¶
port, space and params come from the router's own parser, so they cannot drift from what the
handler will actually act on.
scope is different. It is read verbatim from the x-rift-scope request header and exists
because some routes have no target to key on — POST /imposters creates a port rather than naming
one. Because it is a request header, it is caller-asserted: any authenticated caller can set it
to any value. Cross-check it against what the credential entitles the caller to; never use it
directly as the authorization subject.
The data plane is never authorized. Gateway traffic (/__rift/...) skips this hook for the same
reason it skips the api key — it is app-under-test traffic, and requiring an admin identity for it
would force the application to carry the admin credential.
Fronting the admin API yourself¶
Everything above assumes your requests reach upstream's own request loop, which parses the route and calls the hook for you. If instead you terminate some admin routes yourself and proxy the rest, you have no parsed route to authorize against — so call the same classifier upstream does:
use rift_http_proxy::admin_api::authz::{SCOPE_HEADER, classify, credential};
use rift_mock_core::extensions::authz::AuthzRequest;
// `None` = not an authorizable admin route: the data plane (`/__rift/...`), or a path that
// matches no route at all and so reaches no handler.
if let Some(target) = classify(req.method(), req.uri().path()) {
let params: Vec<(&str, &str)> = target
.params
.iter()
.map(|(name, value)| (*name, value.as_str()))
.collect();
let decision = my_authorizer.authorize(AuthzRequest {
credential: credential(req.headers()),
action: target.action,
port: target.port,
space: target.space.as_deref(),
scope: req.headers().get(SCOPE_HEADER).and_then(|v| v.to_str().ok()),
params: ¶ms,
});
}
Do not write a second parser for this. Two parsers diverging is a silent bypass, and this codebase
has already shipped it: a classifier that filtered empty path segments — which hyper does not
normalise — saw a different route from the one dispatched, so PUT /imposters/:port/scenarios//state
mutated a scenario the classifier had never seen. Under /imposters that is now unrepresentable,
because classify calls the router's own parser and matches its route enum exhaustively — which is
exactly the property a hand-written copy gives up. SCOPE_HEADER likewise spares you a copied
header literal, and credential spares you a copied credential lookup: it picks Authorization,
else x-api-key, by the same rule as the built-in gate.
The same applies to validating a request body you terminate. If you answer
POST /imposters/:port/spaces/:flowId/stubs yourself, apply
rift_http_proxy::admin_api::not_a_stub_reason(&payload) -> Option<String> (issue #1012). It returns
the reason the body is not a stub, such as a {"stub": …} envelope or no recognised stub field, or
None if the body is acceptable. Render the reason in your own error envelope as a 400. A private
copy of the stub-field list goes stale when a field is added, and then rejects valid stubs.
Which routes exist: ADMIN_ROUTES¶
classify answers "what is this request". A front that terminates some routes and forwards the rest
also needs "what requests exist", so it can check that every one is handled on one side.
rift_http_proxy::admin_api::ADMIN_ROUTES is that set: every (method, path) pair the admin
listener dispatches, with each path parameter named as classify reports it in
AuthzTarget::params ({port}, {space}, {stubIndex}, {stubId}, {scenario}, {key}).
{stubIndex} is the raw path segment: any non-empty one names the route, including abc or 01,
which the handler then refuses with a 404 without changing anything. The two
event streams (/events and /imposters/{port}/savedRequests/stream) are GET only.
use rift_http_proxy::admin_api::{ADMIN_ROUTES, RouteFamily};
// Intercept routes are served only by a listener built `with_intercept`.
let served = ADMIN_ROUTES
.iter()
.filter(|route| route.family != RouteFamily::Intercept);
for route in served {
assert!(
i_handle(&route.method, route.path) || i_forward(&route.method, route.path),
"{} {} is served upstream and accounted for nowhere here",
route.method,
route.path,
);
}
Upstream's tests hold the table to the listener: every per-imposter route variant and every
authorization action must appear, every entry must be dispatched by a live listener, and every
method the table does not list on a listed path must not be. A new route on an existing path, or a
new per-imposter route, therefore reaches you as a new entry. A wholly new top-level path is the one
addition those tests cannot see on their own, so it is a review rule upstream. The gateway
(/__rift/...) is not in the table, for the same reason classify answers None for it.
Match a request's path against the table only after rift_http_proxy::admin_api::admin_path has
normalised it, as the listener does before it routes: it drops one trailing / and spells route
words as the table does (/imposters/4545/STUBS/0/ is /imposters/4545/stubs/0), leaving {param}
segments and unknown paths as sent. Matching the raw path would forward a request the listener then
serves as a terminated route.
use rift_http_proxy::admin_api::admin_path;
assert_eq!(admin_path("/imposters/4545/STUBS/0/"), "/imposters/4545/stubs/0");
assert_eq!(admin_path("/imposters/"), "/imposters");
Backend errors and annotations¶
A custom backend signals unavailability by attaching BackendUnavailable to a failed operation's
error (backends wrap with .context(...), and the marker survives the chain):
backend_error_response(&anyhow::Error) maps such an error to a structured 503; any other error
maps to 500. This is how a down remote store becomes a clean 503 to the API caller rather than a
silent fallback. The body carries the standard error envelope, with feature naming which backend
failed:
{
"errors": [{
"code": "503",
"type": "backend unavailable",
"message": "flowState: store connection refused",
"feature": "flowState",
"detail": "store connection refused"
}]
}
Removed in 0.18.0: the top-level
error/feature/detailduplicates (deprecated in 0.16.0, #801) are gone;errors[0]is the only shape.
Per-request operational metadata travels through a tokio task-local annotation scope:
annotate(key: &'static str, value: String) records a (key, value) that a ResponseDecorator later
reads. It still arrives when the engine ran the work on spawn_blocking (a store with
is_blocking() == true, such as a networked one). Before #987, annotations made on that thread were silently
dropped. This is the same mechanism behind the script/behavior error headers — e.g. a script that hits a
down flow-store backend records an annotation, and a ctx.state call against that backend is
fail-loud: it raises a script error that surfaces to the response rather than silently returning a
default (see Scripting → ctx.state and ctx.store).