feat(gateway): the loud ownership-wiring default (review 012 ruling)
- GatewayDispatch now refuses (FORBIDDEN, pre-dispatch) any op whose spec gates on access_control.resource_type when no ownership provider is wired — invoke, invoke_streaming, and invoke_sink alike - Predicate is resource_type, not resource_id_path as review 012 first drafted: AccessControl::check consults the provider whenever the resource type is set, including the list/owns_any case; the refusal message is operator-directed and names both opt-ins - Exemptions: resource_action-only ACLs (no provider is consulted on the wire either) and Internal-visibility ops (spec-404, no acknowledgment leak) - with_static_ownership_fallback(): declared opt-out to the ADR-011 static fallback for deployments with no runtime ownership model — the door contract becomes enforceable, not just documented - Superseded the pre-ruling pin gateway_without_provider_admits_only_the_static_claim with refusal/ opt-out/exemption tests (all three entry points covered) - ADR-048 amendment records the ruling; review 012 status updated Blast-radius survey: alkhttp is the only live GatewayDispatch consumer (unpublished, three unwired sites); alkblobs the only live resource_type op producer (serves native-wire, providers wired); no published 0.9.0 consumer relies on the silent fallback for such ops. Ships in the same unreleased batch as the review-012 amendment. Verification: cargo test (697 pass), cargo test --features gateway (720 pass), cargo clippy --all-features --all-targets -- -D warnings, cargo fmt --check, fuzz corpus replay (5 pass)
This commit is contained in:
1 parent
d0b8124064
commit
9899b37a82
4 files changed
+406
-89
No files matched your search
@@ -129,43 +129,73 @@ Amended:
|
||||
threading is opt-in, additive.
|
||||
|
||||
- **The door contract** (review 012's compensation pattern, blessed
|
||||
as a doc-contract line rather than accepted as the durable answer
|
||||
for anything beyond the no-ownership-model case): the ADR-011
|
||||
static fallback is correct *only* where a deployment genuinely has
|
||||
no runtime ownership model. A door (alkhttp feature doors, the LFS
|
||||
and `git` doors, hub/relay surfaces) serving an op whose
|
||||
authorization truth is a revocable grant table MUST wire the same
|
||||
provider the native wire path consults — visible-surface =
|
||||
authorized-surface. Without a wiring, `GatewayDispatch` on an
|
||||
External namespace-ACL op silently admits token-carried static
|
||||
claims: the spine cannot distinguish "no ownership model in this
|
||||
deployment" from "forgot to wire it", which is why the contract is
|
||||
a doc line and the loud default below is a separate, surveyed
|
||||
decision.
|
||||
as a doc-contract line and — since the loud default below ruled —
|
||||
enforced by the spine): the ADR-011 static fallback is correct
|
||||
*only* where a deployment genuinely has no runtime ownership model.
|
||||
A door (alkhttp feature doors, the LFS and `git` doors, hub/relay
|
||||
surfaces) serving an op whose authorization truth is a revocable
|
||||
grant table MUST wire the same provider the native wire path
|
||||
consults — visible-surface = authorized-surface.
|
||||
|
||||
- **Deferred, not decided here — the loud default:** making the spine
|
||||
refuse (an error envelope) an op whose spec carries
|
||||
`resource_id_path` when no provider is wired. The refusal is
|
||||
mechanically precise (the spine can see the spec), but it changes
|
||||
default outcomes for ownership-relevant ops — consumer-visible —
|
||||
and needs the blast radius surveyed (which doors route such ops
|
||||
through `GatewayDispatch` today) before it is ruled on. Tracked in
|
||||
review 012; decided after the survey. Until then the door contract
|
||||
above is the answer in force.
|
||||
- **The loud default (ruled 2026-10-17 — review 012):** the spine
|
||||
refuses (an error envelope, `FORBIDDEN`) a dispatched op whose spec
|
||||
carries `access_control.resource_type` when no ownership provider is
|
||||
wired — `invoke`, `invoke_streaming`, and `invoke_sink` alike,
|
||||
pre-dispatch. The survey (this session) found the blast radius
|
||||
minimal: alkhttp is the only live `GatewayDispatch` consumer in the
|
||||
family and it is unpublished, its three construction sites wire no
|
||||
provider, and alkblobs is the only live op producer with
|
||||
`resource_type` ACLs (its own serving sides are native-wire and
|
||||
correctly wired). The refusal message is operator-directed (names
|
||||
the wired opt-ins); fail-closed and indistinguishable in code from a
|
||||
normal ACL denial.
|
||||
|
||||
**Predicate refinement:** the refusal predicate is
|
||||
`access_control.resource_type.is_some()`, not
|
||||
`resource_id_path.is_some()` as review 012 first drafted.
|
||||
`AccessControl::check` consults the provider whenever the resource
|
||||
type is set — including the `list` case (`owns_any`) where
|
||||
`resource_id_path` is `None`, which silently admits any
|
||||
token-carried static claim too. `resource_action`-only ACLs are
|
||||
exempt: no provider is consulted for those even on the native wire,
|
||||
so static resolution there is family-wide semantics, not gateway
|
||||
drift. Internal-visibility ops are also exempt (the registry answers
|
||||
the spec-404; the refusal must not acknowledge a never-visible op).
|
||||
|
||||
**The escape hatch:** `with_static_ownership_fallback()` — a
|
||||
declared opt-in to the ADR-011 static `Identity.resources` fallback
|
||||
for deployments that genuinely have no runtime ownership model. This
|
||||
turns the door contract from a doc line into an enforceable
|
||||
construct: the static fallback survives only as a code-review-visible
|
||||
declaration, never as the accidental default. The default spine is
|
||||
loud for `resource_type`-gated ops; wiring
|
||||
`with_ownership_provider` keeps the live grant table.
|
||||
|
||||
Verification: the in-crate regression tests
|
||||
(`gateway_without_provider_admits_only_the_static_claim`,
|
||||
`gateway_with_provider_consults_the_live_grant_table`,
|
||||
`gateway_streaming_with_provider_denies_a_static_claim_only_identity`,
|
||||
all in `src/gateway/dispatch.rs`) pin the static-fallback behavior,
|
||||
the live-table override, and revocation live on the gateway surface;
|
||||
POC-L's `tests/gateway_gap.rs` re-run against the published 0.9.0
|
||||
remains green (the default path is unchanged).
|
||||
(`gateway_with_provider_consults_the_live_grant_table`,
|
||||
`gateway_streaming_with_provider_denies_a_static_claim_only_identity`)
|
||||
pin the live-table override and revocation live on the gateway
|
||||
surface; `gateway_refuses_a_resource_gated_op_without_a_provider`,
|
||||
`gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider`,
|
||||
and `gateway_static_fallback_opt_out_serves_resource_gated_ops` pin
|
||||
the refusal (all three entry points), the opt-out, and the preserved
|
||||
static-fallback mechanics under the declaration;
|
||||
`gateway_refusal_spares_action_only_internal_and_unknown_ops` pins the
|
||||
predicate exemptions (the pre-ruling pin
|
||||
`gateway_without_provider_admits_only_the_static_claim` is superseded —
|
||||
its unwired-spine-admits behavior is now the refusal and the opt-out
|
||||
tests). POC-L's `tests/gateway_gap.rs`, written against published
|
||||
0.9.0, pinned the pre-ruling gap: under this ruling its
|
||||
unwired-spine second branch now answers `FORBIDDEN` pre-dispatch —
|
||||
the POC is archival evidence of the hole, not a living conformance
|
||||
suite; re-run it against the wired or opt-out spine if it is revived.
|
||||
|
||||
Semver note: additive — committed to main without a release (no
|
||||
0.9.x publish); it rides the next release batch (0.10.0's class),
|
||||
keeping the per-alkcall-release downstream churn to one coordinated
|
||||
update.
|
||||
update. The loud default ships in the same unreleased batch as the
|
||||
review-012 amendment, so no published alkcall version ever carried the
|
||||
silent unwired-spine answer past 0.9.0.
|
||||
|
||||
### What the spine deliberately does NOT include
|
||||
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
## Status
|
||||
|
||||
Resolved — filed 2026-10-17, resolved same session. Report-only
|
||||
consumer input; resolution shape 1 is implemented this session
|
||||
(ADR-048 amendment "Ownership provider threading"; the threading,
|
||||
the in-crate regression tests, and the re-run of POC-L's
|
||||
`tests/gateway_gap.rs` against published 0.9.0 are listed there).
|
||||
Committed unreleased — additive work rides the next release batch
|
||||
(0.10.0's class), keeping the six-consumer downstream churn to one
|
||||
coordinated update.
|
||||
Resolved — filed 2026-10-17, resolved in two sessions. Report-only
|
||||
consumer input; resolution shape 1 is implemented (ADR-048 amendment
|
||||
"Ownership provider threading"; the threading, the in-crate regression
|
||||
tests, and the re-run of POC-L's `tests/gateway_gap.rs` against
|
||||
published 0.9.0 are listed there). The deferred loud-default ruling
|
||||
(shape below, decision 2) was ruled in the follow-up session, after
|
||||
the blast-radius survey: **adopt, with a refined predicate and an
|
||||
explicit opt-out** — the spine refuses (`FORBIDDEN`, pre-dispatch, all
|
||||
three entry points) an op whose spec gates on `access_control.resource_type`
|
||||
when no provider is wired and no static fallback is declared; the
|
||||
predicate is `resource_type` (covering the `list`/`owns_any` case the
|
||||
draft wording missed), and `with_static_ownership_fallback()` is the
|
||||
declared escape hatch for genuinely-static deployments. Implemented in
|
||||
the same unreleased batch (ADR-048 "The loud default" section; commit
|
||||
rides the 0.10.0 release class), keeping the six-consumer downstream
|
||||
churn to one coordinated update.
|
||||
|
||||
Two decisions recorded here as required by the resolution session:
|
||||
|
||||
@@ -22,17 +30,20 @@ Two decisions recorded here as required by the resolution session:
|
||||
record-carried-grants pattern (alkgit ADR-011, alkfs, alkdocker)
|
||||
is explicitly **not** an alkcall item — pattern-level now,
|
||||
micro-crate if a third consumer shows up.
|
||||
2. **Deferred, not decided:** the loud default — refusing (an error
|
||||
envelope) an op whose spec has `resource_id_path` when no provider
|
||||
is wired inside the dispatch spine. It is mechanically precise
|
||||
(the spine can see the spec) but consumer-visible; the blast
|
||||
radius must be surveyed first (which doors route
|
||||
`resource_id_path` ops through `GatewayDispatch` today — known
|
||||
case: alkblobs' `blobs/stat` via alkhttp). Ruled on in a
|
||||
subsequent session after that survey; until then the door
|
||||
contract (wire the provider, or hand-assemble the context) is the
|
||||
answer in force. This ruling, once made, belongs in the same
|
||||
release batch — the reason it is decided before 0.10.0 publishes.
|
||||
2. **Adopted (ruled in the follow-up session, superseding the
|
||||
original deferral):** the loud default — refusing (an error
|
||||
envelope) an op whose spec gates on `resource_type` when no
|
||||
provider is wired inside the dispatch spine. Survey outcome: the
|
||||
blast radius is minimal (alkhttp is the only live spine consumer
|
||||
and is unpublished; alkblobs is the only live `resource_type` op
|
||||
producer and serves those ops native-wire with its providers
|
||||
wired; no published consumer of 0.9.0's spine relies on the
|
||||
silent fallback for such ops). Two refinements against the draft
|
||||
wording: the predicate is `resource_type` not `resource_id_path`
|
||||
(the `list` case silently falls back too), and the loud default
|
||||
carries an explicit `with_static_ownership_fallback()` opt-out so
|
||||
the door contract is enforceable, not just documented. Full ruling
|
||||
in ADR-048's "The loud default" section.
|
||||
|
||||
Input basis:
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
||||
+315
-39
@@ -10,7 +10,9 @@
|
||||
//! None` — wire-ingress only), resolves the registration's
|
||||
//! composition authority / capabilities / scoped env into it, carries
|
||||
//! the spine-wide ownership provider when one is wired (ADR-011; the
|
||||
//! ADR-048 amendment — review 012's door contract), and bounds Once-ops
|
||||
//! ADR-048 amendment — review 012's door contract), refuses a
|
||||
//! `resource_type`-gated op served without a provider or a declared
|
||||
//! static fallback (review 012's loud-default ruling), and bounds Once-ops
|
||||
//! and sink dispatch with a configurable deadline
|
||||
//! while leaving streaming subscriptions unbounded (ADR-021:
|
||||
//! subscriptions are long-lived).
|
||||
@@ -59,6 +61,7 @@ pub struct GatewayDispatch {
|
||||
registry: Arc<OperationRegistry>,
|
||||
deadline: Option<Duration>,
|
||||
ownership_provider: Option<Arc<dyn OwnershipProvider>>,
|
||||
static_ownership_fallback: bool,
|
||||
invoke_count: AtomicUsize,
|
||||
}
|
||||
|
||||
@@ -70,6 +73,7 @@ impl GatewayDispatch {
|
||||
registry,
|
||||
deadline: Some(DEFAULT_DEADLINE),
|
||||
ownership_provider: None,
|
||||
static_ownership_fallback: false,
|
||||
invoke_count: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
@@ -77,20 +81,36 @@ impl GatewayDispatch {
|
||||
/// Wire the ownership provider the spine's root contexts carry
|
||||
/// (ADR-011): an op whose `access_control` gates on a
|
||||
/// `resource_type` then consults this provider with the
|
||||
/// `resource_id_path`-extracted resource ID at check time — the
|
||||
/// live, revocable grant table — instead of the static
|
||||
/// `Identity.resources` fallback. The door contract: any door
|
||||
/// serving ops whose authorization truth is a dynamic grant table
|
||||
/// MUST wire the same provider the native wire path consults —
|
||||
/// visible-surface = authorized-surface (review 012 / ADR-048
|
||||
/// amendment). The default ([`GatewayDispatch::new`]) is `None` —
|
||||
/// the ADR-011 static fallback, correct only where the deployment
|
||||
/// genuinely has no runtime ownership model.
|
||||
/// `resource_id_path`-extracted resource ID at check time (the
|
||||
/// live, revocable grant table) — or `owns_any` for the `list`
|
||||
/// case — instead of the static `Identity.resources` fallback.
|
||||
/// The door contract: any door serving ops whose authorization
|
||||
/// truth is a dynamic grant table MUST wire the same provider the
|
||||
/// native wire path consults — visible-surface =
|
||||
/// authorized-surface (review 012 / ADR-048 amendment). The
|
||||
/// default ([`GatewayDispatch::new`]) is `None`, and the default
|
||||
/// policy is loud: without a provider or a declared
|
||||
/// [`GatewayDispatch::with_static_ownership_fallback`], a
|
||||
/// `resource_type`-gated op is refused (an error envelope,
|
||||
/// review 012's ruling).
|
||||
pub fn with_ownership_provider(mut self, provider: Arc<dyn OwnershipProvider>) -> Self {
|
||||
self.ownership_provider = Some(provider);
|
||||
self
|
||||
}
|
||||
|
||||
/// Declare this deployment's static `Identity.resources` fallback
|
||||
/// as a deliberate choice: ops whose `access_control` gates on a
|
||||
/// `resource_type` are served through the ADR-011 static-fallback
|
||||
/// check (token-carried claims), not refused for the missing
|
||||
/// provider wiring. Use this only where the deployment genuinely
|
||||
/// has no runtime ownership model — review 012's door contract
|
||||
/// makes this a code-review-visible decision, not the accidental
|
||||
/// default.
|
||||
pub fn with_static_ownership_fallback(mut self) -> Self {
|
||||
self.static_ownership_fallback = true;
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the Once/sink deadline: `Some(duration)` bounds every
|
||||
/// [`GatewayDispatch::invoke`] and [`GatewayDispatch::invoke_sink`]
|
||||
/// dispatch (a hung handler surfaces as a retryable `TIMEOUT` error
|
||||
@@ -131,6 +151,14 @@ impl GatewayDispatch {
|
||||
return ResponseEnvelope::error(uuid::Uuid::new_v4().to_string(), error);
|
||||
}
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
if let Some(error) = ownership_wiring_refusal(
|
||||
&self.registry,
|
||||
&operation_name,
|
||||
self.ownership_provider.as_ref(),
|
||||
self.static_ownership_fallback,
|
||||
) {
|
||||
return ResponseEnvelope::error(request_id, error);
|
||||
}
|
||||
let context = self.build_root_context(&request_id, &operation_name, identity);
|
||||
let fut = self.registry.invoke(&operation_name, input, context);
|
||||
match self.deadline {
|
||||
@@ -166,6 +194,16 @@ impl GatewayDispatch {
|
||||
}));
|
||||
}
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
if let Some(error) = ownership_wiring_refusal(
|
||||
&self.registry,
|
||||
&operation_name,
|
||||
self.ownership_provider.as_ref(),
|
||||
self.static_ownership_fallback,
|
||||
) {
|
||||
return Box::pin(futures::stream::once(async move {
|
||||
ResponseEnvelope::error(request_id, error)
|
||||
}));
|
||||
}
|
||||
let context = self.build_root_context_streaming(&request_id, &operation_name, identity);
|
||||
self.registry
|
||||
.invoke_streaming(&operation_name, input, context)
|
||||
@@ -186,6 +224,14 @@ impl GatewayDispatch {
|
||||
) -> ResponseEnvelope {
|
||||
let operation_name = strip_leading_slash(op).to_string();
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
if let Some(error) = ownership_wiring_refusal(
|
||||
&self.registry,
|
||||
&operation_name,
|
||||
self.ownership_provider.as_ref(),
|
||||
self.static_ownership_fallback,
|
||||
) {
|
||||
return ResponseEnvelope::error(request_id, error);
|
||||
}
|
||||
let context = self.build_root_context_sink(&request_id, &operation_name, identity);
|
||||
let fut = self
|
||||
.registry
|
||||
@@ -297,6 +343,44 @@ fn schema_via_call_denial(
|
||||
schema_disclosure_denial(registry, name, identity)
|
||||
}
|
||||
|
||||
/// The loud ownership-wiring default (review 012's ruling, ADR-048
|
||||
/// amendment): when the dispatched op's `access_control` gates on a
|
||||
/// `resource_type` and no ownership provider is wired on the spine,
|
||||
/// refuse with `FORBIDDEN` rather than silently applying the ADR-011
|
||||
/// static `Identity.resources` fallback — admission-by-claim at a door
|
||||
/// is exactly the visible-surface ≠ authorized-surface drift. The
|
||||
/// predicate is `resource_type` (not `resource_id_path`): `AccessControl::check`
|
||||
/// consults the provider whenever the resource type is set, including
|
||||
/// the `list` case (`owns_any`) where `resource_id_path` is `None`.
|
||||
/// `resource_action`-only ACLs are exempt — no provider is consulted
|
||||
/// for those even on the native wire, so static resolution there is
|
||||
/// family-wide semantics, not gateway drift. Internal-visibility ops
|
||||
/// are exempt too (the registry's dispatch answers `NOT_FOUND`; the
|
||||
/// refusal must not acknowledge a never-visible op). The
|
||||
/// [`GatewayDispatch::with_static_ownership_fallback`] declaration is
|
||||
/// the deliberate escape hatch.
|
||||
fn ownership_wiring_refusal(
|
||||
registry: &OperationRegistry,
|
||||
operation: &str,
|
||||
ownership_provider: Option<&Arc<dyn OwnershipProvider>>,
|
||||
static_ownership_fallback: bool,
|
||||
) -> Option<CallError> {
|
||||
if ownership_provider.is_some() || static_ownership_fallback {
|
||||
return None;
|
||||
}
|
||||
let registration = registry.registration(operation)?;
|
||||
let spec = ®istration.spec;
|
||||
if spec.visibility == Visibility::Internal || spec.access_control.resource_type.is_none() {
|
||||
return None;
|
||||
}
|
||||
let resource_type = spec.access_control.resource_type.as_deref().unwrap_or("");
|
||||
Some(CallError::forbidden(format!(
|
||||
"operation '{operation}' gates on resource ownership ({resource_type}), but no ownership \
|
||||
provider is wired on this dispatch spine; the deployment must wire \
|
||||
GatewayDispatch::with_ownership_provider or declare with_static_ownership_fallback"
|
||||
)))
|
||||
}
|
||||
|
||||
/// The schema-disclosure check shared by every transport surface:
|
||||
/// Internal ops are invisible (`NOT_FOUND` regardless of caller);
|
||||
/// ACL-forbidden ops are denied with `FORBIDDEN`. One implementation so
|
||||
@@ -858,35 +942,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_without_provider_admits_only_the_static_claim() {
|
||||
let dispatch = GatewayDispatch::new(namespace_registry());
|
||||
let input = serde_json::json!({ "namespace": "ns-1" });
|
||||
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("token-carrier", true)),
|
||||
"ns/stat",
|
||||
input.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
envelope.result.is_ok(),
|
||||
"the static fallback admits the token-carried claim, got {envelope:?}"
|
||||
);
|
||||
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("stranger", false)),
|
||||
"ns/stat",
|
||||
input,
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
|
||||
Ok(v) => panic!("the static fallback denies a claimless caller, got {v:?}"),
|
||||
}
|
||||
}
|
||||
// Review 012's ruling replaces the pre-ruling pin
|
||||
// (`gateway_without_provider_admits_only_the_static_claim`): the
|
||||
// loud default refuses the unwired spine (see
|
||||
// `gateway_refuses_a_resource_gated_op_without_a_provider`), the
|
||||
// opt-out keeps the fallback (see
|
||||
// `gateway_static_fallback_opt_out_serves_resource_gated_ops`),
|
||||
// and the wired provider keeps the live-table behavior below.
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_with_provider_consults_the_live_grant_table() {
|
||||
@@ -986,4 +1048,218 @@ mod tests {
|
||||
Ok(v) => panic!("the provider gate applies to the streaming path too, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Review 012 ruling: the loud ownership-wiring default ------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_refuses_a_resource_gated_op_without_a_provider() {
|
||||
let dispatch = GatewayDispatch::new(namespace_registry());
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("token-carrier", true)),
|
||||
"ns/stat",
|
||||
serde_json::json!({ "namespace": "ns-1" }),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => {
|
||||
assert_eq!(error.code, "FORBIDDEN");
|
||||
assert!(
|
||||
error.message.contains("with_static_ownership_fallback"),
|
||||
"the refusal names the deliberate opt-out, got {error:?}"
|
||||
);
|
||||
assert!(
|
||||
error.message.contains("blob-namespace"),
|
||||
"the refusal names the resource type, got {error:?}"
|
||||
);
|
||||
}
|
||||
Ok(v) => panic!(
|
||||
"the loud default must refuse a resource-gated op with no wired provider, got {v:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider() {
|
||||
let registry = OperationRegistry::new();
|
||||
let registry = {
|
||||
let mut sink_spec = namespace_spec("ns/put");
|
||||
sink_spec.op_type = OperationType::Pub;
|
||||
let mut stream_spec = namespace_spec("ns/stream");
|
||||
stream_spec.op_type = OperationType::Sub;
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
stream_spec,
|
||||
HandlerKind::Stream(make_streaming_handler(|_input, ctx| {
|
||||
let request_id = ctx.request_id.clone();
|
||||
futures::stream::once(async move {
|
||||
ResponseEnvelope::ok(request_id, serde_json::json!({}))
|
||||
})
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
sink_spec,
|
||||
HandlerKind::Sink(make_sink_handler(|_input, ctx, _chunks| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
};
|
||||
let dispatch = GatewayDispatch::new(Arc::clone(®istry));
|
||||
|
||||
let mut stream = dispatch.invoke_streaming(
|
||||
Some(identity_with_static_claim("token-carrier", true)),
|
||||
"ns/stream",
|
||||
serde_json::json!({ "namespace": "ns-1" }),
|
||||
);
|
||||
let envelopes: Vec<ResponseEnvelope> = stream.by_ref().collect().await;
|
||||
assert_eq!(envelopes.len(), 1, "the refusal is the only envelope");
|
||||
match &envelopes[0].result {
|
||||
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
|
||||
Ok(v) => panic!("the streaming path refuses unwired resource-gated ops, got {v:?}"),
|
||||
}
|
||||
|
||||
let chunks: crate::registry::registration::PublishStream =
|
||||
Box::pin(futures::stream::empty());
|
||||
let envelope = dispatch
|
||||
.invoke_sink(
|
||||
Some(identity_with_static_claim("token-carrier", true)),
|
||||
"ns/put",
|
||||
serde_json::json!({ "namespace": "ns-1" }),
|
||||
chunks,
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
|
||||
Ok(v) => panic!("the sink path refuses unwired resource-gated ops, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_static_fallback_opt_out_serves_resource_gated_ops() {
|
||||
let dispatch = GatewayDispatch::new(namespace_registry()).with_static_ownership_fallback();
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("token-carrier", true)),
|
||||
"ns/stat",
|
||||
serde_json::json!({ "namespace": "ns-1" }),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
envelope.result.is_ok(),
|
||||
"the declared static fallback serves the op through the ADR-011 fallback, got {envelope:?}"
|
||||
);
|
||||
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("stranger", false)),
|
||||
"ns/stat",
|
||||
serde_json::json!({ "namespace": "ns-1" }),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
|
||||
Ok(v) => panic!("the static fallback still denies a claimless caller, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_refusal_spares_action_only_internal_and_unknown_ops() {
|
||||
let registry = OperationRegistry::new();
|
||||
let mut action_only = spec("act/run", Visibility::External, OperationType::Query);
|
||||
action_only.access_control = AccessControl {
|
||||
resource_action: Some("read".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut internal_gated = namespace_spec("internal/hidden");
|
||||
internal_gated.visibility = Visibility::Internal;
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
action_only,
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
internal_gated,
|
||||
HandlerKind::Once(make_handler(|_input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, serde_json::json!({}))
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let dispatch = GatewayDispatch::new(Arc::new(registry));
|
||||
|
||||
let mut action_identity_resources = HashMap::new();
|
||||
action_identity_resources.insert("some-resource".to_string(), vec!["read".to_string()]);
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(Identity {
|
||||
id: "action-holder".to_string(),
|
||||
scopes: Vec::new(),
|
||||
resources: action_identity_resources,
|
||||
}),
|
||||
"act/run",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
envelope.result.is_ok(),
|
||||
"a resource_action-only ACL never consults a provider on the wire either — no refusal, got {envelope:?}"
|
||||
);
|
||||
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("snooping", false)),
|
||||
"internal/hidden",
|
||||
serde_json::json!({ "namespace": "ns-1" }),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => {
|
||||
assert_eq!(
|
||||
error.code, "NOT_FOUND",
|
||||
"an Internal op stays unacknowledged instead of the refusal"
|
||||
);
|
||||
assert!(
|
||||
!error.message.contains("with_static_ownership_fallback")
|
||||
&& !error.message.contains("blob-namespace"),
|
||||
"the refusal text must not leak on an Internal op, got {error:?}"
|
||||
);
|
||||
}
|
||||
Ok(v) => panic!("an Internal op must never run, got {v:?}"),
|
||||
}
|
||||
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("anyone", false)),
|
||||
"missing/op",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "NOT_FOUND"),
|
||||
Ok(v) => panic!("an unknown op keeps its NOT_FOUND, got {v:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user