Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a1e4eb7c0 | ||
|
|
1fda0d8587 | ||
|
|
9899b37a82 | ||
|
|
d0b8124064 | ||
|
|
03a6689c99 |
No files matched your search
@@ -4,6 +4,75 @@ All notable changes to this crate are documented here. The format is
|
||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.10.0] - 2026-10-17
|
||||
|
||||
The gateway ownership seam (review 012, downstream evidence from
|
||||
alkgit's LFS POC — POC-L): the gateway dispatch spine can now consult a
|
||||
runtime ownership provider, and the previously-silent static fallback
|
||||
becomes loud — `FORBIDDEN` rather than admission-by-claim — unless the
|
||||
deployment wires the provider or declares the static fallback
|
||||
explicitly. This closes the two-authorization-paths drift: a
|
||||
namespace-ACL-gated op served through a door ran against the
|
||||
token-carried static claim, while the same op on the native wire
|
||||
consulted the revocable grant table. One behavior break (the loud
|
||||
default, below); the wire surface and `OperationContext` shape are
|
||||
unchanged.
|
||||
|
||||
### Added
|
||||
|
||||
- **`GatewayDispatch::with_ownership_provider` (ADR-048 amendment
|
||||
"Ownership provider threading").** An `Option<Arc<dyn
|
||||
OwnershipProvider>>` on the spine, wired by the dispatcher-identical
|
||||
builder and read by the shared root-context builder so all three
|
||||
dispatch entry points (`invoke`, `invoke_streaming`, `invoke_sink`)
|
||||
thread it into `OperationContext.ownership`. `None` (the default)
|
||||
previously hardwired the static fallback; now it activates the loud
|
||||
default below. Regression tests:
|
||||
`gateway_with_provider_consults_the_live_grant_table` (revocation is
|
||||
live on the gateway surface) and
|
||||
`gateway_streaming_with_provider_denies_a_static_claim_only_identity`.
|
||||
- **`GatewayDispatch::with_static_ownership_fallback`** — the declared
|
||||
opt-in to the ADR-011 static `Identity.resources` fallback, for
|
||||
deployments that genuinely have no runtime ownership model. 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. Regression test:
|
||||
`gateway_static_fallback_opt_out_serves_resource_gated_ops`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **The loud ownership-wiring default (review 012 decision 2; ADR-048
|
||||
"The loud default").** The spine refuses (an error envelope,
|
||||
`FORBIDDEN`, pre-dispatch, all three entry points) an op whose spec
|
||||
carries `access_control.resource_type` when no ownership provider is
|
||||
wired and no static fallback is declared. **Behavior break for any
|
||||
deployment serving `resource_type`-gated ops through an unwired
|
||||
spine** — the survey (review 012) found no published consumer relies
|
||||
on the silent fallback (alkhttp, the only live spine consumer, is
|
||||
unpublished; alkblobs, the only live `resource_type` op producer,
|
||||
serves native-wire with providers wired). The predicate is
|
||||
`resource_type` (covering the `list`/`owns_any` case where
|
||||
`resource_id_path` is `None`), not `resource_id_path`;
|
||||
`resource_action`-only ACLs and Internal-visibility ops are exempt
|
||||
(no provider is consulted for those even on the native wire — static
|
||||
resolution there is family-wide semantics). The refusal message is
|
||||
operator-directed (names the wired opt-ins) and indistinguishable in
|
||||
code from a normal ACL denial. Regression tests:
|
||||
`gateway_refuses_a_resource_gated_op_without_a_provider`,
|
||||
`gateway_streaming_and_sink_refuse_a_resource_gated_op_without_a_provider`,
|
||||
and `gateway_refusal_spares_action_only_internal_and_unknown_ops`.
|
||||
Specs: ADR-048's "Ownership provider threading" and "The loud
|
||||
default" sections; the finding is
|
||||
`docs/reviews/012-gateway-ownership-seam-consumer-input.md`.
|
||||
|
||||
### Verified
|
||||
|
||||
- 698 tests pass (including the stub guard); `clippy --all-targets
|
||||
--all-features -- -D warnings`, `fmt --check`, `cargo doc`,
|
||||
wasm32-unknown-unknown check + clippy, fuzz corpus replay
|
||||
(`cargo test --manifest-path fuzz/shared/Cargo.toml`, 5/5),
|
||||
`cargo semver-checks`, and `cargo publish --dry-run` clean.
|
||||
|
||||
## [0.9.0] - 2026-10-14
|
||||
|
||||
The stub sweep: four registered-but-always-failing stubs shipped across
|
||||
@@ -834,6 +903,7 @@ Vendored core types (`Connection`, `ProtocolHandler`, `BiStream`,
|
||||
(ADR-046), the channels protocol with openable-ALPNs-as-operations
|
||||
(ADR-047), and the `ChannelClient` transport-agnostic client.
|
||||
|
||||
[0.10.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.10.0
|
||||
[0.9.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.9.0
|
||||
[0.8.1]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.1
|
||||
[0.8.0]: https://git.alk.dev/alkdev/alkcall/releases/tag/v0.8.0
|
||||
|
||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "alkcall"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.88"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
Accepted (amended 2026-10-17 — ownership provider threading; review 012)
|
||||
|
||||
## Context
|
||||
|
||||
@@ -101,6 +101,102 @@ 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 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.
|
||||
|
||||
- **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_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. 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
|
||||
|
||||
- **No HTTP mapping.** `CallError` → status/body is the consumer's
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
# Review 012 — The gateway dispatch's hardwired `ownership: None`: downstream evidence from alkgit's LFS POC
|
||||
|
||||
## Status
|
||||
|
||||
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:
|
||||
|
||||
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. **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:
|
||||
|
||||
- **POC-L** (alkgit, 2026-10-16; findings at
|
||||
`/workspace/@alkdev/alkgit/docs/research/pocl-findings.md`; the
|
||||
evidence test is `tests/gateway_gap.rs` in `/workspace/lfs-poc`) —
|
||||
a real `git-lfs` client served through an alkhttp extras door
|
||||
mapping the LFS batch API onto alkblobs' `ops` (`blobs/stat`,
|
||||
namespace-ACL-gated, External visibility).
|
||||
- The ask ledger: alkgit's `substrate-recomposition.md`
|
||||
§Upstream-push candidates #6 (the "alkhttp gateway ownership seam"
|
||||
entry — filed here under the crate that owns the dispatch spine).
|
||||
|
||||
---
|
||||
|
||||
## 1. The finding
|
||||
|
||||
`GatewayDispatch::invoke`'s `OperationContext` builder hardwires
|
||||
`ownership: None` when it assembles an external identity's context
|
||||
(`src/gateway/dispatch.rs`, the builder around lines 215–249; the
|
||||
`ownership: None` field assignment verified this session). Downstream
|
||||
consequence, demonstrated live in POC-L:
|
||||
|
||||
- `AccessControl::check` for a namespace-ACL-gated op (`resource_id`
|
||||
extracted from the payload — alkblobs' `blobs/stat` with
|
||||
`resource_id_path: $.namespace`, External visibility) falls back
|
||||
to the **static** `Identity.resources` model when no ownership
|
||||
provider is present in the context.
|
||||
- Therefore a token-**carried static claim** — e.g. an LFS-style
|
||||
bearer JWT embedding `{"blob-namespace": ["read"]}` in its
|
||||
resources map — **is admitted through the gateway with no runtime
|
||||
provider consulted**, while the same op served through the native
|
||||
call protocol consults the dynamic (revocable) grant table and
|
||||
admits only live grants.
|
||||
- The dynamic grant table is *unreachable* from this dispatch spine:
|
||||
no thread of the gateway path carries an ownership provider, so a
|
||||
deployment whose authorization truth is a revocable table gets
|
||||
stale-at-birth answers on the HTTP-gateway surface — revocation
|
||||
and grant changes never apply there.
|
||||
|
||||
The security frame is the family's standing invariant
|
||||
(visible-surface = authorized-surface): an External op must not have
|
||||
two authorization paths with different revocation semantics
|
||||
depending on which door the caller walked through. The static
|
||||
fallback is admission-by-claim at the exact point where the family's
|
||||
posture wants admission-by-live-grant.
|
||||
|
||||
## 2. What it means for the consumer (and what they did about it)
|
||||
|
||||
The POC-L door cannot use the gateway dispatch spine for these ops.
|
||||
Its compensation: build the `OperationContext` itself (identity from
|
||||
the resolved HTTP identity + the ownership provider wired
|
||||
explicitly) and call `OperationRegistry::invoke("blobs/stat", …)`
|
||||
directly — after which the namespace ACL runs exactly as it would on
|
||||
the native wire, against live grant sets (POC-L finding 2, the batch
|
||||
binding section). The compensation is correct and verified, but it
|
||||
is per-door hand-assembly of what the dispatch spine exists to do —
|
||||
every future door (alkhttp's `git` feature, the LFS door proper,
|
||||
alkssh's surfaces) inherits the same manual step and the same trap of
|
||||
forgetting it (the spine *works* with the fallback, silently).
|
||||
|
||||
## 3. Resolution shapes (recommendation only — own session)
|
||||
|
||||
1. **Thread an ownership provider through the dispatch path** — an
|
||||
`Option` on the gateway dispatch builder (per-dispatch config or
|
||||
a connection-establishment property), `None` only where a
|
||||
deployment genuinely has no ownership model. Preserves the
|
||||
single-spine posture; needs an ADR if the context shape is
|
||||
frozen-published (alkcall is 0.9, published — the context builder
|
||||
surface's semver class decides).
|
||||
2. **Bless the compensation**: document the door-side pattern (door
|
||||
builds its own context with ownership wired; never route
|
||||
External namespace-ACL ops through `GatewayDispatch::invoke`
|
||||
without it) as a *door contract* line in the gateway docs, plus
|
||||
a loud static-fallback warning at the hardwired site. Cheap, but
|
||||
the trap stays structural — silent-by-default.
|
||||
3. **Hybrid**: doc contract now (2), seam threading as the ADR'd
|
||||
follow-up (1). Probably where the session lands, given 0.9 is
|
||||
published and the ADR round for the context shape is not open.
|
||||
|
||||
**Verification status**: the `ownership: None` anchor and the
|
||||
builder's context assembly were re-read this session; the admission
|
||||
behavior (static `Identity.resources` fallback, token-carried claim
|
||||
admitted) is POC-L's `tests/gateway_gap.rs` result, witnessed in the
|
||||
findings doc. Re-verify the behavior during resolution by re-running
|
||||
that test against a current alkcall.
|
||||
|
||||
## 4. Non-goal note
|
||||
|
||||
Not asking for anything on the native call-protocol path: the
|
||||
channel-path dispatch carries ownership correctly (the ops wire path
|
||||
is what the whole family gates through). The finding is scoped to
|
||||
the gateway dispatch spine used by HTTP-door routing.
|
||||
Generated
+1
-1
@@ -27,7 +27,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "alkcall"
|
||||
version = "0.8.1"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
|
||||
+535
-4
@@ -8,8 +8,12 @@
|
||||
//! 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), 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).
|
||||
//!
|
||||
@@ -26,6 +30,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 +51,17 @@ 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>>,
|
||||
static_ownership_fallback: bool,
|
||||
invoke_count: AtomicUsize,
|
||||
}
|
||||
|
||||
@@ -62,10 +72,45 @@ impl GatewayDispatch {
|
||||
Self {
|
||||
registry,
|
||||
deadline: Some(DEFAULT_DEADLINE),
|
||||
ownership_provider: None,
|
||||
static_ownership_fallback: false,
|
||||
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) — 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
|
||||
@@ -106,6 +151,9 @@ 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) = self.ownership_wiring_refusal(&operation_name) {
|
||||
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 {
|
||||
@@ -141,6 +189,11 @@ impl GatewayDispatch {
|
||||
}));
|
||||
}
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
if let Some(error) = self.ownership_wiring_refusal(&operation_name) {
|
||||
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)
|
||||
@@ -161,6 +214,9 @@ 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) = self.ownership_wiring_refusal(&operation_name) {
|
||||
return ResponseEnvelope::error(request_id, error);
|
||||
}
|
||||
let context = self.build_root_context_sink(&request_id, &operation_name, identity);
|
||||
let fut = self
|
||||
.registry
|
||||
@@ -179,6 +235,15 @@ impl GatewayDispatch {
|
||||
}
|
||||
}
|
||||
|
||||
fn ownership_wiring_refusal(&self, operation: &str) -> Option<CallError> {
|
||||
ownership_wiring_refusal(
|
||||
&self.registry,
|
||||
operation,
|
||||
self.ownership_provider.as_ref(),
|
||||
self.static_ownership_fallback,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_root_context_sink(
|
||||
&self,
|
||||
request_id: &str,
|
||||
@@ -242,7 +307,7 @@ impl GatewayDispatch {
|
||||
env,
|
||||
abort_policy: AbortPolicy::default(),
|
||||
internal: false,
|
||||
ownership: None,
|
||||
ownership: self.ownership_provider.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +337,43 @@ 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 spec = ®istry.registration(operation)?.spec;
|
||||
if spec.visibility == Visibility::Internal {
|
||||
return None;
|
||||
}
|
||||
let resource_type = spec.access_control.resource_type.as_deref()?;
|
||||
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
|
||||
@@ -784,4 +886,433 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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_list_case_resource_gated_ops_are_refused_unwired_and_served_via_owns_any() {
|
||||
let registry = OperationRegistry::new();
|
||||
let mut list_spec = spec("ns/list", Visibility::External, OperationType::Query);
|
||||
list_spec.access_control = namespace_acl();
|
||||
registry
|
||||
.register(HandlerRegistration::new(
|
||||
list_spec,
|
||||
HandlerKind::Once(make_handler(|input, ctx| async move {
|
||||
ResponseEnvelope::ok(ctx.request_id, input)
|
||||
})),
|
||||
OperationProvenance::Local,
|
||||
None,
|
||||
None,
|
||||
Capabilities::new(),
|
||||
))
|
||||
.unwrap();
|
||||
let registry = Arc::new(registry);
|
||||
|
||||
let envelope = GatewayDispatch::new(Arc::clone(®istry))
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("token-carrier", true)),
|
||||
"ns/list",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
match envelope.result {
|
||||
Err(error) => assert_eq!(
|
||||
error.code, "FORBIDDEN",
|
||||
"the list case (resource_id_path None) is refused unwired too, got {error:?}"
|
||||
),
|
||||
Ok(v) => panic!(
|
||||
"a token-carried static claim must not admit the list case on an unwired spine, \
|
||||
got {v:?}"
|
||||
),
|
||||
}
|
||||
|
||||
let store = Arc::new(InMemoryOwnershipStore::new());
|
||||
store
|
||||
.record(
|
||||
&identity_with_static_claim("tenant-a", false),
|
||||
"blob-namespace",
|
||||
"ns-9",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let envelope = GatewayDispatch::new(Arc::clone(®istry))
|
||||
.with_ownership_provider(Arc::clone(&store) as Arc<dyn OwnershipProvider>)
|
||||
.invoke(
|
||||
Some(identity_with_static_claim("tenant-a", false)),
|
||||
"ns/list",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
envelope.result.is_ok(),
|
||||
"the wired spine serves the list case through owns_any, got {envelope:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[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