feat(gateway): thread the ownership provider through the dispatch spine (review 012)
- GatewayDispatch carries ownership_provider: Option<Arc<dyn OwnershipProvider>> + with_ownership_provider (dispatcher-identical builder); the shared root-context builder threads it, covering invoke / invoke_streaming / invoke_sink. Default stays None (the ADR-011 static fallback) — additive and opt-in, no context shape change, no behavior change for existing consumers. - Door contract documented in the ADR-048 amendment: doors serving ops whose authorization truth is a revocable grant table must wire the same provider the wire path consults (visible-surface = authorized-surface). Anchored downstream by alkgit's LFS POC. - The loud default (refuse resource_id_path ops with no provider wired) is deferred, not decided — tracked in review 012, ruled on after the door blast-radius survey; it belongs in the same release batch. - Release placement: committed unreleased — additive work rides the next breaking batch (0.10.0's class) instead of publishing 0.9.1, keeping the six-consumer downstream churn to one coordinated update. Verification: - cargo test --all-features (717 + stub_guard); fuzz/shared corpus replay (5); clippy --all-features --all-targets -D warnings; fmt; cargo doc --no-deps - in-crate regression tests pin static-fallback behavior, live-table override, revocation live on the gateway surface - POC-L tests/gateway_gap.rs re-run against published 0.9.0 — green
This commit is contained in:
1 parent
03a6689c99
commit
d0b8124064
3 files changed
+304
-13
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended 2026-10-17 — ownership provider threading; review 012)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -101,6 +101,72 @@ identity-aware refinement. When an unauthenticated caller hits both,
|
||||
copy; until then the two implementations coexist (byte-identical in
|
||||
behavior, one in each crate).
|
||||
|
||||
### Ownership provider threading (amendment 2026-10-17 — review 012)
|
||||
|
||||
ADR-048's enumeration of what the spine resolves into the root context
|
||||
omitted the ownership provider: `GatewayDispatch`'s context builder
|
||||
hardwired `ownership: None` (review 012, anchor
|
||||
`src/gateway/dispatch.rs`). The promoted spine predates ADR-011's
|
||||
`Dispatcher::with_ownership_provider` threading on the protocol
|
||||
dispatch path, so the two surfaces drifted: the same op, same caller,
|
||||
ran through the dynamic (revocable) grant table on the wire and
|
||||
through the static `Identity.resources` fallback at a door —
|
||||
admission-by-claim where the family's posture is admission-by-live-grant.
|
||||
Downstream evidence: alkgit's LFS POC (POC-L), whose gateway-gap test
|
||||
pinned the behavior with a real `git-lfs` client against a
|
||||
namespace-ACL-gated `blobs/stat` (alkblobs).
|
||||
|
||||
Amended:
|
||||
|
||||
- `GatewayDispatch` carries `ownership_provider: Option<Arc<dyn
|
||||
OwnershipProvider>>` — private field, wired by
|
||||
`with_ownership_provider` (the dispatcher-identical builder), read
|
||||
by the shared root-context builder so all three dispatch entry
|
||||
points (`invoke`, `invoke_streaming`, `invoke_sink`) thread it.
|
||||
`None` (the default) keeps the ADR-011 static fallback. No context
|
||||
shape change (`OperationContext.ownership` stays `Option`-typed,
|
||||
unchanged) and no behavior change for existing consumers — the
|
||||
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.
|
||||
|
||||
- **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.
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
### What the spine deliberately does NOT include
|
||||
|
||||
- **No HTTP mapping.** `CallError` → status/body is the consumer's
|
||||
|
||||
@@ -2,14 +2,37 @@
|
||||
|
||||
## Status
|
||||
|
||||
Open — filed 2026-10-17. Report-only consumer input; the resolution
|
||||
(session-owned) either threads an ownership provider through the
|
||||
gateway dispatch path or blesses/documents the door-side compensation
|
||||
pattern as the durable answer. Filed here rather than in the consumer
|
||||
because the anchor is this crate's surface
|
||||
(`src/gateway/dispatch.rs`), and because the family precedent exists
|
||||
(review 011 — downstream input filed in the crate that owns the
|
||||
seam).
|
||||
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.
|
||||
|
||||
Two decisions recorded here as required by the resolution session:
|
||||
|
||||
1. **Adopted:** shape 1 — thread an ownership provider through the
|
||||
dispatch path (`GatewayDispatch::with_ownership_provider`, default
|
||||
`None` = ADR-011 static fallback), plus the door-contract doc line
|
||||
from shape 2 folded into the ADR-048 amendment. No context shape
|
||||
change (`OperationContext.ownership` stays `Option`-typed,
|
||||
unchanged); the `alkauth`-style shared-vocabulary question for the
|
||||
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.
|
||||
|
||||
Input basis:
|
||||
|
||||
|
||||
+206
-4
@@ -8,8 +8,10 @@
|
||||
//! every transport (`internal: false` — ACL runs against the caller's
|
||||
//! identity, not a handler's composition authority; `forwarded_for:
|
||||
//! None` — wire-ingress only), resolves the registration's
|
||||
//! composition authority / capabilities / scoped env into it, and
|
||||
//! bounds Once-ops and sink dispatch with a configurable deadline
|
||||
//! 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
|
||||
//! and sink dispatch with a configurable deadline
|
||||
//! while leaving streaming subscriptions unbounded (ADR-021:
|
||||
//! subscriptions are long-lived).
|
||||
//!
|
||||
@@ -26,6 +28,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::core::auth::Identity;
|
||||
use crate::core::ownership::OwnershipProvider;
|
||||
use crate::core::types::Capabilities;
|
||||
use crate::protocol::wire::{CallError, ResponseEnvelope};
|
||||
use crate::registry::context::{AbortPolicy, OperationContext, ScopedPeerEnv};
|
||||
@@ -46,12 +49,16 @@ pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(30);
|
||||
/// invokes operations for the neutral `ResponseEnvelope` result shape
|
||||
/// any transport maps to its own wire format. Identity arrives
|
||||
/// per-call as `Option<Identity>` — bearer resolution, transport
|
||||
/// framing, and error presentation happen upstream in the consumer.
|
||||
/// framing, and error presentation happen upstream in the consumer;
|
||||
/// the ownership provider (review 012 / ADR-048 amendment) arrives
|
||||
/// once per spine via [`GatewayDispatch::with_ownership_provider`] and
|
||||
/// is carried by every root context this spine builds.
|
||||
///
|
||||
/// See the [module docs](crate::gateway) and ADR-048.
|
||||
pub struct GatewayDispatch {
|
||||
registry: Arc<OperationRegistry>,
|
||||
deadline: Option<Duration>,
|
||||
ownership_provider: Option<Arc<dyn OwnershipProvider>>,
|
||||
invoke_count: AtomicUsize,
|
||||
}
|
||||
|
||||
@@ -62,10 +69,28 @@ impl GatewayDispatch {
|
||||
Self {
|
||||
registry,
|
||||
deadline: Some(DEFAULT_DEADLINE),
|
||||
ownership_provider: None,
|
||||
invoke_count: AtomicUsize::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn with_ownership_provider(mut self, provider: Arc<dyn OwnershipProvider>) -> Self {
|
||||
self.ownership_provider = Some(provider);
|
||||
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
|
||||
@@ -242,7 +267,7 @@ impl GatewayDispatch {
|
||||
env,
|
||||
abort_policy: AbortPolicy::default(),
|
||||
internal: false,
|
||||
ownership: None,
|
||||
ownership: self.ownership_provider.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -784,4 +809,181 @@ mod tests {
|
||||
"default-ACL ops are fetchable unauthenticated"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Review 012 / ADR-048 amendment: ownership threading ------------
|
||||
|
||||
use crate::core::ownership::{InMemoryOwnershipStore, OwnershipStore};
|
||||
|
||||
fn namespace_acl() -> AccessControl {
|
||||
AccessControl {
|
||||
resource_type: Some("blob-namespace".to_string()),
|
||||
resource_action: Some("read".to_string()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn namespace_registry() -> Arc<OperationRegistry> {
|
||||
let registry = OperationRegistry::new();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
namespace_spec("ns/stat"),
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
Arc::new(registry)
|
||||
}
|
||||
|
||||
fn namespace_spec(name: &str) -> OperationSpec {
|
||||
let mut spec = spec(name, Visibility::External, OperationType::Query);
|
||||
spec.access_control = namespace_acl();
|
||||
spec.resource_id_path = Some("$.namespace".to_string());
|
||||
spec
|
||||
}
|
||||
|
||||
fn identity_with_static_claim(id: &str, claimed: bool) -> Identity {
|
||||
let mut resources = HashMap::new();
|
||||
if claimed {
|
||||
resources.insert("blob-namespace".to_string(), vec!["read".to_string()]);
|
||||
}
|
||||
Identity {
|
||||
id: id.to_string(),
|
||||
scopes: Vec::new(),
|
||||
resources,
|
||||
}
|
||||
}
|
||||
|
||||
#[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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_with_provider_consults_the_live_grant_table() {
|
||||
let store = Arc::new(InMemoryOwnershipStore::new());
|
||||
store
|
||||
.record(
|
||||
&identity_with_static_claim("tenant-a", false),
|
||||
"blob-namespace",
|
||||
"ns-1",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let dispatch = GatewayDispatch::new(namespace_registry())
|
||||
.with_ownership_provider(Arc::clone(&store) as Arc<dyn OwnershipProvider>);
|
||||
|
||||
let input = serde_json::json!({ "namespace": "ns-1" });
|
||||
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("tenant-a", false)),
|
||||
"ns/stat",
|
||||
input.clone(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
envelope.result.is_ok(),
|
||||
"a live grant admits without any static claim on the identity, got {envelope:?}"
|
||||
);
|
||||
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("token-carrier", true)),
|
||||
"ns/stat",
|
||||
input.clone(),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
|
||||
Ok(v) => panic!("a wired provider must override the static fallback, got {v:?}"),
|
||||
}
|
||||
|
||||
store.revoke("blob-namespace", "ns-1").await.unwrap();
|
||||
let envelope = dispatch
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("tenant-a", false)),
|
||||
"ns/stat",
|
||||
input,
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => {
|
||||
assert_eq!(
|
||||
error.code, "FORBIDDEN",
|
||||
"revocation is live on the gateway surface, got {error:?}"
|
||||
);
|
||||
}
|
||||
Ok(v) => panic!("revocation must apply on the gateway surface, got {v:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_streaming_with_provider_denies_a_static_claim_only_identity() {
|
||||
let registry = OperationRegistry::new();
|
||||
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();
|
||||
let dispatch = GatewayDispatch::new(Arc::new(registry))
|
||||
.with_ownership_provider(Arc::new(InMemoryOwnershipStore::new()));
|
||||
|
||||
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 denied op yields one error envelope"
|
||||
);
|
||||
match &envelopes[0].result {
|
||||
Err(error) => assert_eq!(error.code, "FORBIDDEN"),
|
||||
Ok(v) => panic!("the provider gate applies to the streaming path too, got {v:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user