Skip to main content

koprogo_api/infrastructure/web/middleware/
scope_guard.rs

1//! `scope_guard` middleware — Story 1.3 (refonte UX multi-rôle ACP).
2//!
3//! Reads the optional scope hint provided by the client (header
4//! `X-Scope-AcpId` *or* query parameter `?acp_id=...`), resolves the
5//! caller's effective `AcpCaller` from JWT, and injects an `AcpScope`
6//! into the request extensions for downstream handlers to consume.
7//!
8//! Refuses the request early (without hitting the handler) if the
9//! caller attempts to address an ACP outside their scope.
10//!
11//! Design:
12//! - The middleware is a thin `Transform` registered on `/buildings` /
13//!   `/acps` routes; it consults `AppState::list_acps_use_case` to
14//!   verify the scope.
15//! - For routes that don't need a forced ACP scope (e.g. admin listing
16//!   "all"), the middleware is permissive: no scope hint → no
17//!   restriction, the use-case will fall back to the role-derived
18//!   default scope.
19//!
20//! Error semantics (cf. architecture §6.3) :
21//! - 401 `Unauthorized` if no/invalid JWT
22//! - 403 `AcpNotInScope { acp_id }` if scope forged out of perimeter
23//! - 400 `Validation` if header/query are malformed *and* the role
24//!   needs an explicit scope id (non-admin)
25
26use std::future::{ready, Future, Ready};
27use std::pin::Pin;
28use std::sync::Arc;
29
30use actix_web::{
31    body::{EitherBody, MessageBody},
32    dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
33    http::StatusCode,
34    web, Error, HttpMessage, HttpResponse, ResponseError,
35};
36use serde_json::json;
37use thiserror::Error;
38use uuid::Uuid;
39
40use crate::application::error::AppError;
41use crate::application::use_cases::acp_use_cases::{AcpCaller, AcpUseCases};
42use crate::application::use_cases::building_use_cases::BuildingUseCases;
43use crate::application::use_cases::call_for_funds_use_cases::CallForFundsUseCases;
44use crate::application::use_cases::convocation_use_cases::ConvocationUseCases;
45use crate::application::use_cases::document_use_cases::DocumentUseCases;
46use crate::application::use_cases::gamification_use_cases::ChallengeUseCases;
47use crate::application::use_cases::local_exchange_use_cases::LocalExchangeUseCases;
48use crate::application::use_cases::notice_use_cases::NoticeUseCases;
49use crate::application::use_cases::owner_contribution_use_cases::OwnerContributionUseCases;
50use crate::application::use_cases::owner_use_cases::OwnerUseCases;
51use crate::application::use_cases::poll_use_cases::PollUseCases;
52use crate::application::use_cases::quote_use_cases::QuoteUseCases;
53use crate::application::use_cases::resource_booking_use_cases::ResourceBookingUseCases;
54use crate::application::use_cases::shared_object_use_cases::SharedObjectUseCases;
55use crate::application::use_cases::skill_use_cases::SkillUseCases;
56use crate::application::use_cases::technical_spec_use_cases::TechnicalSpecUseCases;
57use crate::application::use_cases::ticket_use_cases::TicketUseCases;
58use crate::application::use_cases::unit_use_cases::UnitUseCases;
59use crate::infrastructure::web::app_state::AppState;
60use crate::infrastructure::web::AuthenticatedUser;
61
62/// Header name accepted as a scope hint. Case-insensitive per HTTP RFC.
63pub const SCOPE_ACP_HEADER: &str = "X-Scope-AcpId";
64
65/// Resolved scope context, injected into request extensions by the
66/// `ScopeGuard` middleware. Handlers read it via
67/// `req.extensions().get::<AcpScope>()` (or via an extractor in a
68/// follow-up story).
69#[derive(Debug, Clone)]
70pub struct AcpScope {
71    /// Caller derived from JWT (mapped by the same convention as
72    /// `acp_handlers::caller_from_user`).
73    pub caller: AcpCaller,
74    /// ACP id explicitly requested by the client (header/query).
75    /// `None` = use role-derived default scope.
76    pub requested_acp_id: Option<Uuid>,
77    /// `true` if the middleware has verified the caller is allowed to
78    /// see the requested ACP. Always `true` when `requested_acp_id` is
79    /// `None` (no forging possible).
80    pub allowed: bool,
81}
82
83/// Errors surfaced by `ScopeGuard`. Mapped to HTTP via `ResponseError`.
84#[derive(Debug, Error)]
85pub enum ScopeGuardError {
86    #[error("Unauthorized — missing or invalid JWT")]
87    Unauthorized,
88
89    #[error("ACP {acp_id} not in scope")]
90    AcpNotInScope { acp_id: Uuid },
91
92    #[error("Validation error: {0}")]
93    Validation(String),
94
95    #[error("Internal error: {0}")]
96    Internal(String),
97}
98
99impl ScopeGuardError {
100    pub fn kind(&self) -> &'static str {
101        match self {
102            ScopeGuardError::Unauthorized => "unauthorized",
103            ScopeGuardError::AcpNotInScope { .. } => "acp_not_in_scope",
104            ScopeGuardError::Validation(_) => "validation",
105            ScopeGuardError::Internal(_) => "internal",
106        }
107    }
108}
109
110impl ResponseError for ScopeGuardError {
111    fn status_code(&self) -> StatusCode {
112        match self {
113            ScopeGuardError::Unauthorized => StatusCode::UNAUTHORIZED,
114            ScopeGuardError::AcpNotInScope { .. } => StatusCode::FORBIDDEN,
115            ScopeGuardError::Validation(_) => StatusCode::BAD_REQUEST,
116            ScopeGuardError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
117        }
118    }
119
120    fn error_response(&self) -> HttpResponse {
121        HttpResponse::build(self.status_code()).json(json!({
122            "error": self.to_string(),
123            "kind": self.kind(),
124        }))
125    }
126}
127
128impl From<AppError> for ScopeGuardError {
129    fn from(err: AppError) -> Self {
130        match err {
131            AppError::AcpNotInScope { acp_id } => ScopeGuardError::AcpNotInScope { acp_id },
132            AppError::Unauthorized | AppError::InvalidCredentials | AppError::TokenError(_) => {
133                ScopeGuardError::Unauthorized
134            }
135            AppError::Validation(s) => ScopeGuardError::Validation(s),
136            other => ScopeGuardError::Internal(other.to_string()),
137        }
138    }
139}
140
141// ============================================================================
142// Pure helpers (used by middleware AND tested by BDD/unit tests without
143// actix machinery).
144// ============================================================================
145
146/// Map a `UserRoleString + organization_id + user_id` triple to an
147/// `AcpCaller`. Same convention as `acp_handlers::caller_from_user` —
148/// duplicated here to keep the middleware free of handler imports.
149pub fn caller_from_role(role: &str, organization_id: Option<Uuid>, user_id: Uuid) -> AcpCaller {
150    match role.to_lowercase().as_str() {
151        "superadmin" => AcpCaller::SuperAdmin,
152        "admin" => match organization_id {
153            Some(org) => AcpCaller::Admin {
154                organization_id: org,
155            },
156            None => AcpCaller::SuperAdmin,
157        },
158        "syndic" | "accountant" => match organization_id {
159            Some(org) => AcpCaller::Syndic {
160                organization_id: org,
161            },
162            None => AcpCaller::Owner { user_id },
163        },
164        _ => AcpCaller::Owner { user_id },
165    }
166}
167
168/// Extract the requested ACP id from headers or query string.
169/// Header `X-Scope-AcpId` takes precedence over `?acp_id=`.
170/// Returns `Err(Validation)` if a value is present but malformed.
171pub fn extract_requested_acp_id(
172    header_value: Option<&str>,
173    query_value: Option<&str>,
174) -> Result<Option<Uuid>, ScopeGuardError> {
175    let raw = header_value.or(query_value);
176    match raw {
177        None => Ok(None),
178        Some(s) if s.trim().is_empty() => Ok(None),
179        Some(s) => Uuid::parse_str(s.trim())
180            .map(Some)
181            .map_err(|_| ScopeGuardError::Validation(format!("invalid acp_id: {}", s))),
182    }
183}
184
185/// Decide whether the caller is allowed to *attach* the requested scope
186/// id, without hitting the DB. Returns:
187/// - `Ok(None)` : caller has no requested scope → no enforcement needed
188/// - `Ok(Some(acp_id))` : the middleware must consult the use-case to
189///   verify `assert_caller_can_see(acp_id)`
190/// - `Err(Validation)` : the caller is non-admin AND owns no role-scope
191///   information (e.g. syndic with `organization_id = None` AND no
192///   explicit acp_id) — refuse rather than guess.
193pub fn requires_repository_check(
194    caller: &AcpCaller,
195    requested: Option<Uuid>,
196) -> Result<Option<Uuid>, ScopeGuardError> {
197    match (caller, requested) {
198        // SuperAdmin can pin any ACP, but we still want to verify it exists.
199        (AcpCaller::SuperAdmin, Some(id)) => Ok(Some(id)),
200        (AcpCaller::SuperAdmin, None) => Ok(None),
201
202        // Admin / Syndic with their own org are allowed if requested
203        // matches their org-derived scope or is None.
204        (AcpCaller::Admin { .. }, req) | (AcpCaller::Syndic { .. }, req) => Ok(req),
205
206        // Owner: every request must be checked via the use-case (no
207        // direct shortcut — story 1.3 conservatively refuses pinning
208        // until story 3.5 wires user_role_assignments.scope/scope_id).
209        (AcpCaller::Owner { .. }, req) => Ok(req),
210    }
211}
212
213/// Hotfix #603 — résout `building.acp_id -> acp.organization_id` et applique
214/// l'isolation multi-tenant sur les GET-by-id (building, budget, expense,
215/// meeting, resolution, unit, work_report).
216///
217/// Après #602 (`Building.organization_id -> acp_id`), `BuildingResponseDto`
218/// ne porte plus `organization_id` ; les 7 handlers ci-dessus ont perdu leur
219/// `user.verify_org_access(...)`. Ce helper recâble la chaîne en lookup ACP.
220///
221/// Sémantique :
222/// - SuperAdmin : toujours autorisé (bypass).
223/// - Sinon : `acp.organization_id` MUST == `user.organization_id`. Sinon
224///   `AppError::AcpNotInScope` (HTTP 403 via `ResponseError`).
225/// - ACP introuvable OU `acp.organization_id IS NULL` (auto-gérée) :
226///   refuse pour non-superadmin (conservateur — gouvernance ACP auto-gérée
227///   en story 4.x).
228pub async fn verify_acp_org_access(
229    user: &AuthenticatedUser,
230    acp_id: Uuid,
231    acp_use_cases: &AcpUseCases,
232) -> Result<(), AppError> {
233    if user.is_superadmin() {
234        return Ok(());
235    }
236
237    let acp = acp_use_cases
238        .find_acp(acp_id)
239        .await?
240        .ok_or(AppError::AcpNotInScope { acp_id })?;
241
242    let acp_org_id = acp
243        .organization_id
244        .ok_or(AppError::AcpNotInScope { acp_id })?;
245
246    user.verify_org_access(acp_org_id)
247        .map_err(|_| AppError::AcpNotInScope { acp_id })
248}
249
250/// Isolation multi-tenant sur les ÉCRITURES qui désignent un immeuble par le
251/// CORPS de la requête.
252///
253/// `verify_acp_org_access` ci-dessus protège les lectures par identifiant
254/// (Hotfix #603). Le côté écriture n'avait pas d'équivalent : 29 routes
255/// `POST`/`PUT` acceptent un `building_id` dans leur DTO et aucune ne
256/// vérifiait qu'il appartient à l'organisation de l'appelant.
257///
258/// Mesuré en conditions réelles le 2026-09-02 entre deux cabinets syndics
259/// indépendants. Le cabinet B a pu, sur l'immeuble du cabinet A :
260///
261///   POST /expenses          → 201, dépense de 50 000 € VISIBLE dans la
262///                             liste des charges de l'immeuble de A ;
263///   POST /call-for-funds    → 201, puis `send` → 200, générant une
264///                             quote-part de 25 000 € réclamée à une
265///                             copropriétaire de A.
266///
267/// Les lectures, elles, répondaient bien 403 dans toutes les directions : la
268/// faille était strictement du côté écriture.
269///
270/// La garde existante `dto.organization_id = <celle du JWT>` ne protège pas
271/// de cela — elle empêche d'ESTAMPILLER l'enregistrement au nom d'autrui, pas
272/// de le RATTACHER au patrimoine d'autrui. Le champ contrôlé n'était pas le
273/// bon.
274///
275/// Sémantique : superadmin passe ; sinon l'immeuble doit exister et son ACP
276/// appartenir à l'organisation de l'appelant. Un immeuble introuvable est
277/// refusé plutôt qu'ignoré — accepter une écriture pointant vers le vide
278/// créerait un orphelin invisible.
279pub async fn verify_building_org_access(
280    user: &AuthenticatedUser,
281    building_id: Uuid,
282    building_use_cases: &BuildingUseCases,
283    acp_use_cases: &AcpUseCases,
284) -> Result<(), AppError> {
285    if user.is_superadmin() {
286        return Ok(());
287    }
288
289    let building = building_use_cases
290        .get_building(building_id)
291        .await
292        .map_err(AppError::from)?
293        .ok_or(AppError::NotFound(format!(
294            "Building not found: {building_id}"
295        )))?;
296
297    let acp_id = Uuid::parse_str(&building.acp_id)
298        .map_err(|_| AppError::Internal("Invalid building.acp_id format".to_string()))?;
299
300    verify_acp_org_access(user, acp_id, acp_use_cases).await
301}
302
303/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **convocation**.
304///
305/// La convocation porte directement son `building_id` : le saut est unique.
306///
307/// `GET /convocations/{id}/recipients` sert la liste NOMINATIVE des
308/// copropriétaires convoqués, avec leur adresse de courriel et le mode d'envoi
309/// retenu. C'est un fichier de personnes, et il était servi à qui connaissait
310/// un identifiant de convocation.
311///
312/// `tracking-summary` en dit davantage encore : qui a ouvert le courriel et
313/// quand. Une donnée de comportement, pas seulement d'identité.
314pub async fn verify_convocation_org_access(
315    user: &AuthenticatedUser,
316    convocation_id: Uuid,
317    convocation_use_cases: &ConvocationUseCases,
318    building_use_cases: &BuildingUseCases,
319    acp_use_cases: &AcpUseCases,
320) -> Result<(), AppError> {
321    if user.is_superadmin() {
322        return Ok(());
323    }
324
325    let convocation = convocation_use_cases
326        .get_convocation(convocation_id)
327        .await
328        .map_err(AppError::from)?;
329
330    verify_building_org_access(
331        user,
332        convocation.building_id,
333        building_use_cases,
334        acp_use_cases,
335    )
336    .await
337}
338
339/// Vérifie le mandat de l'appelant sur l'ACP dont relève un **document**.
340///
341/// Remonte document → immeuble → ACP → organisation.
342///
343/// Un document de copropriété n'est pas un fichier anodin : l'acte de base,
344/// les procès-verbaux d'assemblée et les factures nominatives passent par là.
345/// `GET /documents/{id}/download` servait le contenu même du fichier à qui
346/// connaissait son identifiant.
347///
348/// Les deux routes de rattachement — vers une assemblée, vers une dépense —
349/// sont des **écritures** : elles permettaient de raccrocher le document d'un
350/// cabinet au dossier d'un autre.
351pub async fn verify_document_org_access(
352    user: &AuthenticatedUser,
353    document_id: Uuid,
354    document_use_cases: &DocumentUseCases,
355    building_use_cases: &BuildingUseCases,
356    acp_use_cases: &AcpUseCases,
357) -> Result<(), AppError> {
358    if user.is_superadmin() {
359        return Ok(());
360    }
361
362    let document = document_use_cases
363        .get_document(document_id)
364        .await
365        .map_err(AppError::from)?;
366
367    verify_building_org_access(
368        user,
369        document.building_id,
370        building_use_cases,
371        acp_use_cases,
372    )
373    .await
374}
375
376/// Vérifie le mandat de l'appelant sur l'ACP dont relève un **lot**.
377///
378/// Remonte lot → immeuble → ACP → organisation.
379///
380/// `GET /units/{id}/etats-dates` sert les états datés d'un lot : le document
381/// remis au notaire lors d'une vente, qui porte les arriérés du vendeur et
382/// l'état du fonds de réserve. C'est une pièce financière nominative.
383/// Vérifie le mandat de l'appelant sur l'organisation d'une **réservation**.
384///
385/// ── Ce que cette garde protège ─────────────────────────────────────────────
386///
387/// Une réservation de ressource commune dit qui a réservé la salle, la buanderie
388/// ou le parking visiteur, et **quand**. Ce n'est pas une donnée neutre : elle
389/// dit aussi qui n'était pas chez lui à ce moment-là.
390///
391/// Trois routes agissent sur une réservation par son seul identifiant —
392/// `complete`, `no-show`, `confirm` — et deux la lisent. Aucune ne reçoit
393/// d'immeuble : la chaîne réservation → immeuble → ACP → organisation doit
394/// donc être remontée ici.
395///
396/// Elles prenaient `_auth: AuthenticatedUser`, l'identité soulignée d'un
397/// underscore pour dire qu'on ne s'en sert pas (#772).
398///
399/// ── Pourquoi elle délègue plutôt que de comparer ──────────────────────────
400///
401/// Comme les huit autres, elle finit par appeler `verify_building_org_access`,
402/// qui remonte à l'ACP. Refaire la comparaison ici dupliquerait la règle de
403/// cloisonnement en un endroit de plus — et c'est cette duplication, recopiée
404/// à la main dans chaque gestionnaire, que l'issue #772 désigne comme la cause
405/// première de la fuite.
406/// Vérifie le mandat de l'appelant sur l'organisation d'un **devis**.
407///
408/// ── Ce que cette garde protège ─────────────────────────────────────────────
409///
410/// Un devis dit qui a soumis quel prix pour quels travaux. Le lire hors de son
411/// ACP, c'est lire la concurrence — un entrepreneur ayant un compte sur la
412/// plateforme y verrait les offres de ses concurrents, montants compris.
413///
414/// Neuf routes agissent sur un devis par son seul identifiant : le lire, le
415/// soumettre, l'examiner, le retirer, le noter, le supprimer. Aucune ne reçoit
416/// d'immeuble, d'où la remontée devis → immeuble → ACP → organisation.
417///
418/// ── Ce qu'elle ne fait PAS ────────────────────────────────────────────────
419///
420/// Elle vérifie le **périmètre**, pas le droit d'agir. Examiner un devis
421/// (`review`) ou noter un prestataire n'appartient pas à tout membre de l'ACP —
422/// ce sont des actes de gestion. Ce garde ne dit donc pas « cet utilisateur
423/// peut le faire », il dit « ce devis n'est pas celui d'une autre
424/// copropriété ».
425///
426/// Confondre les deux serait le défaut que l'issue #772 décrit : un garde qui
427/// vérifie la mauvaise chose est pire qu'un garde absent, puisqu'il fait
428/// croire la route protégée.
429pub async fn verify_quote_org_access(
430    user: &AuthenticatedUser,
431    quote_id: Uuid,
432    quote_use_cases: &QuoteUseCases,
433    building_use_cases: &BuildingUseCases,
434    acp_use_cases: &AcpUseCases,
435) -> Result<(), AppError> {
436    if user.is_superadmin() {
437        return Ok(());
438    }
439
440    let quote = quote_use_cases
441        .get_quote(quote_id)
442        .await
443        .map_err(AppError::from)?
444        .ok_or(AppError::NotFound(format!("Quote not found: {quote_id}")))?;
445
446    let building_id = Uuid::parse_str(&quote.building_id)
447        .map_err(|_| AppError::Internal("Invalid quote.building_id format".to_string()))?;
448
449    verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
450}
451
452/// Vérifie le mandat de l'appelant sur l'organisation d'un **échange local**.
453///
454/// ── Ce que cette garde protège ─────────────────────────────────────────────
455///
456/// Le système d'échange local — le SEL — enregistre qui rend quel service à
457/// qui, et pour combien de crédits. Six routes agissent sur un échange par son
458/// seul identifiant : le demander, le démarrer, le clore, l'annuler, noter le
459/// prestataire, noter le demandeur.
460///
461/// Les deux dernières comptent particulièrement : une note engage la
462/// réputation d'un voisin dans sa propre copropriété. La poser depuis une
463/// autre ACP n'a aucun sens légitime.
464///
465/// ── Pourquoi elle délègue ─────────────────────────────────────────────────
466///
467/// Comme les dix autres gardes du module, elle remonte jusqu'à l'immeuble puis
468/// confie la comparaison à `verify_building_org_access`. C'est la duplication
469/// de cette comparaison, recopiée à la main dans chaque gestionnaire, que
470/// l'issue #772 désigne comme la cause première de la fuite.
471pub async fn verify_exchange_org_access(
472    user: &AuthenticatedUser,
473    exchange_id: Uuid,
474    exchange_use_cases: &LocalExchangeUseCases,
475    building_use_cases: &BuildingUseCases,
476    acp_use_cases: &AcpUseCases,
477) -> Result<(), AppError> {
478    if user.is_superadmin() {
479        return Ok(());
480    }
481
482    let exchange = exchange_use_cases
483        .get_exchange(exchange_id)
484        .await
485        .map_err(AppError::from)?;
486
487    verify_building_org_access(
488        user,
489        exchange.building_id,
490        building_use_cases,
491        acp_use_cases,
492    )
493    .await
494}
495
496/// Vérifie le mandat de l'appelant sur l'organisation d'un **ticket**.
497///
498/// ── Ce que cette garde ajoute à ce qui existait ───────────────────────────
499///
500/// `syndic_response_handlers` appelait déjà `require_syndic_or_superadmin`, et
501/// ce contrôle est juste : répondre à un ticket est un acte de gestion, pas de
502/// copropriétaire.
503///
504/// Mais il vérifie le RÔLE, et rien d'autre. Un syndic de l'organisation A y
505/// passait pour répondre au ticket d'un copropriétaire de l'organisation B —
506/// et sa réponse s'y inscrivait, signée de son nom.
507///
508/// Les deux contrôles sont donc nécessaires et ne se remplacent pas : l'un dit
509/// « vous avez qualité pour cela », l'autre « ce ticket est bien le vôtre ».
510/// C'est la distinction que l'issue #772 demande de tenir, et que le tableau
511/// de bord du conseil (#816) illustrait déjà.
512pub async fn verify_ticket_org_access(
513    user: &AuthenticatedUser,
514    ticket_id: Uuid,
515    ticket_use_cases: &TicketUseCases,
516    building_use_cases: &BuildingUseCases,
517    acp_use_cases: &AcpUseCases,
518) -> Result<(), AppError> {
519    if user.is_superadmin() {
520        return Ok(());
521    }
522
523    let ticket = ticket_use_cases
524        .get_ticket(ticket_id)
525        .await
526        .map_err(AppError::from)?
527        .ok_or(AppError::NotFound(format!("Ticket not found: {ticket_id}")))?;
528
529    verify_building_org_access(user, ticket.building_id, building_use_cases, acp_use_cases).await
530}
531
532/// Vérifie le mandat de l'appelant sur l'ACP d'une **fiche technique**.
533///
534/// ── Pourquoi l'ACP, et non l'immeuble ─────────────────────────────────────
535///
536/// `TechnicalSpec` porte `acp_id: Uuid` **obligatoire** et
537/// `building_id: Option<Uuid>`. Une fiche peut donc concerner l'ACP entière
538/// sans viser un immeuble précis — un marché d'entretien couvrant tout le
539/// patrimoine, par exemple.
540///
541/// Remonter par l'immeuble aurait laissé sans contrôle toutes les fiches dont
542/// il est absent. Le périmètre juste est celui que l'entité rend obligatoire,
543/// et c'est l'ACP.
544///
545/// C'est le genre de choix qu'on ne peut pas recopier d'un garde voisin : les
546/// onze autres remontent à l'immeuble parce que leurs entités le portent
547/// toujours. Ici, suivre le modèle aurait produit un trou pour les fiches sans
548/// immeuble — soit exactement les plus larges (#772).
549pub async fn verify_technical_spec_org_access(
550    user: &AuthenticatedUser,
551    spec_id: Uuid,
552    spec_use_cases: &TechnicalSpecUseCases,
553    acp_use_cases: &AcpUseCases,
554) -> Result<(), AppError> {
555    if user.is_superadmin() {
556        return Ok(());
557    }
558
559    let spec = spec_use_cases.get(spec_id).await?;
560    verify_acp_org_access(user, spec.acp_id, acp_use_cases).await
561}
562
563/// Vérifie le mandat de l'appelant sur l'ACP d'un **appel de fonds**.
564///
565/// Un appel de fonds engage l'argent des copropriétaires : il dit combien
566/// chacun doit, et pour quoi. `POST /call-for-funds/{id}/send` le leur envoie
567/// — un acte qui, hors de son ACP, écrirait à des personnes qu'on n'a pas à
568/// contacter, au nom d'une copropriété qui n'est pas la sienne.
569///
570/// Le périmètre est l'ACP : `CallForFunds` porte `acp_id` obligatoire et ne
571/// vise pas d'immeuble. C'est cohérent avec l'Art. 3.86, où le fonds de
572/// roulement et le fonds de réserve appartiennent à l'association, non aux
573/// immeubles qu'elle regroupe.
574/// Vérifie le mandat de l'appelant sur l'ACP d'une **quote-part**.
575///
576/// `PUT /owner-contributions/{id}/mark-paid` déclare qu'un copropriétaire
577/// nommé a payé. C'est l'écriture la plus lourde de conséquence du produit
578/// après la clôture d'un vote : elle éteint une dette, et son absence de
579/// contrôle permettait de le faire dans la comptabilité d'une autre
580/// copropriété.
581///
582/// Le périmètre est l'ACP : `OwnerContribution` porte `acp_id` obligatoire et
583/// `unit_id: Option<Uuid>` — une quote-part peut n'être rattachée à aucun lot
584/// précis, notamment lors d'une régularisation.
585pub async fn verify_contribution_org_access(
586    user: &AuthenticatedUser,
587    contribution_id: Uuid,
588    contribution_use_cases: &OwnerContributionUseCases,
589    acp_use_cases: &AcpUseCases,
590) -> Result<(), AppError> {
591    if user.is_superadmin() {
592        return Ok(());
593    }
594
595    let contribution = contribution_use_cases
596        .get_contribution(contribution_id)
597        .await
598        .map_err(AppError::from)?
599        .ok_or(AppError::NotFound(format!(
600            "Contribution not found: {contribution_id}"
601        )))?;
602
603    verify_acp_org_access(user, contribution.acp_id, acp_use_cases).await
604}
605
606pub async fn verify_call_for_funds_org_access(
607    user: &AuthenticatedUser,
608    cff_id: Uuid,
609    cff_use_cases: &CallForFundsUseCases,
610    acp_use_cases: &AcpUseCases,
611) -> Result<(), AppError> {
612    if user.is_superadmin() {
613        return Ok(());
614    }
615
616    let cff = cff_use_cases
617        .get_call_for_funds(cff_id)
618        .await
619        .map_err(AppError::from)?
620        .ok_or(AppError::NotFound(format!(
621            "Call for funds not found: {cff_id}"
622        )))?;
623
624    verify_acp_org_access(user, cff.acp_id, acp_use_cases).await
625}
626
627/// Vérifie le mandat de l'appelant sur l'organisation d'un **sondage**.
628///
629/// Un sondage recueille l'avis des copropriétaires sur une question qui les
630/// concerne. Quatre routes agissent sur lui par son seul identifiant : lire
631/// ses résultats, le publier, le clore, l'annuler.
632///
633/// Lire les résultats d'un sondage d'une autre ACP, c'est apprendre ce que des
634/// voisins qui ne sont pas les vôtres pensent d'un sujet qui ne vous regarde
635/// pas. Le publier ou le clore depuis l'extérieur serait pire : cela
636/// interromprait une consultation en cours.
637///
638/// Le périmètre est l'immeuble — `Poll.building_id` est obligatoire — et non
639/// l'ACP, contrairement aux appels de fonds et aux fiches techniques. Une
640/// consultation porte sur la vie d'un bâtiment, pas sur le patrimoine d'une
641/// association.
642pub async fn verify_poll_org_access(
643    user: &AuthenticatedUser,
644    poll_id: Uuid,
645    poll_use_cases: &PollUseCases,
646    building_use_cases: &BuildingUseCases,
647    acp_use_cases: &AcpUseCases,
648) -> Result<(), AppError> {
649    if user.is_superadmin() {
650        return Ok(());
651    }
652
653    let poll = poll_use_cases
654        .get_poll(poll_id)
655        .await
656        .map_err(AppError::from)?;
657
658    let building_id = Uuid::parse_str(&poll.building_id)
659        .map_err(|_| AppError::Internal("Invalid poll.building_id format".to_string()))?;
660
661    verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
662}
663
664/// Vérifie le mandat de l'appelant sur l'immeuble d'une **annonce**.
665///
666/// Épingler ou désépingler une annonce la met en tête du tableau d'affichage
667/// de la copropriété. Le faire depuis une autre ACP, c'est décider de ce que
668/// des voisins qui ne sont pas les vôtres verront en premier.
669pub async fn verify_notice_org_access(
670    user: &AuthenticatedUser,
671    notice_id: Uuid,
672    notice_use_cases: &NoticeUseCases,
673    building_use_cases: &BuildingUseCases,
674    acp_use_cases: &AcpUseCases,
675) -> Result<(), AppError> {
676    if user.is_superadmin() {
677        return Ok(());
678    }
679
680    let notice = notice_use_cases
681        .get_notice(notice_id)
682        .await
683        .map_err(AppError::from)?;
684
685    verify_building_org_access(user, notice.building_id, building_use_cases, acp_use_cases).await
686}
687
688/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **compétence**.
689///
690/// Une offre de compétence nomme une personne et décrit ce qu'elle sait faire.
691/// C'est une donnée personnelle au sens du RGPD, et elle appartient à la
692/// communauté d'un immeuble, pas au premier venu qui en connaît l'identifiant.
693///
694/// `GET /skills/{id}` ne prenait AUCUNE identité (#845).
695pub async fn verify_skill_org_access(
696    user: &AuthenticatedUser,
697    skill_id: Uuid,
698    skill_use_cases: &SkillUseCases,
699    building_use_cases: &BuildingUseCases,
700    acp_use_cases: &AcpUseCases,
701) -> Result<(), AppError> {
702    if user.is_superadmin() {
703        return Ok(());
704    }
705
706    let skill = skill_use_cases
707        .get_skill(skill_id)
708        .await
709        .map_err(AppError::from)?;
710
711    verify_building_org_access(user, skill.building_id, building_use_cases, acp_use_cases).await
712}
713
714/// Vérifie le mandat de l'appelant sur l'ACP dont relève un **objet partagé**.
715///
716/// Prêter une perceuse à ses voisins ne revient pas à l'annoncer à qui connaît
717/// un UUID : l'annonce nomme son propriétaire et, indirectement, son adresse.
718///
719/// `GET /shared-objects/{id}` ne prenait AUCUNE identité (#845).
720pub async fn verify_shared_object_org_access(
721    user: &AuthenticatedUser,
722    object_id: Uuid,
723    shared_object_use_cases: &SharedObjectUseCases,
724    building_use_cases: &BuildingUseCases,
725    acp_use_cases: &AcpUseCases,
726) -> Result<(), AppError> {
727    if user.is_superadmin() {
728        return Ok(());
729    }
730
731    let objet = shared_object_use_cases
732        .get_shared_object(object_id)
733        .await
734        .map_err(AppError::from)?;
735
736    verify_building_org_access(user, objet.building_id, building_use_cases, acp_use_cases).await
737}
738
739/// Vérifie le mandat de l'appelant sur l'organisation d'un **défi**.
740///
741/// ── Pourquoi l'organisation, et non l'immeuble ────────────────────────────
742///
743/// `Challenge` porte `organization_id` obligatoire et `building_id: Option`,
744/// dont le commentaire d'origine dit tout : « None = organization-wide ». Un
745/// défi énergétique peut concerner tout un cabinet de syndic, plusieurs
746/// copropriétés à la fois.
747///
748/// Remonter par l'immeuble aurait laissé sans contrôle exactement les défis
749/// les plus larges — le même piège que pour les fiches techniques, dont
750/// `building_id` est optionnel aussi.
751pub async fn verify_challenge_org_access(
752    user: &AuthenticatedUser,
753    challenge_id: Uuid,
754    challenge_use_cases: &ChallengeUseCases,
755) -> Result<(), AppError> {
756    if user.is_superadmin() {
757        return Ok(());
758    }
759
760    let challenge = challenge_use_cases
761        .get_challenge(challenge_id)
762        .await
763        .map_err(AppError::from)?;
764
765    user.verify_org_access(challenge.organization_id)
766        .map_err(AppError::Forbidden)
767}
768
769pub async fn verify_booking_org_access(
770    user: &AuthenticatedUser,
771    booking_id: Uuid,
772    booking_use_cases: &ResourceBookingUseCases,
773    building_use_cases: &BuildingUseCases,
774    acp_use_cases: &AcpUseCases,
775) -> Result<(), AppError> {
776    if user.is_superadmin() {
777        return Ok(());
778    }
779
780    let booking = booking_use_cases
781        .get_booking(booking_id)
782        .await
783        .map_err(AppError::from)?;
784
785    verify_building_org_access(user, booking.building_id, building_use_cases, acp_use_cases).await
786}
787
788pub async fn verify_unit_org_access(
789    user: &AuthenticatedUser,
790    unit_id: Uuid,
791    unit_use_cases: &UnitUseCases,
792    building_use_cases: &BuildingUseCases,
793    acp_use_cases: &AcpUseCases,
794) -> Result<(), AppError> {
795    if user.is_superadmin() {
796        return Ok(());
797    }
798
799    let unit = unit_use_cases
800        .get_unit(unit_id)
801        .await
802        .map_err(AppError::from)?
803        .ok_or(AppError::NotFound(format!("Unit not found: {unit_id}")))?;
804
805    let building_id = Uuid::parse_str(&unit.building_id)
806        .map_err(|_| AppError::Internal("Invalid unit.building_id format".to_string()))?;
807
808    verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
809}
810
811/// Vérifie le mandat de l'appelant sur l'organisation d'un **copropriétaire**.
812///
813/// ── Pourquoi cette garde-ci compte plus que les autres ─────────────────────
814///
815/// Les routes portées par un copropriétaire servent, nominativement, ce qu'une
816/// personne doit et ce qu'elle a payé :
817///
818/// ```text
819/// GET /owners/{id}/payments            montants et dates
820/// GET /owners/{id}/payments/total      ce qu'elle a versé
821/// GET /owners/{id}/payment-methods     ses instruments enregistrés
822/// GET /owners/{id}/payment-reminders   ses rappels, donc ses retards
823/// GET /owners/{id}/distributions       sa quote-part de chaque charge
824/// GET /owners/{id}/total-due           ce qu'elle doit
825/// ```
826///
827/// Un identifiant de copropriétaire suffisait à les obtenir. Ce n'est pas un
828/// écart de périmètre, c'est la situation financière d'une personne nommée
829/// servie à qui la demande — au sens du RGPD, une violation de données.
830///
831/// ── Le chemin est court, et c'est ce qui le rend sûr ───────────────────────
832///
833/// `Owner` porte directement son `organization_id` (`domain/copropriete/owner.rs:9`).
834/// Pas de chaîne à remonter, donc pas de chaîne à recopier de travers — c'est
835/// la recopie manuelle d'une chaîne de quatre sauts qui avait laissé fuir les
836/// bulletins de vote nominatifs (RN-2, issue #772).
837///
838/// Sémantique : superadmin passe ; sinon le copropriétaire doit exister et
839/// relever de l'organisation de l'appelant. Un copropriétaire introuvable est
840/// refusé plutôt qu'ignoré.
841pub async fn verify_owner_org_access(
842    user: &AuthenticatedUser,
843    owner_id: Uuid,
844    owner_use_cases: &OwnerUseCases,
845) -> Result<(), AppError> {
846    if user.is_superadmin() {
847        return Ok(());
848    }
849
850    let owner = owner_use_cases
851        .get_owner(owner_id)
852        .await
853        .map_err(AppError::from)?
854        .ok_or(AppError::NotFound(format!("Owner not found: {owner_id}")))?;
855
856    let org_id = Uuid::parse_str(&owner.organization_id)
857        .map_err(|_| AppError::Internal("Invalid owner.organization_id format".to_string()))?;
858
859    user.verify_org_access(org_id)
860        .map_err(|_| AppError::Forbidden("Owner outside your organization".to_string()))
861}
862
863/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **assemblée**.
864///
865/// Remonte AG → immeuble → ACP → organisation. Cette chaîne était recopiée à
866/// la main dans chaque gestionnaire qui en avait besoin, et c'est ainsi
867/// qu'elle a fini par manquer : `GET /meetings/{id}/resolutions` et
868/// `GET /resolutions/{id}/votes` rendaient `200` sur les données d'une autre
869/// copropriété, bulletins nominatifs compris (RN-2, issue #772).
870///
871/// Une AG introuvable est refusée, jamais ignorée : c'est précisément le cas
872/// où l'on ne sait pas à qui elle appartient.
873pub async fn verify_meeting_org_access(
874    user: &AuthenticatedUser,
875    meeting_id: Uuid,
876    meeting_use_cases: &crate::application::use_cases::MeetingUseCases,
877    building_use_cases: &BuildingUseCases,
878    acp_use_cases: &AcpUseCases,
879) -> Result<(), AppError> {
880    if user.is_superadmin() {
881        return Ok(());
882    }
883
884    let meeting = meeting_use_cases
885        .get_meeting(meeting_id)
886        .await
887        .map_err(AppError::from)?
888        .ok_or(AppError::NotFound(format!(
889            "Meeting not found: {meeting_id}"
890        )))?;
891
892    verify_building_org_access(user, meeting.building_id, building_use_cases, acp_use_cases).await
893}
894
895/// Vérifie le mandat de l'appelant sur l'ACP dont relève une **dépense**.
896///
897/// Remonte dépense → immeuble → ACP → organisation. Même motif que
898/// `verify_meeting_org_access` : une dépense porte des montants et souvent des
899/// pièces jointes nominatives — factures, devis — qui n'ont pas à circuler
900/// entre cabinets.
901pub async fn verify_expense_org_access(
902    user: &AuthenticatedUser,
903    expense_id: Uuid,
904    expense_use_cases: &crate::application::use_cases::ExpenseUseCases,
905    building_use_cases: &BuildingUseCases,
906    acp_use_cases: &AcpUseCases,
907) -> Result<(), AppError> {
908    if user.is_superadmin() {
909        return Ok(());
910    }
911
912    let depense = expense_use_cases
913        .get_expense(expense_id)
914        .await
915        .map_err(AppError::from)?
916        .ok_or(AppError::NotFound(format!(
917            "Expense not found: {expense_id}"
918        )))?;
919
920    let building_id = Uuid::parse_str(&depense.building_id)
921        .map_err(|_| AppError::Internal("Invalid expense.building_id format".to_string()))?;
922
923    verify_building_org_access(user, building_id, building_use_cases, acp_use_cases).await
924}
925
926// ============================================================================
927// Actix middleware
928// ============================================================================
929
930/// `ScopeGuard` Actix middleware factory.
931///
932/// Wrap your routes with:
933/// ```rust,ignore
934/// use actix_web::web;
935/// use koprogo_api::infrastructure::web::middleware::ScopeGuard;
936///
937/// cfg.service(
938///     web::scope("/buildings")
939///         .wrap(ScopeGuard::new())
940///         // ... .service(...)
941/// );
942/// ```
943#[derive(Clone, Default)]
944pub struct ScopeGuard;
945
946impl ScopeGuard {
947    pub fn new() -> Self {
948        Self
949    }
950}
951
952impl<S, B> Transform<S, ServiceRequest> for ScopeGuard
953where
954    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
955    S::Future: 'static,
956    B: MessageBody + 'static,
957{
958    type Response = ServiceResponse<EitherBody<B>>;
959    type Error = Error;
960    type InitError = ();
961    type Transform = ScopeGuardMiddleware<S>;
962    type Future = Ready<Result<Self::Transform, Self::InitError>>;
963
964    fn new_transform(&self, service: S) -> Self::Future {
965        ready(Ok(ScopeGuardMiddleware {
966            service: Arc::new(service),
967        }))
968    }
969}
970
971pub struct ScopeGuardMiddleware<S> {
972    service: Arc<S>,
973}
974
975impl<S, B> Service<ServiceRequest> for ScopeGuardMiddleware<S>
976where
977    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
978    S::Future: 'static,
979    B: MessageBody + 'static,
980{
981    type Response = ServiceResponse<EitherBody<B>>;
982    type Error = Error;
983    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
984
985    forward_ready!(service);
986
987    fn call(&self, req: ServiceRequest) -> Self::Future {
988        let service = self.service.clone();
989
990        // 1. Extract JWT claims.
991        let app_state = match req.app_data::<web::Data<AppState>>() {
992            Some(s) => s.clone(),
993            None => {
994                let err = ScopeGuardError::Internal("AppState missing".into());
995                let resp = req.into_response(err.error_response().map_into_right_body());
996                return Box::pin(async move { Ok(resp) });
997            }
998        };
999
1000        let auth_header = req
1001            .headers()
1002            .get("Authorization")
1003            .and_then(|h| h.to_str().ok())
1004            .map(|s| s.to_string());
1005
1006        let token = match auth_header.as_deref() {
1007            Some(h) if h.starts_with("Bearer ") => {
1008                h.trim_start_matches("Bearer ").trim().to_string()
1009            }
1010            _ => {
1011                let err = ScopeGuardError::Unauthorized;
1012                let resp = req.into_response(err.error_response().map_into_right_body());
1013                return Box::pin(async move { Ok(resp) });
1014            }
1015        };
1016
1017        let claims = match app_state.auth_use_cases.verify_token(&token) {
1018            Ok(c) => c,
1019            Err(_) => {
1020                let err = ScopeGuardError::Unauthorized;
1021                let resp = req.into_response(err.error_response().map_into_right_body());
1022                return Box::pin(async move { Ok(resp) });
1023            }
1024        };
1025
1026        let user_id = match Uuid::parse_str(&claims.sub) {
1027            Ok(u) => u,
1028            Err(_) => {
1029                let err = ScopeGuardError::Unauthorized;
1030                let resp = req.into_response(err.error_response().map_into_right_body());
1031                return Box::pin(async move { Ok(resp) });
1032            }
1033        };
1034
1035        let caller = caller_from_role(&claims.role, claims.organization_id, user_id);
1036
1037        // 2. Extract requested acp_id from header OR query string.
1038        let header_val = req
1039            .headers()
1040            .get(SCOPE_ACP_HEADER)
1041            .and_then(|v| v.to_str().ok())
1042            .map(|s| s.to_string());
1043        // Parse `?acp_id=` ourselves to avoid double-Deserialize collisions
1044        // with arbitrary handlers' Query<T> extractors.
1045        let query_val = req.query_string().split('&').find_map(|kv| {
1046            let mut it = kv.splitn(2, '=');
1047            match (it.next(), it.next()) {
1048                (Some("acp_id"), Some(v)) => Some(v.to_string()),
1049                _ => None,
1050            }
1051        });
1052
1053        let requested_acp_id =
1054            match extract_requested_acp_id(header_val.as_deref(), query_val.as_deref()) {
1055                Ok(v) => v,
1056                Err(err) => {
1057                    let resp = req.into_response(err.error_response().map_into_right_body());
1058                    return Box::pin(async move { Ok(resp) });
1059                }
1060            };
1061
1062        let check = match requires_repository_check(&caller, requested_acp_id) {
1063            Ok(v) => v,
1064            Err(err) => {
1065                let resp = req.into_response(err.error_response().map_into_right_body());
1066                return Box::pin(async move { Ok(resp) });
1067            }
1068        };
1069
1070        // 3. Optional repository check.
1071        Box::pin(async move {
1072            if let Some(acp_id) = check {
1073                if let Err(app_err) = app_state
1074                    .acp_use_cases
1075                    .assert_can_see_acp(&caller, acp_id)
1076                    .await
1077                {
1078                    let guard_err = ScopeGuardError::from(app_err);
1079                    let resp = req.into_response(guard_err.error_response().map_into_right_body());
1080                    return Ok(resp);
1081                }
1082            }
1083
1084            // 4. Inject AcpScope into request extensions for handlers.
1085            req.extensions_mut().insert(AcpScope {
1086                caller: caller.clone(),
1087                requested_acp_id,
1088                allowed: true,
1089            });
1090
1091            let res = service.call(req).await?;
1092            Ok(res.map_into_left_body())
1093        })
1094    }
1095}
1096
1097// ============================================================================
1098// Tests — taxonomie 4-cat (CRITICAL.md §3). Pure helpers only ; the
1099// Transform integration is exercised end-to-end via the BDD harness
1100// `tests/features/list_buildings_role_based.feature`.
1101// ============================================================================
1102
1103#[cfg(test)]
1104mod tests {
1105    use super::*;
1106
1107    // ----- @happy --------------------------------------------------------------
1108
1109    #[test]
1110    fn happy_caller_from_role_admin() {
1111        let org = Uuid::new_v4();
1112        let c = caller_from_role("admin", Some(org), Uuid::new_v4());
1113        assert!(matches!(c, AcpCaller::Admin { organization_id } if organization_id == org));
1114    }
1115
1116    #[test]
1117    fn happy_extract_acp_id_from_header() {
1118        let id = Uuid::new_v4();
1119        let s = id.to_string();
1120        let got = extract_requested_acp_id(Some(&s), None).unwrap();
1121        assert_eq!(got, Some(id));
1122    }
1123
1124    #[test]
1125    fn happy_extract_acp_id_from_query_when_no_header() {
1126        let id = Uuid::new_v4();
1127        let s = id.to_string();
1128        let got = extract_requested_acp_id(None, Some(&s)).unwrap();
1129        assert_eq!(got, Some(id));
1130    }
1131
1132    // ----- @edge ---------------------------------------------------------------
1133
1134    #[test]
1135    fn edge_header_takes_precedence_over_query() {
1136        let h = Uuid::new_v4();
1137        let q = Uuid::new_v4();
1138        let got = extract_requested_acp_id(Some(&h.to_string()), Some(&q.to_string())).unwrap();
1139        assert_eq!(got, Some(h));
1140    }
1141
1142    #[test]
1143    fn edge_empty_header_treated_as_none() {
1144        let got = extract_requested_acp_id(Some(""), None).unwrap();
1145        assert!(got.is_none());
1146    }
1147
1148    #[test]
1149    fn edge_super_admin_with_no_scope_is_unrestricted() {
1150        let r = requires_repository_check(&AcpCaller::SuperAdmin, None).unwrap();
1151        assert!(r.is_none());
1152    }
1153
1154    // ----- @security -----------------------------------------------------------
1155
1156    #[test]
1157    fn security_owner_requested_acp_must_be_checked_via_repo() {
1158        let user = Uuid::new_v4();
1159        let target = Uuid::new_v4();
1160        let r =
1161            requires_repository_check(&AcpCaller::Owner { user_id: user }, Some(target)).unwrap();
1162        assert_eq!(r, Some(target));
1163    }
1164
1165    #[test]
1166    fn security_caller_from_role_unknown_falls_back_to_owner() {
1167        let uid = Uuid::new_v4();
1168        let c = caller_from_role("contractor", Some(Uuid::new_v4()), uid);
1169        assert!(matches!(c, AcpCaller::Owner { user_id } if user_id == uid));
1170    }
1171
1172    // ----- @negative -----------------------------------------------------------
1173
1174    #[test]
1175    fn negative_malformed_header_returns_validation_error() {
1176        let err = extract_requested_acp_id(Some("not-a-uuid"), None).unwrap_err();
1177        assert!(matches!(err, ScopeGuardError::Validation(_)));
1178    }
1179
1180    #[test]
1181    fn negative_scope_guard_error_kind_strings_are_stable() {
1182        assert_eq!(ScopeGuardError::Unauthorized.kind(), "unauthorized");
1183        assert_eq!(
1184            ScopeGuardError::AcpNotInScope {
1185                acp_id: Uuid::nil()
1186            }
1187            .kind(),
1188            "acp_not_in_scope"
1189        );
1190        assert_eq!(ScopeGuardError::Validation("x".into()).kind(), "validation");
1191    }
1192
1193    #[test]
1194    fn negative_apperror_acp_not_in_scope_maps_to_scopeguard_acp_not_in_scope() {
1195        let id = Uuid::new_v4();
1196        let g = ScopeGuardError::from(AppError::AcpNotInScope { acp_id: id });
1197        match g {
1198            ScopeGuardError::AcpNotInScope { acp_id } => assert_eq!(acp_id, id),
1199            other => panic!("expected AcpNotInScope, got {:?}", other),
1200        }
1201    }
1202
1203    #[test]
1204    fn negative_apperror_unauthorized_maps_to_scopeguard_unauthorized() {
1205        let g = ScopeGuardError::from(AppError::Unauthorized);
1206        assert!(matches!(g, ScopeGuardError::Unauthorized));
1207    }
1208}