- verify kid from the JWT header (pyjwt decode() returns claims, not the header — the old lookup was always None and rejected every token) - convert JWK entries to crypto keys explicitly (pyjwt 2.13 dropped dict keys) and fetch the JWKS from /.well-known/jwks - 401 for missing/invalid/expired/wrong-issuer/wrong-audience tokens (was 403/400); 403 stays for valid-but-roleless users; 503 when Casdoor is unreachable; new CASDOOR_ISSUER setting separates the in-network endpoint from the browser-facing issuer - dev-header fallback in inventory/pm scopes now requires an explicit X-User-Role header (no auth info at all -> 401) - new POST /api/v1/auth/login: password grant against Casdoor, local JWT verification, auto-provisioning, returns token + profile with the role resolved from the app users table
66 lines
2.1 KiB
Python
66 lines
2.1 KiB
Python
"""Application error type + standardized error envelope.
|
|
|
|
All errors are returned as: {"error": {"code", "message", "field"}}
|
|
(contracts: expert-opinion-p0.md «General Shared Contracts»).
|
|
"""
|
|
|
|
|
|
class AppError(Exception):
|
|
"""Base error carrying an HTTP status and the contract error envelope."""
|
|
|
|
def __init__(self, status_code: int, code: str, message: str, field: str | None = None):
|
|
super().__init__(message)
|
|
self.status_code = status_code
|
|
self.code = code
|
|
self.message = message
|
|
self.field = field
|
|
|
|
@property
|
|
def body(self) -> dict:
|
|
error = {"code": self.code, "message": self.message}
|
|
if self.field is not None:
|
|
error["field"] = self.field
|
|
return {"error": error}
|
|
|
|
|
|
class ValidationError(AppError):
|
|
"""400 VALIDATION_ERROR — bad input (types, business validation)."""
|
|
|
|
def __init__(self, message: str, field: str | None = None):
|
|
super().__init__(400, "VALIDATION_ERROR", message, field)
|
|
|
|
|
|
class NotFoundError(AppError):
|
|
"""404 NOT_FOUND."""
|
|
|
|
def __init__(self, message: str = "رکورد یافت نشد", field: str | None = None):
|
|
super().__init__(404, "NOT_FOUND", message, field)
|
|
|
|
|
|
class ConflictError(AppError):
|
|
"""409 CONFLICT — duplicates, invalid state transitions, protected deletes."""
|
|
|
|
def __init__(self, message: str, field: str | None = None):
|
|
super().__init__(409, "CONFLICT", message, field)
|
|
|
|
|
|
class ForbiddenError(AppError):
|
|
"""403 FORBIDDEN — role / ownership checks."""
|
|
|
|
def __init__(self, message: str, field: str | None = None):
|
|
super().__init__(403, "FORBIDDEN", message, field)
|
|
|
|
|
|
class UnauthorizedError(AppError):
|
|
"""401 UNAUTHORIZED — missing, invalid, expired or untrusted credentials."""
|
|
|
|
def __init__(self, message: str, field: str | None = None):
|
|
super().__init__(401, "UNAUTHORIZED", message, field)
|
|
|
|
|
|
class ServiceUnavailableError(AppError):
|
|
"""503 UPSTREAM_UNAVAILABLE — a dependent service (Casdoor/MinIO) is down."""
|
|
|
|
def __init__(self, message: str, field: str | None = None):
|
|
super().__init__(503, "UPSTREAM_UNAVAILABLE", message, field)
|