Line data Source code
1 : // SPDX-License-Identifier: EUPL-1.2
2 : //! /jsonldContexts management (5.13; resources 6.29/6.30).
3 : //!
4 : //! Three kinds (5.13.1): Hosted (client-added, served on demand),
5 : //! Cached (externally-fetched, metadata only), ImplicitlyCreated (broker-made
6 : //! wrappers for array @contexts on subscriptions, served on demand).
7 :
8 : use crate::negotiate::*;
9 : use crate::state::{now_iso, AppState};
10 : use antares_jsonld::Loader;
11 : use antares_model::{NgsiError, TenantId};
12 : use axum::body::Bytes;
13 : use axum::extract::{Path, State};
14 : use axum::http::{header, HeaderMap, StatusCode};
15 : use axum::response::{IntoResponse, Response};
16 : use serde_json::{json, Value};
17 : use std::collections::HashMap;
18 :
19 : use crate::negotiate::CleanParams;
20 :
21 : /// Base URL under which this broker publishes its own @context entries
22 : /// (5.13.2.4 locally unique URI, 5.13.3.5 `URL`). The address is the
23 : /// broker's, so it comes from ITS configuration — ANTARES_PUBLIC_URL, the
24 : /// same value peers are handed as the 5.8.1.4 notification endpoint. The
25 : /// request's `Host` header is client input and only the fallback for a
26 : /// deployment that configures nothing.
27 208 : pub(crate) fn base_url(headers: &HeaderMap) -> String {
28 208 : context_base(std::env::var("ANTARES_PUBLIC_URL").ok().as_deref(), headers)
29 208 : }
30 :
31 224 : fn context_base(configured: Option<&str>, headers: &HeaderMap) -> String {
32 224 : let base = match configured.map(str::trim).filter(|u| !u.is_empty()) {
33 168 : Some(url) => url.trim_end_matches('/').to_owned(),
34 : None => {
35 56 : let host = headers
36 56 : .get(header::HOST)
37 56 : .and_then(|h| h.to_str().ok())
38 56 : .unwrap_or("localhost:9090");
39 56 : format!("http://{host}")
40 : }
41 : };
42 224 : format!("{base}/ngsi-ld/v1/jsonldContexts")
43 224 : }
44 :
45 : /// Validate the `details` query param: absent | true | false (053_05 sends
46 : /// `True`); anything else is 400 (052_04_02).
47 380 : fn details_param(params: &HashMap<String, String>) -> Result<bool, NgsiError> {
48 380 : match params.get("details").map(|s| s.to_ascii_lowercase()) {
49 132 : None => Ok(false),
50 248 : Some(v) if v == "true" => Ok(true),
51 54 : Some(v) if v == "false" => Ok(false),
52 14 : Some(v) => Err(NgsiError::BadRequestData(format!(
53 14 : "invalid details value {v:?}"
54 14 : ))),
55 : }
56 380 : }
57 :
58 316 : fn reload_param(params: &HashMap<String, String>) -> Result<bool, NgsiError> {
59 316 : match params.get("reload").map(|s| s.to_ascii_lowercase()) {
60 274 : None => Ok(false),
61 42 : Some(v) if v == "true" => Ok(true),
62 14 : Some(v) if v == "false" => Ok(false),
63 6 : Some(v) => Err(NgsiError::BadRequestData(format!(
64 6 : "invalid reload value {v:?}"
65 6 : ))),
66 : }
67 316 : }
68 :
69 : /// Detailed metadata object (5.13.3.5).
70 306 : fn details_obj(
71 306 : url: &str,
72 306 : local_id: &str,
73 306 : kind: &str,
74 306 : created_at: &str,
75 306 : usage: Option<&antares_jsonld::CtxUsage>,
76 306 : ) -> Value {
77 306 : let mut o = json!({
78 306 : "URL": url,
79 306 : "localId": local_id,
80 306 : "kind": kind,
81 306 : "createdAt": created_at,
82 : });
83 : // numberOfHits/lastUsage only for kinds where the suite's arithmetic
84 : // expects them (Cached + ImplicitlyCreated; 053_04 vs 053_06)
85 306 : if kind != "Hosted" {
86 214 : let hits = usage.map(|u| u.hits).unwrap_or(0);
87 214 : o["numberOfHits"] = json!(hits);
88 214 : if let Some(u) = usage {
89 210 : o["lastUsage"] = json!(u.last_usage);
90 210 : }
91 92 : }
92 306 : o
93 306 : }
94 :
95 : /// A resolved @context entry, whichever backing it has.
96 : enum CtxEntry {
97 : /// Hosted or ImplicitlyCreated store entry.
98 : Stored(Value),
99 : /// External URL known through the loader (kind Cached).
100 : Cached(antares_jsonld::CtxUsage),
101 : /// Built-in core context (undeletable).
102 : Core(String),
103 : }
104 :
105 : /// Usage view of a store row — the SHARED truth (per-instance loader
106 : /// stats split-brain behind a load balancer; the usage_bump hook keeps the
107 : /// row's counters current from every instance).
108 400 : fn row_usage(doc: &Value) -> antares_jsonld::CtxUsage {
109 400 : let created_at = doc["createdAt"].as_str().unwrap_or_default().to_owned();
110 : antares_jsonld::CtxUsage {
111 400 : url: doc["url"].as_str().unwrap_or_default().to_owned(),
112 400 : local_id: doc["localId"].as_str().unwrap_or_default().to_owned(),
113 400 : last_usage: doc["lastUsage"]
114 400 : .as_str()
115 400 : .map(str::to_owned)
116 400 : .unwrap_or_else(|| created_at.clone()),
117 400 : hits: doc["numberOfHits"].as_u64().unwrap_or(0),
118 400 : created_at,
119 : }
120 400 : }
121 :
122 : /// Build the Cached-entry view of a store row.
123 84 : fn cached_from_row(doc: &Value) -> CtxEntry {
124 84 : CtxEntry::Cached(row_usage(doc))
125 84 : }
126 :
127 : /// Ownership of a stored row (5.13.1, ADR-0021). Hosted and ImplicitlyCreated
128 : /// rows hold term mappings authored through one tenant's requests and are
129 : /// visible, servable and deletable only through that tenant; Cached rows are
130 : /// copies of public documents the broker fetched and belong to no tenant.
131 : /// Rows written before the owner member existed belong to the default tenant.
132 : ///
133 : /// The store answers the same question over its own `tenant_id`, so a row
134 : /// another Tenant owns does not reach this check under Postgres. Both stay:
135 : /// the day they disagree, the database is the one that refuses.
136 528 : fn row_visible(doc: &Value, tenant: &TenantId) -> bool {
137 528 : antares_store::context_row_visible(doc, Some(tenant))
138 528 : }
139 :
140 : /// 5.13.1: a Hosted or ImplicitlyCreated @context holds term mappings
141 : /// authored through one Tenant's requests, so it is one of that Tenant's
142 : /// documents and a purge of the Tenant takes it too. A Cached row is a copy
143 : /// of a public document and belongs to no Tenant (see `row_visible`), so it
144 : /// stays.
145 : ///
146 : /// The rows themselves also go with the store's own `purge_tenant`
147 : /// (ADR-0021). What only this pass can do is release the loader's warm copy
148 : /// and usage entry for each URL, which live in this process and would
149 : /// otherwise keep serving a document whose row is gone.
150 28 : pub async fn purge_tenant(st: &AppState, tenant: &TenantId) -> Result<(), NgsiError> {
151 32 : for row in st.store.context_list_meta(Some(tenant)).await? {
152 20 : if row["kind"].as_str() == Some("Cached") || !row_visible(&row, tenant) {
153 8 : continue;
154 12 : }
155 12 : let Some(local_id) = row["localId"].as_str() else {
156 0 : continue;
157 : };
158 12 : st.store.context_delete(Some(tenant), local_id).await?;
159 12 : if let Some(url) = row["url"].as_str() {
160 12 : st.loader.usage_remove(url).await;
161 0 : }
162 : }
163 28 : Ok(())
164 28 : }
165 :
166 : /// Resolve an id to the @context it names (5.13.4.4). Every probe is a keyed
167 : /// lookup: a store failure is an error, never "not found" — answering 404 for
168 : /// a hiccup would tell the client to add the @context a second time.
169 518 : async fn find_entry(st: &AppState, tenant: &TenantId, id: &str) -> ApiResult<Option<CtxEntry>> {
170 518 : if let Some(doc) = st.store.context_get(Some(tenant), id).await? {
171 : // Cached rows are addressable by their deterministic localId too.
172 260 : if doc["kind"].as_str() == Some("Cached") {
173 12 : return Ok(Some(cached_from_row(&doc)));
174 248 : }
175 : // another tenant's row is as absent as one that never existed
176 248 : return Ok(row_visible(&doc, tenant).then_some(CtxEntry::Stored(doc)));
177 258 : }
178 : // Stored entries are also addressable by their full URL (5.13.2.4): the
179 : // row key IS the URL's trailing segment, so this is one keyed lookup and
180 : // the row's own url still has to match the id.
181 258 : if let Some(pos) = id.rfind("/ngsi-ld/v1/jsonldContexts/") {
182 20 : let local_id = &id[pos + "/ngsi-ld/v1/jsonldContexts/".len()..];
183 20 : if let Some(doc) = st.store.context_get(Some(tenant), local_id).await? {
184 8 : if doc["url"].as_str() == Some(id) && row_visible(&doc, tenant) {
185 4 : return Ok(Some(CtxEntry::Stored(doc)));
186 4 : }
187 12 : }
188 238 : }
189 254 : if Loader::is_pinned_core(id) {
190 58 : return Ok(Some(CtxEntry::Core(id.to_owned())));
191 196 : }
192 : // Cached entries: the persisted row is the ONE existence truth (the
193 : // per-instance usage map split-brains behind a load balancer). The row id
194 : // is uuid5(url), so a URL-shaped id resolves in O(1).
195 196 : if id.starts_with("http://") || id.starts_with("https://") {
196 112 : let rid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, id.as_bytes()).to_string();
197 112 : if let Some(doc) = st.store.context_get(Some(tenant), &rid).await? {
198 72 : if doc["kind"].as_str() == Some("Cached") {
199 72 : return Ok(Some(cached_from_row(&doc)));
200 0 : }
201 40 : }
202 84 : }
203 124 : Ok(None)
204 518 : }
205 :
206 : // ---------- POST /jsonldContexts (5.13.2) ----------
207 :
208 : /// 5.13.2.4 + 5.5.4: a JSON-LD local context is a string (IRI), an object of
209 : /// term definitions, null, or an array of those — anything else is invalid
210 : /// JSON-LD and rejected as BadRequestData.
211 220 : fn valid_context_shape(v: &Value) -> bool {
212 220 : match v {
213 190 : Value::String(_) | Value::Object(_) | Value::Null => true,
214 18 : Value::Array(a) => a
215 18 : .iter()
216 32 : .all(|e| matches!(e, Value::String(_) | Value::Object(_) | Value::Null)),
217 12 : _ => false,
218 : }
219 220 : }
220 :
221 : /// 5.13.2.4 Add @context: store the client-supplied @context under a new
222 : /// locally unique URI, flagged "Hosted"; the URI is returned (Location).
223 238 : pub async fn add_context(
224 238 : State(st): State<AppState>,
225 238 : CleanParams(params): CleanParams,
226 238 : headers: HeaderMap,
227 238 : body: Bytes,
228 238 : ) -> Response {
229 238 : let go = async {
230 238 : let tenant = tenant_from(&headers)?;
231 238 : check_params(¶ms, &["local"])?;
232 : // 6.3.5: an unsupported media type is a bare 415. An ABSENT
233 : // Content-Type is tolerated — the body is parsed as JSON, as on the
234 : // entity routes — but a header that is present and unreadable is not
235 : // absent, and `content_type` reports both as the empty string. The
236 : // presence of the header is what separates them.
237 238 : gate!(st, &tenant, &headers, "5.13.2").await?;
238 238 : let ct = content_type(&headers)?;
239 238 : if headers.contains_key(header::CONTENT_TYPE)
240 232 : && ct != "application/ld+json"
241 220 : && ct != "application/json"
242 : {
243 4 : return Err(ApiError::Bare(StatusCode::UNSUPPORTED_MEDIA_TYPE));
244 234 : }
245 234 : let value: Value = serde_json::from_slice(&body)
246 234 : .map_err(|e| NgsiError::InvalidRequest(format!("body is not valid JSON: {e}")))?;
247 230 : let ctx_val = value.get("@context").cloned().ok_or_else(|| {
248 : // 050_02: a JSON object without @context is InvalidRequest
249 10 : NgsiError::InvalidRequest("body must carry an @context member".into())
250 10 : })?;
251 220 : if !valid_context_shape(&ctx_val) {
252 18 : return Err(NgsiError::BadRequestData("invalid JSON-LD @context value".into()).into());
253 202 : }
254 202 : let local_id = uuid::Uuid::new_v4().to_string();
255 202 : let url = format!("{}/{local_id}", base_url(&headers));
256 202 : let doc = json!({
257 202 : "url": url,
258 202 : "localId": local_id,
259 202 : "kind": "Hosted",
260 202 : "createdAt": now_iso(),
261 : // the adding tenant owns the entry (5.13.1 Hosted)
262 202 : "owner": tenant.as_str(),
263 202 : "body": {"@context": ctx_val.clone()},
264 : });
265 202 : st.store.context_put(Some(&tenant), &local_id, doc).await?;
266 : // stored FOR the adding Tenant (5.13.1 Hosted): 5.5.10 confines the
267 : // mappings to that Tenant's operations, so resolution is scoped the
268 : // same way the serve/list/delete paths above are
269 202 : st.loader.put_local_for(&tenant, url.clone(), ctx_val).await;
270 202 : let mut resp = (
271 202 : StatusCode::CREATED,
272 202 : [(
273 202 : header::LOCATION,
274 202 : format!("/ngsi-ld/v1/jsonldContexts/{local_id}"),
275 202 : )],
276 202 : )
277 202 : .into_response();
278 202 : echo_tenant(&tenant, &mut resp);
279 202 : Ok::<_, ApiError>(resp)
280 238 : };
281 238 : go.await.unwrap_or_else(|e| e.into_response())
282 238 : }
283 :
284 : // ---------- GET /jsonldContexts (5.13.3) ----------
285 :
286 : /// 5.13.3.4 List @contexts: one URL (or metadata object, 5.13.3.5) per
287 : /// stored @context matching the kind filter; no filter → all kinds.
288 212 : pub async fn list_contexts(
289 212 : State(st): State<AppState>,
290 212 : CleanParams(params): CleanParams,
291 212 : headers: HeaderMap,
292 212 : ) -> Response {
293 212 : let go = async {
294 212 : let tenant = tenant_from(&headers)?;
295 : // Table 6.29.3.2-1: details and kind are the only parameters of this
296 : // resource — it serves the whole list, so a pagination parameter would
297 : // be accepted and silently ignored.
298 212 : check_params(¶ms, &["kind", "details", "local"])?;
299 196 : gate!(st, &tenant, &headers, "5.13.3").await?;
300 :
301 196 : let details = details_param(¶ms)?;
302 182 : let kind_filter = params.get("kind");
303 182 : if let Some(k) = kind_filter {
304 94 : if !["Hosted", "Cached", "ImplicitlyCreated"].contains(&k.as_str()) {
305 10 : return Err(NgsiError::BadRequestData(format!("invalid kind {k:?}")).into());
306 84 : }
307 88 : }
308 534 : let keep = |k: &str| kind_filter.is_none_or(|f| f == k);
309 : // (url, localId, kind, createdAt, usage)
310 172 : let mut entries: Vec<(
311 172 : String,
312 172 : String,
313 172 : String,
314 172 : String,
315 172 : Option<antares_jsonld::CtxUsage>,
316 172 : )> = Vec::new();
317 362 : for c in st.store.context_list_meta(Some(&tenant)).await? {
318 362 : let kind = c["kind"].as_str().unwrap_or("Hosted").to_owned();
319 362 : if !keep(&kind) || !row_visible(&c, &tenant) {
320 98 : continue;
321 264 : }
322 264 : let url = c["url"].as_str().unwrap_or_default().to_owned();
323 : // counters from the ROW (shared truth), never this instance's map
324 264 : let usage = Some(row_usage(&c));
325 264 : entries.push((
326 264 : url,
327 264 : c["localId"].as_str().unwrap_or_default().to_owned(),
328 264 : kind,
329 264 : c["createdAt"].as_str().unwrap_or_default().to_owned(),
330 264 : usage,
331 264 : ));
332 : }
333 : // Cached entries come from the store rows walked above — the shared
334 : // truth every instance sees. Loader-only usage entries are NOT
335 : // listed (an entry another instance deleted must not resurface),
336 : // with one exception: pinned core contexts never get a row and stay
337 : // listable from local usage.
338 172 : if keep("Cached") {
339 340 : for u in st.loader.usage_list().await {
340 340 : if !Loader::is_pinned_core(&u.url) {
341 124 : continue;
342 216 : }
343 216 : entries.push((
344 216 : u.url.clone(),
345 216 : u.local_id.clone(),
346 216 : "Cached".into(),
347 216 : u.created_at.clone(),
348 216 : Some(u),
349 216 : ));
350 : }
351 74 : }
352 172 : let payload = if details {
353 : Value::Array(
354 66 : entries
355 66 : .iter()
356 206 : .map(|(url, lid, kind, created, usage)| {
357 206 : details_obj(url, lid, kind, created, usage.as_ref())
358 206 : })
359 66 : .collect(),
360 : )
361 : } else {
362 274 : Value::Array(entries.iter().map(|(url, ..)| json!(url)).collect())
363 : };
364 172 : let mut resp = (
365 172 : StatusCode::OK,
366 172 : [(header::CONTENT_TYPE, "application/json")],
367 172 : axum::Json(payload),
368 172 : )
369 172 : .into_response();
370 172 : echo_tenant(&tenant, &mut resp);
371 172 : Ok::<_, ApiError>(resp)
372 212 : };
373 212 : go.await.unwrap_or_else(|e| e.into_response())
374 212 : }
375 :
376 : // ---------- GET /jsonldContexts/{ctxId} (5.13.4) ----------
377 :
378 : /// 5.13.4.4 Serve @context: full content for Hosted/ImplicitlyCreated,
379 : /// OperationNotSupported for Cached, ResourceNotFound for unknown ids;
380 : /// details=true serves metadata for all kinds.
381 184 : pub async fn serve_context(
382 184 : State(st): State<AppState>,
383 184 : Path(id): Path<String>,
384 184 : CleanParams(params): CleanParams,
385 184 : headers: HeaderMap,
386 184 : ) -> Response {
387 184 : let go = async {
388 184 : let tenant = tenant_from(&headers)?;
389 184 : check_params(¶ms, &["details", "local"])?;
390 184 : gate!(st, &tenant, &headers, "5.13.4", ids: &[&id]).await?;
391 184 : let details = details_param(¶ms)?;
392 184 : let entry = find_entry(&st, &tenant, &id)
393 184 : .await?
394 184 : .ok_or_else(|| NgsiError::ResourceNotFound(format!("@context {id} not found")))?;
395 132 : let payload = match &entry {
396 82 : CtxEntry::Stored(doc) => {
397 82 : let kind = doc["kind"].as_str().unwrap_or("Hosted");
398 82 : let url = doc["url"].as_str().unwrap_or_default();
399 82 : if details {
400 52 : let usage = row_usage(doc);
401 52 : details_obj(
402 52 : url,
403 52 : doc["localId"].as_str().unwrap_or_default(),
404 52 : kind,
405 52 : doc["createdAt"].as_str().unwrap_or_default(),
406 52 : Some(&usage),
407 : )
408 : } else {
409 30 : doc["body"].clone()
410 : }
411 : }
412 36 : CtxEntry::Cached(u) => {
413 36 : if !details {
414 : // Cached entries are never served on demand (5.13.4.4)
415 0 : return Err(NgsiError::OperationNotSupported(
416 0 : "Cached @contexts are not served on demand (5.13.4)".into(),
417 0 : )
418 0 : .into());
419 36 : }
420 36 : details_obj(&u.url, &u.local_id, "Cached", &u.created_at, Some(u))
421 : }
422 14 : CtxEntry::Core(url) => {
423 14 : if details {
424 12 : let usage = st.loader.usage_get(url).await;
425 12 : details_obj(url, url, "Cached", &st.started_at, usage.as_ref())
426 : } else {
427 2 : return Err(NgsiError::OperationNotSupported(
428 2 : "Cached @contexts are not served on demand (5.13.4)".into(),
429 2 : )
430 2 : .into());
431 : }
432 : }
433 : };
434 : // a serve counts as a hit for Cached/ImplicitlyCreated entries, after
435 : // the value shown in this response (053_06/053_08 arithmetic) —
436 : // EXCEPT broker-internal fetches (a fleet peer resolving this
437 : // @context through the LB): the resolving instance bumps the shared
438 : // row itself, a serve-side bump would double-count (053_08 fleet).
439 130 : let internal_fetch = headers.contains_key(antares_jsonld::INTERNAL_FETCH_HEADER);
440 82 : match &entry {
441 82 : CtxEntry::Stored(doc) if !internal_fetch && doc["kind"] == "ImplicitlyCreated" => {
442 6 : if let Some(u) = doc["url"].as_str() {
443 6 : let _ = st.loader.bump_url(Some(&tenant), u).await;
444 0 : }
445 : }
446 36 : CtxEntry::Cached(u) if !internal_fetch => {
447 36 : let _ = st.loader.bump_url(Some(&tenant), &u.url).await;
448 : }
449 88 : _ => {}
450 : }
451 130 : let mut resp = (
452 130 : StatusCode::OK,
453 130 : [(header::CONTENT_TYPE, "application/json")],
454 130 : axum::Json(payload),
455 130 : )
456 130 : .into_response();
457 130 : echo_tenant(&tenant, &mut resp);
458 130 : Ok::<_, ApiError>(resp)
459 184 : };
460 184 : go.await.unwrap_or_else(|e| e.into_response())
461 184 : }
462 :
463 : // ---------- DELETE /jsonldContexts/{ctxId} (5.13.5) ----------
464 :
465 : /// 5.13.5.4 Delete and Reload @context: unknown id → ResourceNotFound;
466 : /// reload=true re-downloads a Cached @context in place (failure →
467 : /// LdContextNotAvailable, entry kept) and is BadRequestData for other
468 : /// kinds; without reload the entry is removed.
469 316 : pub async fn delete_context(
470 316 : State(st): State<AppState>,
471 316 : Path(id): Path<String>,
472 316 : CleanParams(params): CleanParams,
473 316 : headers: HeaderMap,
474 316 : ) -> Response {
475 316 : let go = async {
476 316 : let tenant = tenant_from(&headers)?;
477 316 : check_params(¶ms, &["reload", "local"])?;
478 316 : gate!(st, &tenant, &headers, "5.13.5", ids: &[&id]).await?;
479 :
480 316 : let reload = reload_param(¶ms)?;
481 310 : let entry = find_entry(&st, &tenant, &id).await?;
482 310 : if reload {
483 : // reload is only meaningful for Cached @contexts (5.13.5.4);
484 : // unknown ids and non-Cached kinds are 400 (051_04_01/05)
485 22 : return match entry {
486 4 : Some(CtxEntry::Core(url)) => {
487 4 : st.loader.refetch(&url).await.map_err(ApiError::from)?;
488 4 : Ok(no_content(&tenant))
489 : }
490 12 : Some(CtxEntry::Cached(u)) => {
491 12 : st.loader.refetch(&u.url).await.map_err(ApiError::from)?;
492 0 : Ok(no_content(&tenant))
493 : }
494 12 : _ => Err(NgsiError::BadRequestData(
495 12 : "reload is only valid for Cached @contexts (5.13.5)".into(),
496 12 : )
497 12 : .into()),
498 : };
499 282 : }
500 232 : match entry {
501 50 : None => Err(NgsiError::ResourceNotFound(format!("@context {id} not found")).into()),
502 : Some(CtxEntry::Core(_)) => {
503 40 : Err(NgsiError::BadRequestData("the core @context cannot be deleted".into()).into())
504 : }
505 36 : Some(CtxEntry::Cached(u)) => {
506 36 : st.loader.usage_remove(&u.url).await;
507 : // The write-through row shares the deterministic local id —
508 : // deleting the API entry must delete the persisted copy too,
509 : // or a restart resurrects a deleted @context (5.13.5).
510 36 : let _ = st.store.context_delete(Some(&tenant), &u.local_id).await;
511 36 : Ok(no_content(&tenant))
512 : }
513 156 : Some(CtxEntry::Stored(doc)) => {
514 156 : let lid = doc["localId"].as_str().unwrap_or(&id);
515 156 : st.store.context_delete(Some(&tenant), lid).await?;
516 156 : if let Some(url) = doc.get("url").and_then(Value::as_str) {
517 156 : st.loader.usage_remove(url).await;
518 0 : }
519 156 : Ok::<_, ApiError>(no_content(&tenant))
520 : }
521 : }
522 316 : };
523 316 : go.await.unwrap_or_else(|e| e.into_response())
524 316 : }
525 :
526 : #[cfg(test)]
527 : mod tests {
528 : use super::*;
529 :
530 16 : fn forged_host() -> HeaderMap {
531 16 : let mut h = HeaderMap::new();
532 16 : h.insert(header::HOST, "attacker.example".parse().expect("host"));
533 16 : h
534 16 : }
535 :
536 24 : fn hosted_row(url: &str, local_id: &str, owner: &TenantId) -> Value {
537 24 : json!({
538 24 : "url": url,
539 24 : "localId": local_id,
540 24 : "kind": "Hosted",
541 24 : "createdAt": now_iso(),
542 24 : "owner": owner.as_str(),
543 24 : "body": {"@context": {}},
544 : })
545 24 : }
546 :
547 : /// 5.13.1 + 5.5.10: purging a Tenant takes the @contexts authored through
548 : /// ITS requests and nothing else — one Tenant's deletion must not reach
549 : /// another Tenant's term mappings, and a Cached row is a copy of a public
550 : /// document belonging to no Tenant, so it survives every purge.
551 : #[tokio::test]
552 4 : async fn clause_5_13_1_a_tenant_purge_takes_only_its_own_contexts() {
553 4 : let st = AppState::new("antares-ctx-purge".into());
554 4 : let alpha = TenantId::new("alpha").expect("tenant");
555 4 : let beta = TenantId::new("beta").expect("tenant");
556 4 : let base = "http://broker.example/ngsi-ld/v1/jsonldContexts";
557 8 : for (lid, owner) in [("hosted-alpha", &alpha), ("hosted-beta", &beta)] {
558 8 : st.store
559 8 : .context_put(
560 8 : Some(owner),
561 8 : lid,
562 8 : hosted_row(&format!("{base}/{lid}"), lid, owner),
563 8 : )
564 8 : .await
565 8 : .expect("store the Hosted @context");
566 : }
567 : // an ImplicitlyCreated wrapper is owned the same way a Hosted one is
568 4 : let mut implicit = hosted_row(&format!("{base}/implicit-alpha"), "implicit-alpha", &alpha);
569 4 : implicit["kind"] = json!("ImplicitlyCreated");
570 4 : st.store
571 4 : .context_put(Some(&alpha), "implicit-alpha", implicit)
572 4 : .await
573 4 : .expect("store the ImplicitlyCreated @context");
574 : // and a Cached copy, which carries no owner at all
575 4 : st.store
576 4 : .context_put(
577 4 : None,
578 4 : "cached-shared",
579 4 : json!({"url": "https://example.org/ctx.jsonld", "localId": "cached-shared",
580 4 : "kind": "Cached", "createdAt": now_iso()}),
581 4 : )
582 4 : .await
583 4 : .expect("store the Cached @context");
584 : // a legacy row with no owner member belongs to the default Tenant
585 4 : let mut legacy = hosted_row(&format!("{base}/legacy"), "legacy", &alpha);
586 4 : legacy.as_object_mut().expect("row object").remove("owner");
587 4 : st.store
588 4 : .context_put(Some(&TenantId::default()), "legacy", legacy)
589 4 : .await
590 4 : .expect("store legacy");
591 :
592 4 : purge_tenant(&st, &alpha).await.expect("purge alpha");
593 :
594 : // read as the row's own Tenant: a purge that took the row and a
595 : // store that hides it from its owner are different failures, and the
596 : // assertions below are about the first.
597 28 : async fn present(st: &AppState, owner: Option<&TenantId>, lid: &str) -> bool {
598 28 : st.store
599 28 : .context_get(owner, lid)
600 28 : .await
601 28 : .expect("store read")
602 28 : .is_some()
603 28 : }
604 4 : assert!(
605 4 : !present(&st, Some(&alpha), "hosted-alpha").await,
606 : "the purged Tenant's Hosted row goes"
607 : );
608 4 : assert!(
609 4 : !present(&st, Some(&alpha), "implicit-alpha").await,
610 : "and so does its ImplicitlyCreated wrapper"
611 : );
612 4 : assert!(
613 4 : present(&st, Some(&beta), "hosted-beta").await,
614 : "another Tenant's Hosted @context must survive a purge it has no part in"
615 : );
616 4 : assert!(
617 4 : present(&st, None, "cached-shared").await,
618 : "a Cached copy belongs to no Tenant and is not purged with one"
619 : );
620 4 : assert!(
621 4 : present(&st, Some(&TenantId::default()), "legacy").await,
622 : "an owner-less row belongs to the default Tenant, not to alpha"
623 : );
624 : // the same purge run against the default Tenant reaches the legacy row
625 4 : purge_tenant(&st, &TenantId::default())
626 4 : .await
627 4 : .expect("purge default");
628 4 : assert!(!present(&st, Some(&TenantId::default()), "legacy").await);
629 4 : assert!(
630 4 : present(&st, Some(&beta), "hosted-beta").await,
631 4 : "still beta's"
632 4 : );
633 4 : }
634 :
635 : /// 5.13.4.4: a stored @context resolves by its locally unique URI —
636 : /// the localId and the full published URL name the SAME entry (5.13.2.4),
637 : /// while a URL that only ends in a known localId names no entry at all,
638 : /// and another tenant's Hosted row stays invisible (5.13.1).
639 : #[tokio::test]
640 4 : async fn clause_5_13_4_entry_resolves_by_local_id_and_by_url() {
641 4 : let st = AppState::new("antares-ctx-find".into());
642 4 : let owner = TenantId::default();
643 4 : let other = TenantId::new("beta").expect("tenant");
644 4 : let local_id = "b2a1c0de-0000-4000-8000-000000000001";
645 4 : let url = format!("http://broker.example/ngsi-ld/v1/jsonldContexts/{local_id}");
646 4 : st.store
647 4 : .context_put(Some(&owner), local_id, hosted_row(&url, local_id, &owner))
648 4 : .await
649 4 : .expect("store the @context");
650 4 : assert!(
651 4 : find_entry(&st, &owner, local_id)
652 4 : .await
653 4 : .expect("store")
654 4 : .is_some(),
655 : "the localId names the entry"
656 : );
657 4 : assert!(
658 4 : find_entry(&st, &owner, &url)
659 4 : .await
660 4 : .expect("store")
661 4 : .is_some(),
662 : "the published URL names the same entry"
663 : );
664 4 : assert!(
665 4 : find_entry(&st, &other, &url)
666 4 : .await
667 4 : .expect("store")
668 4 : .is_none(),
669 : "another tenant's Hosted @context is as absent as one that never existed"
670 : );
671 4 : let forged = format!("http://attacker.example/ngsi-ld/v1/jsonldContexts/{local_id}");
672 4 : assert!(
673 4 : find_entry(&st, &owner, &forged)
674 4 : .await
675 4 : .expect("store")
676 4 : .is_none(),
677 : "a foreign URL ending in a known localId is not that entry"
678 : );
679 4 : assert!(find_entry(&st, &owner, "no-such-context")
680 4 : .await
681 4 : .expect("store")
682 4 : .is_none());
683 4 : }
684 :
685 : /// 5.13.2.4 stores the @context "supplied by the client" under a locally
686 : /// unique URI; 5.13.1 makes that Hosted entry the adding Tenant's own
687 : /// resource and 5.5.10 makes the Tenant the boundary an operation applies
688 : /// within. So the mappings expand the adding Tenant's payloads only —
689 : /// another Tenant handing the broker the same URL resolves nothing.
690 : #[tokio::test]
691 4 : async fn clause_5_13_1_added_context_expands_only_the_adding_tenant() {
692 4 : let st = AppState::new("antares-ctx-tenant".into());
693 4 : let alpha = TenantId::new("alpha").expect("tenant");
694 4 : let beta = TenantId::new("beta").expect("tenant");
695 4 : let mut headers = HeaderMap::new();
696 : // a dead address: the published URL cannot be fetched back over the
697 : // network, so a resolution that succeeds came from the stored entry
698 4 : headers.insert(header::HOST, "127.0.0.1:9".parse().expect("host"));
699 4 : headers.insert("NGSILD-Tenant", "alpha".parse().expect("tenant header"));
700 4 : let resp = add_context(
701 4 : State(st.clone()),
702 4 : CleanParams(HashMap::new()),
703 4 : headers.clone(),
704 4 : Bytes::from(r#"{"@context":{"secret":"https://alpha.example/secret"}}"#),
705 4 : )
706 4 : .await;
707 4 : assert_eq!(resp.status(), StatusCode::CREATED);
708 4 : let local_id = resp
709 4 : .headers()
710 4 : .get(header::LOCATION)
711 4 : .and_then(|v| v.to_str().ok())
712 4 : .and_then(|l| l.rsplit('/').next())
713 4 : .expect("Location names the new @context")
714 4 : .to_owned();
715 4 : let url = Value::String(format!("{}/{local_id}", base_url(&headers)));
716 :
717 4 : let ctx = st
718 4 : .loader
719 4 : .resolve_for(&alpha, &url)
720 4 : .await
721 4 : .expect("the adding Tenant resolves its own @context");
722 4 : assert_eq!(ctx.expand_key("secret"), "https://alpha.example/secret");
723 :
724 4 : let err = st
725 4 : .loader
726 4 : .resolve_for(&beta, &url)
727 4 : .await
728 4 : .expect_err("another Tenant must not resolve this @context");
729 4 : assert!(
730 4 : matches!(err, NgsiError::LdContextNotAvailable(_)),
731 : "a foreign Hosted @context is not available, got {err:?}"
732 : );
733 : // and the entry is invisible to the other Tenant through the API too
734 4 : assert!(
735 4 : find_entry(&st, &beta, &local_id)
736 4 : .await
737 4 : .expect("store")
738 4 : .is_none(),
739 4 : "another Tenant's Hosted @context is as absent as one that never existed"
740 4 : );
741 4 : }
742 :
743 : /// 5.13.1 + 5.5.10 on the three handlers a client reaches, not only on the
744 : /// lookup they share: another Tenant's Hosted @context must not appear in
745 : /// the listing (with or without `details`, where the URL and the localId
746 : /// would both be readable), must not be served, and must not be deletable.
747 : /// A delete that answered 204 would be worse than a leak: the owning
748 : /// Tenant's term mappings would be gone with no error anywhere.
749 : #[tokio::test]
750 4 : async fn clause_5_13_1_the_handlers_hide_another_tenants_context() {
751 4 : let st = AppState::new("antares-ctx-crosstenant".into());
752 4 : let alpha = TenantId::new("alpha").expect("tenant");
753 4 : let lid = "b2a1c0de-0000-4000-8000-000000000002";
754 4 : let url = format!("http://broker.example/ngsi-ld/v1/jsonldContexts/{lid}");
755 4 : st.store
756 4 : .context_put(Some(&alpha), lid, hosted_row(&url, lid, &alpha))
757 4 : .await
758 4 : .expect("store the @context");
759 4 : let mut foreign = HeaderMap::new();
760 4 : foreign.insert("NGSILD-Tenant", "beta".parse().expect("tenant header"));
761 :
762 8 : for details in ["false", "true"] {
763 8 : let p: HashMap<String, String> = [("details".to_owned(), details.to_owned())]
764 8 : .into_iter()
765 8 : .collect();
766 8 : let resp = list_contexts(State(st.clone()), CleanParams(p), foreign.clone()).await;
767 8 : assert_eq!(resp.status(), StatusCode::OK);
768 8 : let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
769 8 : .await
770 8 : .expect("list body");
771 8 : let shown = String::from_utf8_lossy(&body);
772 8 : assert!(
773 8 : !shown.contains(lid),
774 : "details={details} listed another Tenant's @context: {shown}"
775 : );
776 : }
777 :
778 8 : for id in [lid.to_owned(), url.clone()] {
779 8 : let resp = serve_context(
780 8 : State(st.clone()),
781 8 : Path(id.clone()),
782 8 : CleanParams(HashMap::new()),
783 8 : foreign.clone(),
784 8 : )
785 8 : .await;
786 8 : assert_eq!(
787 8 : resp.status(),
788 : StatusCode::NOT_FOUND,
789 : "serving {id} to another Tenant"
790 : );
791 8 : let resp = delete_context(
792 8 : State(st.clone()),
793 8 : Path(id.clone()),
794 8 : CleanParams(HashMap::new()),
795 8 : foreign.clone(),
796 8 : )
797 8 : .await;
798 8 : assert_eq!(
799 8 : resp.status(),
800 : StatusCode::NOT_FOUND,
801 : "deleting {id} as another Tenant"
802 : );
803 : }
804 4 : assert!(
805 4 : st.store
806 4 : .context_get(Some(&alpha), lid)
807 4 : .await
808 4 : .expect("store")
809 4 : .is_some(),
810 : "the owning Tenant's @context survived a foreign delete"
811 : );
812 : // and the owner still reaches it
813 4 : let mut own = HeaderMap::new();
814 4 : own.insert("NGSILD-Tenant", "alpha".parse().expect("tenant header"));
815 4 : let resp = serve_context(
816 4 : State(st.clone()),
817 4 : Path(lid.to_owned()),
818 4 : CleanParams(HashMap::new()),
819 4 : own,
820 4 : )
821 4 : .await;
822 4 : assert_eq!(resp.status(), StatusCode::OK, "the owner is served its own");
823 4 : }
824 :
825 : /// Table 6.29.3.2-1: List @contexts defines `details` and `kind` only —
826 : /// a pagination parameter this resource does not implement must be
827 : /// refused (6.3.20 InvalidRequest), never accepted and ignored.
828 : #[tokio::test]
829 4 : async fn clause_6_29_3_2_list_takes_only_the_table_parameters() {
830 4 : let st = AppState::new("antares-ctx-params".into());
831 16 : for bad in ["limit", "offset", "count", "bogus"] {
832 16 : let p: HashMap<String, String> =
833 16 : [(bad.to_owned(), "1".to_owned())].into_iter().collect();
834 16 : let resp = list_contexts(State(st.clone()), CleanParams(p), HeaderMap::new()).await;
835 16 : assert_eq!(
836 16 : resp.status(),
837 4 : StatusCode::BAD_REQUEST,
838 4 : "{bad:?} is not a List @contexts parameter"
839 4 : );
840 4 : }
841 8 : for good in [("details", "true"), ("kind", "Hosted")] {
842 8 : let p: HashMap<String, String> = [(good.0.to_owned(), good.1.to_owned())]
843 8 : .into_iter()
844 8 : .collect();
845 8 : let resp = list_contexts(State(st.clone()), CleanParams(p), HeaderMap::new()).await;
846 8 : assert_eq!(resp.status(), StatusCode::OK, "{good:?}");
847 4 : }
848 4 : }
849 :
850 : /// 5.13.2.4/5.13.3.5: the URI published for a broker-served @context
851 : /// names the BROKER. It is taken from configuration, so a forged `Host`
852 : /// header cannot make the broker advertise someone else's address, and a
853 : /// TLS deployment is not stuck advertising `http`.
854 : #[test]
855 4 : fn published_context_url_comes_from_configuration_not_the_host_header() {
856 4 : assert_eq!(
857 4 : context_base(Some("https://broker.example"), &forged_host()),
858 : "https://broker.example/ngsi-ld/v1/jsonldContexts"
859 : );
860 : // a configured value with a trailing slash must not double it
861 4 : assert_eq!(
862 4 : context_base(Some("https://broker.example/"), &forged_host()),
863 : "https://broker.example/ngsi-ld/v1/jsonldContexts"
864 : );
865 : // nothing configured: today's Host-derived URL stays the fallback
866 4 : assert_eq!(
867 4 : context_base(None, &forged_host()),
868 : "http://attacker.example/ngsi-ld/v1/jsonldContexts"
869 : );
870 4 : assert_eq!(
871 4 : context_base(Some(""), &forged_host()),
872 : "http://attacker.example/ngsi-ld/v1/jsonldContexts",
873 : "an empty setting is no setting"
874 : );
875 4 : }
876 : }
|