feat(auth): autorizza ROLE_CONFIDI come proprietario pratica (parallelo BENEFICIARY)

Risoluzione 403 segnalato da Rinaldo Bonazzo su upload fattura con utente
ROLE_CONFIDI (confidi4@test.test). Pattern allineato al BE Gepafin che
in DashboardDao, CompanyDocumentDao e FaqDao raggruppa BENEFICIARY+CONFIDI
con stessi diritti operativi sulla pratica.

==RAZIONALE==
Sui bandi con call.confidi=true il confidi sottomette la application
per conto dell'azienda e diventa user_id della application. Lato
microservizio rendicontazione la pratica viene ereditata con stesso
user_id, quindi il confidi e proprietario della pratica e deve poter
fare upload/download/delete come il beneficiario.

==MODIFICHE==

app/auth.py:
- Aggiunto AuthUser.is_confidi() — controlla ROLE_CONFIDI
- Aggiunto AuthUser.is_owner_role() — True per BENEFICIARY o CONFIDI
- Aggiornato docstring header con ROLE_CONFIDI
- Manteno is_beneficiary() per backward compat (non rimosso, non chiamato)

Sostituzione is_beneficiary() -> is_owner_role() in 11 punti dove la
semantica era 'proprietario pratica':
- app/routers/files.py: 3 (_can_upload, _can_download, _can_delete)
- app/routers/instructor.py: 2 (respond-beneficiary, ack-amendment)
- app/routers/practices.py: 3 (visibilita, create, schema gating)
- app/routers/custom_checks.py: 3 (declared, gate)

==COMPORTAMENTO==

Per ROLE_CONFIDI vale ora la stessa regola di BENEFICIARY:
- upload/download/delete: solo se practice.user_id == user.user_id
  AND practice.status IN ('DRAFT','AWAITING_AMENDMENT')
- respond-beneficiary: solo se proprietario pratica
- visualizzazione: solo proprie pratiche
- creazione: solo se schema PUBLISHED

Confidi su pratica di altri o su pratica non editabile -> 403 come prima.

==TEST E2E (4 step verdi)==
/tmp/test_confidi_upload.py:
1. CONFIDI proprietario DRAFT upload Invoice_zapier2024.pdf -> 200 (era 403)
2. CONFIDI NON proprietario -> 403 (scoping)
3. CONFIDI proprietario ma SUBMITTED -> 403 (stato)
4. BENEFICIARY proprietario DRAFT (regressione) -> 200
This commit is contained in:
BFLOWS
2026-04-27 09:06:10 +02:00
parent 1dbf542104
commit 83bb0a29ec
5 changed files with 21 additions and 12 deletions

View File

@@ -3,7 +3,7 @@ JWT validation compatibile con GEPAFIN-BE.
Il BE Spring emette token HS512 con payload: Il BE Spring emette token HS512 con payload:
sub: "email:userId:hubId" sub: "email:userId:hubId"
userId: int userId: int
auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | ... auth: "ROLE_SUPER_ADMIN" | "ROLE_BENEFICIARY" | "ROLE_CONFIDI" | ...
exp: unix timestamp exp: unix timestamp
loginAttemptId: int loginAttemptId: int
""" """
@@ -29,6 +29,15 @@ class AuthUser:
def is_beneficiary(self) -> bool: def is_beneficiary(self) -> bool:
return self.role == "ROLE_BENEFICIARY" return self.role == "ROLE_BENEFICIARY"
def is_confidi(self) -> bool:
return self.role == "ROLE_CONFIDI"
def is_owner_role(self) -> bool:
"""Ruoli che possono essere proprietari di una pratica (user_id match):
BENEFICIARY (azienda diretta) o CONFIDI (delegato per conto azienda).
Pattern allineato al BE Gepafin (DashboardDao, CompanyDocumentDao)."""
return self.role in ("ROLE_BENEFICIARY", "ROLE_CONFIDI")
def get_current_user( def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme), credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),

View File

@@ -36,16 +36,16 @@ def _get_practice(db: Session, practice_id: UUID, user: AuthUser) -> RemissionPr
if not p: if not p:
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata") raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Autorizzazione base: beneficiario owner o istruttore # Autorizzazione base: beneficiario owner o istruttore
if user.is_beneficiary() and p.user_id != user.user_id: if user.is_owner_role() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Accesso negato") raise HTTPException(status_code=403, detail="Accesso negato")
if not user.is_beneficiary() and not _is_instructor(user): if not user.is_owner_role() and not _is_instructor(user):
raise HTTPException(status_code=403, detail="Ruolo non autorizzato") raise HTTPException(status_code=403, detail="Ruolo non autorizzato")
return p return p
def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool: def _can_declare(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT.""" """Solo beneficiario owner e solo su DRAFT | AWAITING_AMENDMENT."""
if not user.is_beneficiary(): if not user.is_owner_role():
return False return False
if practice.user_id != user.user_id: if practice.user_id != user.user_id:
return False return False

View File

@@ -38,7 +38,7 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
"""Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore.""" """Beneficiario proprietario in DRAFT/AWAITING_AMENDMENT oppure istruttore."""
if _is_instructor(user): if _is_instructor(user):
return True return True
if user.is_beneficiary() and practice.user_id == user.user_id: if user.is_owner_role() and practice.user_id == user.user_id:
return practice.status in ("DRAFT", "AWAITING_AMENDMENT") return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
return False return False
@@ -46,14 +46,14 @@ def _can_upload(user: AuthUser, practice: RemissionPractice) -> bool:
def _can_download(user: AuthUser, practice: RemissionPractice) -> bool: def _can_download(user: AuthUser, practice: RemissionPractice) -> bool:
if _is_instructor(user): if _is_instructor(user):
return True return True
if user.is_beneficiary() and practice.user_id == user.user_id: if user.is_owner_role() and practice.user_id == user.user_id:
return True return True
return False return False
def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool: def _can_delete(user: AuthUser, practice: RemissionPractice) -> bool:
"""Solo beneficiario su pratica modificabile. Istruttore non elimina file.""" """Solo beneficiario su pratica modificabile. Istruttore non elimina file."""
if user.is_beneficiary() and practice.user_id == user.user_id: if user.is_owner_role() and practice.user_id == user.user_id:
return practice.status in ("DRAFT", "AWAITING_AMENDMENT") return practice.status in ("DRAFT", "AWAITING_AMENDMENT")
if user.is_superadmin(): if user.is_superadmin():
return True return True

View File

@@ -446,7 +446,7 @@ def respond_amendment_beneficiary(practice_id: UUID, amendment_id: UUID,
user: AuthUser = Depends(get_current_user)): user: AuthUser = Depends(get_current_user)):
"""Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED).""" """Beneficiario risponde al soccorso istruttorio (stato AWAITING -> RESPONSE_RECEIVED)."""
p = _get_practice_or_404(db, practice_id) p = _get_practice_or_404(db, practice_id)
if user.is_beneficiary() and p.user_id != user.user_id: if user.is_owner_role() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica") raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
ar = _amendment_or_404(db, practice_id, amendment_id) ar = _amendment_or_404(db, practice_id, amendment_id)
@@ -620,7 +620,7 @@ async def upload_response_document(practice_id: UUID, amendment_id: UUID,
"""Beneficiario allega un documento come supporto alla sua risposta al soccorso. """Beneficiario allega un documento come supporto alla sua risposta al soccorso.
Consentito su amendment in stato AWAITING, solo dal proprietario pratica.""" Consentito su amendment in stato AWAITING, solo dal proprietario pratica."""
p = _get_practice_or_404(db, practice_id) p = _get_practice_or_404(db, practice_id)
if user.is_beneficiary() and p.user_id != user.user_id: if user.is_owner_role() and p.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica") raise HTTPException(status_code=403, detail="Non sei il proprietario della pratica")
ar = _amendment_or_404(db, practice_id, amendment_id) ar = _amendment_or_404(db, practice_id, amendment_id)

View File

@@ -39,7 +39,7 @@ def _get_practice_or_404(db: Session, practice_id: UUID, user: AuthUser) -> Remi
raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata") raise HTTPException(status_code=404, detail=f"Pratica {practice_id} non trovata")
# Solo il beneficiario owner o un superadmin può accedere # Solo il beneficiario owner o un superadmin può accedere
if user.is_beneficiary() and practice.user_id != user.user_id: if user.is_owner_role() and practice.user_id != user.user_id:
raise HTTPException(status_code=403, detail="Accesso negato a questa pratica") raise HTTPException(status_code=403, detail="Accesso negato a questa pratica")
return practice return practice
@@ -438,7 +438,7 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
if app_row["status"] != "CONTRACT_SIGNED": if app_row["status"] != "CONTRACT_SIGNED":
raise HTTPException(status_code=409, raise HTTPException(status_code=409,
detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED") detail=f"Application in stato {app_row['status']}, richiesto CONTRACT_SIGNED")
if user.is_beneficiary() and app_row["user_id"] != user.user_id: if user.is_owner_role() and app_row["user_id"] != user.user_id:
raise HTTPException(status_code=403, detail="Application non di tua proprieta") raise HTTPException(status_code=403, detail="Application non di tua proprieta")
# Schema del bando # Schema del bando
@@ -446,7 +446,7 @@ def start_practice(body: PracticeStartRequest, db: Session = Depends(get_db),
if not schema: if not schema:
raise HTTPException(status_code=409, raise HTTPException(status_code=409,
detail="Nessuno schema di rendicontazione configurato per questo bando.") detail="Nessuno schema di rendicontazione configurato per questo bando.")
if schema.status != "PUBLISHED" and user.is_beneficiary(): if schema.status != "PUBLISHED" and user.is_owner_role():
raise HTTPException(status_code=409, raise HTTPException(status_code=409,
detail="Lo schema di rendicontazione non e ancora stato pubblicato.") detail="Lo schema di rendicontazione non e ancora stato pubblicato.")