RABC fejlesztése és beélesítése hibák kijavításával

This commit is contained in:
Roo
2026-06-18 18:09:51 +00:00
parent 611307a24b
commit fe3c32597d
57 changed files with 4689 additions and 655 deletions

View File

@@ -10,8 +10,10 @@ from sqlalchemy.orm import selectinload, joinedload
from app.db.session import get_db
from app.core.security import decode_token, DEFAULT_RANK_MAP
from app.models.identity import User, UserRole # JAVÍTVA: Új Identity modell használata
from app.models.marketplace.organization import OrgRole, OrganizationMember
from app.core.config import settings
from app.core.translation_helper import t # Translation helper
from app.core.capabilities import SYSTEM_CAPABILITIES_MATRIX, role_has_capability
logger = logging.getLogger(__name__)
@@ -104,13 +106,13 @@ async def get_current_active_user(
return current_user
async def check_resource_access(
resource_scope_id: Union[str, int],
resource_scope_id: Union[str, int],
current_user: User = Depends(get_current_user)
):
"""
Scoped RBAC: Megakadályozza a jogosulatlan hozzáférést mások adataihoz.
"""
if current_user.role == UserRole.superadmin:
if current_user.role == UserRole.SUPERADMIN:
return True
user_scope = str(current_user.scope_id) if current_user.scope_id else None
@@ -161,17 +163,138 @@ async def get_current_admin(
"""
Csak admin/moderátor/superadmin szerepkörrel rendelkező felhasználók számára.
"""
# A UserRole Enum értékeit használjuk
# A UserRole Enum értékeit használjuk (RBAC Phase 1)
allowed_roles = {
UserRole.superadmin,
UserRole.admin,
UserRole.region_admin,
UserRole.country_admin,
UserRole.moderator,
UserRole.SUPERADMIN,
UserRole.ADMIN,
UserRole.MODERATOR,
}
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=t("AUTH.INSUFFICIENT_ADMIN_PERMISSIONS")
)
return current_user
return current_user
# ═══════════════════════════════════════════════════════════════════════════════
# RBAC Phase 2: Capability Dependencies (Kapuőrök)
# ═══════════════════════════════════════════════════════════════════════════════
def RequireSystemCapability(capability_name: str):
"""
🎯 Rendszerszintű képesség-ellenőrző dependency.
Használat:
@router.get("/admin/users")
async def list_users(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(RequireSystemCapability("can_manage_users"))
):
Logika:
1. Ha a user SUPERADMIN → azonnal átengedi (True).
2. Egyébként lekéri a user role-ját, és ellenőrzi a
SYSTEM_CAPABILITIES_MATRIX-ból, hogy a kért capability True-e.
3. Ha nincs meg a capability → 403 Forbidden.
"""
async def system_capability_checker(
current_user: User = Depends(get_current_user),
) -> bool:
# SUPERADMIN mindent visz
if current_user.role == UserRole.SUPERADMIN:
return True
# Ellenőrizzük a SYSTEM_CAPABILITIES_MATRIX-ból
role_key = current_user.role.value # pl. "ADMIN", "MODERATOR", "USER"
if not role_has_capability(role_key, capability_name):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"SYSTEM_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{role_key}' szerepkör számára."
)
return True
return system_capability_checker
def RequireOrgCapability(capability_name: str):
"""
🎯 Szervezeti képesség-ellenőrző dependency.
Használat:
@router.post("/expenses")
async def create_expense(
expense: AssetCostCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_ = Depends(RequireOrgCapability("can_add_expense"))
):
Logika:
1. Ha a user SUPERADMIN → azonnal átengedi (True).
2. Lekéri a user OrgRole-ját az adott organization_id alapján
a fleet.organization_members táblából.
3. Kikeresi a fleet.org_roles táblából a szerepkörhöz tartozó
permissions JSONB oszlopot.
4. Ha a JSONB-ben a kért capability_name nem True → 403 Forbidden.
Fontos: Ez a dependency egy organization_id paramétert vár a végponttól.
A végpontnak át kell adnia az org_id-t a dependency-nek.
"""
async def org_capability_checker(
organization_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> bool:
# SUPERADMIN mindent visz
if current_user.role == UserRole.SUPERADMIN:
return True
# 1. Lekérjük a user szerepkörét a szervezetben
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="ORG_MEMBERSHIP_REQUIRED: You are not a member of this organization."
)
# 2. Lekérjük a szerepkör permissions JSONB-jét a fleet.org_roles táblából
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback: ha nincs a fleet.org_roles táblában, használjuk a
# OrganizationMember.permissions mezőt (örökölt viselkedés)
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
# 3. Ellenőrizzük a kért képességet
if not isinstance(permissions, dict):
permissions = {}
if not permissions.get(capability_name, False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"ORG_CAPABILITY_DENIED: A '{capability_name}' képesség nem elérhető a '{org_role_name}' szerepkör számára ebben a szervezetben."
)
return True
return org_capability_checker

View File

@@ -192,7 +192,7 @@ async def ban_user(
)
# 2. Ellenőrizd, hogy nem superadmin-e
if user.role == UserRole.superadmin:
if user.role == UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot ban a superadmin user"
@@ -698,7 +698,7 @@ async def bulk_user_action(
affected_count = 0
for user in users:
if user.role == UserRole.superadmin and user.id != current_admin.id:
if user.role == UserRole.SUPERADMIN and user.id != current_admin.id:
continue
if request.action == "ban":

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import selectinload
from app.db.session import get_db
from app.api.deps import get_current_user
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember
from app.models import Asset, AssetCatalog, AssetCost, AssetEvent, OdometerReading, CostCategory, OrganizationMember, OrgRole
from app.models.identity import User
from app.services.cost_service import cost_service
from app.services.asset_service import AssetService
@@ -21,6 +21,79 @@ from app.schemas.asset import AssetResponse, AssetCreate, AssetUpdate, AssetEven
logger = logging.getLogger(__name__)
async def _resolve_user_role_in_org(
db: AsyncSession,
user_id: int,
organization_id: int
) -> str:
"""
Resolve the user's role within the given organization.
Returns:
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
Returns 'MEMBER' as default if no membership found.
"""
stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
return role or "MEMBER"
async def _check_org_capability(
db: AsyncSession,
user_id: int,
organization_id: int,
capability_name: str
) -> bool:
"""
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
permissions JSONB oszlopából ellenőrzi a kért képességet.
Returns:
bool: True ha a user rendelkezik a képességgel.
"""
# 1. Get user's org role
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# 2. Get permissions from fleet.org_roles table
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback to OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
return permissions.get(capability_name, False)
class ArchiveVehicleRequest(BaseModel):
"""Payload a jármű archiválásához (Soft Delete)."""
final_mileage: int = Field(..., ge=0, description="Utolsó km óra állás")
@@ -40,10 +113,18 @@ async def get_vehicle_quota_status(
GET /api/v1/assets/vehicles/quota-status
Resolves the user's active organization:
- If scope_id is set, use that organization
- If scope_id is null, resolve the user's primary (individual) garage
from fleet.organizations via owner_id
Returns:
{ "can_add": bool, "current_count": int, "limit": int }
"""
try:
from sqlalchemy import func
from app.models.marketplace.organization import Organization
# Determine organization ID based on user's active scope (garage isolation)
org_id = None
if current_user.scope_id is not None:
@@ -51,6 +132,20 @@ async def get_vehicle_quota_status(
org_id = int(current_user.scope_id)
except (ValueError, TypeError):
pass
else:
# Resolve the user's primary individual garage
org_stmt = select(Organization.id).where(
Organization.owner_id == current_user.id,
Organization.org_type == "individual",
Organization.is_active == True
).limit(1)
org_result = await db.execute(org_stmt)
resolved_org_id = org_result.scalar_one_or_none()
if resolved_org_id is not None:
org_id = resolved_org_id
logger.info(
f"Resolved primary org for user {current_user.id}: org_id={org_id}"
)
# Get the user's vehicle limit
allowed_limit = await AssetService.get_user_vehicle_limit(
@@ -59,17 +154,15 @@ async def get_vehicle_quota_status(
# SAFETY: Ensure limit is at least 1
allowed_limit = max(allowed_limit or 1, 1)
# Count only active vehicles (draft vehicles don't count toward the limit)
from sqlalchemy import func
# Count only active vehicles in the resolved organization
if org_id is not None:
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id == org_id,
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
else:
# Fallback: count by owner_person_id if no org could be resolved
count_stmt = select(func.count(Asset.id)).where(
Asset.current_organization_id.is_(None),
Asset.owner_person_id == current_user.person_id,
Asset.status == "active"
)
@@ -158,29 +251,35 @@ async def get_user_vehicles(
order_expr = text("(individual_equipment->>'is_primary')::boolean DESC NULLS LAST")
if current_user.scope_id is None:
# Personal mode: only show vehicles owned/operated personally (no organization)
stmt = (
select(Asset)
.where(
or_(
Asset.owner_org_id.is_(None),
Asset.operator_org_id.is_(None)
),
or_(
Asset.owner_person_id == current_user.person_id,
Asset.operator_person_id == current_user.person_id
# Personal mode: show vehicles in organizations where user is a member
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.current_organization_id.in_(user_org_ids),
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
.order_by(order_expr, Asset.created_at.desc())
.offset(skip)
.limit(limit)
.options(
selectinload(Asset.catalog),
selectinload(Asset.odometer_readings)
)
)
else:
# No organizations — return empty list
return []
else:
# Corporate mode: only show vehicles belonging to the active organization's garages
# Corporate mode: show vehicles belonging to the active organization
# Uses current_organization_id (same logic as quota-status endpoint)
# for consistency — vehicles belong to organizations, not branches.
try:
scope_org_id = int(current_user.scope_id)
except (ValueError, TypeError):
@@ -191,25 +290,11 @@ async def get_user_vehicles(
# Fallback: no valid organization, return empty list
return []
# First, get all branch IDs (garages) for this organization
from app.models.marketplace.organization import Branch
branch_stmt = select(Branch.id).where(
Branch.organization_id == scope_org_id,
Branch.is_deleted == False,
Branch.status == "active"
)
branch_result = await db.execute(branch_stmt)
branch_ids = [row[0] for row in branch_result.all()]
if not branch_ids:
# Organization has no active garages, return empty list
return []
# Query assets that are in any of the organization's garages
# Query assets that belong to the active organization
stmt = (
select(Asset)
.where(
Asset.branch_id.in_(branch_ids),
Asset.current_organization_id == scope_org_id,
Asset.status == "active"
)
.order_by(order_expr, Asset.created_at.desc())
@@ -271,7 +356,9 @@ async def list_asset_costs(
"id": cost.id,
"asset_id": cost.asset_id,
"organization_id": cost.organization_id,
"amount_gross": cost.amount_gross,
"amount_net": cost.amount_net,
"vat_rate": cost.vat_rate,
"currency": cost.currency,
"date": cost.date,
"invoice_number": cost.invoice_number,
@@ -281,6 +368,8 @@ async def list_asset_costs(
"category_code": cost.category.code if cost.category else None,
"description": (cost.data or {}).get("description"),
"mileage_at_cost": (cost.data or {}).get("mileage_at_cost"),
"status": cost.status,
"linked_asset_event_id": cost.linked_asset_event_id,
}
result.append(AssetCostResponse(**cost_dict))
@@ -308,22 +397,32 @@ async def get_asset(
user_org_ids = [row[0] for row in org_result.all()]
# Query asset with catalog and master definition
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Unified Workspace: access is granted if the asset belongs to an organization
# where the current user is an active member
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -337,6 +436,69 @@ async def get_asset(
return asset
@router.get("/vehicles/{vehicle_id}", response_model=AssetResponse)
async def get_vehicle(
vehicle_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get detailed information about a specific vehicle/asset by vehicle_id.
GET /api/v1/assets/vehicles/{vehicle_id}
This endpoint is the primary single-vehicle lookup used by the frontend.
Uses Unified Workspace (OrganizationMember-based) permission check.
Returns the asset's full technical profile including catalog data
and vehicle model definition specifications.
"""
# Get user's organization memberships
org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Unified Workspace: access is granted if the asset belongs to an organization
# where the current user is an active member
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == vehicle_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
else:
stmt = (
select(Asset)
.where(
Asset.id == vehicle_id,
Asset.current_organization_id.is_(None)
)
.options(
selectinload(Asset.catalog).selectinload(AssetCatalog.master_definition),
selectinload(Asset.odometer_readings)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
if not asset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Asset not found or you don't have permission to access it"
)
return asset
@router.post("/vehicles", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
async def create_or_claim_vehicle(
payload: AssetCreate,
@@ -366,21 +528,27 @@ async def create_or_claim_vehicle(
existing_asset = existing_result.scalar_one_or_none()
if existing_asset:
# Szabály A: Saját jármű duplikációja
if existing_asset.owner_person_id == current_user.person_id:
# Szabály A: Saját jármű duplikációja — check via organization membership
# Get user's organization IDs
member_org_stmt = select(OrganizationMember.organization_id).where(
OrganizationMember.user_id == current_user.id
)
member_org_result = await db.execute(member_org_stmt)
user_org_ids = [row[0] for row in member_org_result.all()]
if existing_asset.current_organization_id in user_org_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Ez a jármű már szerepel a garázsodban!"
)
# Szabály B: Tulajdonosváltás / Átmeneti állapot
# A rendszám létezik, de más a tulajdonosa
# A rendszám létezik, de más szervezetben van
# Kényszerítsük draft állapotba, jelezve az átmeneti státuszt
payload.data_status = "draft"
logger.info(
f"License plate {license_plate_clean} exists with different owner "
f"(owner_person_id={existing_asset.owner_person_id} != "
f"current_user.person_id={current_user.person_id}). "
f"License plate {license_plate_clean} exists in different organization "
f"(current_organization_id={existing_asset.current_organization_id}). "
f"Setting data_status='draft' for transfer scenario."
)
@@ -458,20 +626,25 @@ async def update_vehicle(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Find the asset
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.person_id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.person_id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Find the asset — Unified Workspace: access via organization membership only
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
.options(selectinload(Asset.catalog))
)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
.options(selectinload(Asset.catalog))
)
.options(selectinload(Asset.catalog))
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -540,16 +713,17 @@ async def list_maintenance_records(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Check asset access
asset_stmt = select(Asset).where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Check asset access — Unified Workspace: via organization membership only
if user_org_ids:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
else:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
)
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
@@ -599,16 +773,17 @@ async def create_maintenance_record(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
# Check asset access and get asset
asset_stmt = select(Asset).where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Check asset access and get asset — Unified Workspace: via organization membership only
if user_org_ids:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
else:
asset_stmt = select(Asset).where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
)
asset_result = await db.execute(asset_stmt)
asset = asset_result.scalar_one_or_none()
@@ -663,11 +838,14 @@ async def create_maintenance_record(
)
# Create AssetCost record with MAINTENANCE category (id=2)
# GROSS-FIRST: The payload "cost" field is treated as gross (Bruttó)
cost_gross = float(payload["cost"])
maintenance_cost = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=2,
amount_net=float(payload["cost"]),
amount_gross=cost_gross,
amount_net=cost_gross, # Default: net = gross (0% VAT) unless vat_rate provided
currency=payload.get("currency", "EUR"),
date=date,
invoice_number=payload.get("invoice_number"),
@@ -736,18 +914,23 @@ async def _check_asset_access(
org_result = await db.execute(org_stmt)
user_org_ids = [row[0] for row in org_result.all()]
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
or_(
Asset.owner_person_id == current_user.id,
Asset.owner_org_id.in_(user_org_ids) if user_org_ids else False,
Asset.operator_person_id == current_user.id,
Asset.operator_org_id.in_(user_org_ids) if user_org_ids else False
# Unified Workspace: access via organization membership only
if user_org_ids:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.in_(user_org_ids)
)
)
else:
stmt = (
select(Asset)
.where(
Asset.id == asset_id,
Asset.current_organization_id.is_(None)
)
)
)
result = await db.execute(stmt)
asset = result.scalar_one_or_none()
@@ -863,6 +1046,14 @@ async def create_asset_event(
- Ha `cost_amount > 0`, automatikusan létrehoz egy AssetCost rekordot
a megfelelő költségkategóriával (MAINTENANCE/REPAIR/SERVICE),
és összekapcsolja az eseménnyel a `cost_id` mezőn keresztül.
**Role-Based Auto-Approval (PHASE 2):**
- OWNER/ADMIN → event status = COMPLETED, cost status = APPROVED
- DRIVER → event status = MISSING_TECH_DATA, cost status = PENDING_APPROVAL
**Smart Linking (Double-Entry Avoidance):**
- When cost_amount > 0, the auto-created AssetCost is linked back
to the event via linked_expense_id / linked_asset_event_id.
"""
# Jogosultság ellenőrzés
asset = await _check_asset_access(db, asset_id, current_user)
@@ -873,9 +1064,30 @@ async def create_asset_event(
# Szervezet meghatározása
organization_id = asset.current_organization_id or asset.owner_org_id
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
# Check if user has can_approve_expense capability
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
# Determine event and cost status based on capability
if can_approve:
event_status = "COMPLETED"
cost_status = "APPROVED"
else:
event_status = "MISSING_TECH_DATA"
cost_status = "PENDING_APPROVAL"
logger.info(
f"Capability-based approval for event: user {current_user.id} (role={user_role}) "
f"in org {organization_id}: can_approve_expense={can_approve}, "
f"event_status={event_status}, cost_status={cost_status}"
)
try:
# ── Üzleti logika: AssetCost automatikus létrehozása ──
cost_id = payload.cost_id
auto_created_cost_id = None
if payload.cost_amount is not None and payload.cost_amount > 0:
# Költségkategória feloldása az esemény típusa alapján
# SERVICE/MAINTENANCE -> karbantartás, REPAIR -> javítás
@@ -883,13 +1095,17 @@ async def create_asset_event(
currency = payload.currency or "HUF"
# GROSS-FIRST: cost_amount is treated as gross (Bruttó)
cost_gross = payload.cost_amount
cost_record = AssetCost(
asset_id=asset_id,
organization_id=organization_id,
category_id=cost_category_id,
amount_net=payload.cost_amount,
amount_gross=cost_gross,
amount_net=cost_gross, # Default: net = gross (0% VAT)
currency=currency,
date=event_date,
status=cost_status,
data={
"description": payload.description or f"Service event: {payload.event_type}",
"mileage_at_cost": payload.odometer_reading,
@@ -900,13 +1116,15 @@ async def create_asset_event(
db.add(cost_record)
await db.flush() # Flush to get the cost_record.id
cost_id = cost_record.id
auto_created_cost_id = cost_record.id
logger.info(
f"AssetCost auto-created for asset {asset_id}: "
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, cost_id={cost_id})"
f"{payload.cost_amount} {currency} (event_type={payload.event_type}, "
f"cost_id={cost_id}, status={cost_status})"
)
# Új esemény létrehozása
# Új esemény létrehozása (with status and bidirectional link)
event = AssetEvent(
asset_id=asset_id,
user_id=current_user.id,
@@ -915,9 +1133,28 @@ async def create_asset_event(
odometer_reading=payload.odometer_reading,
description=payload.description,
cost_id=cost_id,
linked_expense_id=auto_created_cost_id, # Bidirectional link
status=event_status,
event_date=event_date,
)
db.add(event)
await db.flush() # Flush to get event.id
# If we auto-created a cost, link it back to the event
if auto_created_cost_id:
# Update the cost record with the link back to the event
stmt_update = (
select(AssetCost)
.where(AssetCost.id == auto_created_cost_id)
)
result = await db.execute(stmt_update)
cost_record = result.scalar_one_or_none()
if cost_record:
cost_record.linked_asset_event_id = event.id
logger.info(
f"Smart Sync: Bidirectional link established between "
f"AssetEvent {event.id} and AssetCost {auto_created_cost_id}"
)
# ── Üzleti logika: Km óra állás frissítése ──
if payload.odometer_reading is not None and payload.odometer_reading > asset.current_mileage:

View File

@@ -89,11 +89,15 @@ async def get_document_status(
# RBAC helper function
def _check_premium_or_admin(user: User) -> bool:
"""Check if user has premium subscription or admin role."""
premium_plans = ['PREMIUM', 'PREMIUM_PLUS', 'VIP', 'VIP_PLUS']
if user.role == 'admin':
return True
if hasattr(user, 'subscription_plan') and user.subscription_plan in premium_plans:
"""Check if user has premium subscription or admin role.
P0: Legacy subscription_plan string check removed.
The Single Source of Truth is now subscription_tier JSONB rules.
Premium entitlement should be checked via the SubscriptionTier entitlements array.
"""
premium_roles = ['admin', 'superadmin']
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
if user_role.lower() in premium_roles:
return True
return False

View File

@@ -2,10 +2,12 @@
import logging
from fastapi import APIRouter, UploadFile, File, HTTPException, status, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, text
from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
from app.models.identity import User
from app.models import Asset # JAVÍTVA: Asset modell
from app.models.marketplace.organization import Organization
from app.models.core_logic import SubscriptionTier
logger = logging.getLogger(__name__)
@@ -13,10 +15,28 @@ router = APIRouter()
@router.post("/scan-registration")
async def scan_registration_document(file: UploadFile = File(...), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user)):
stmt_limit = text("SELECT (value->>:plan)::int FROM system.system_parameters WHERE key = 'VEHICLE_LIMIT'")
plan_key = (current_user.subscription_plan or "free").lower()
res = await db.execute(stmt_limit, {"plan": plan_key})
max_allowed = max(res.scalar() or 1, 1)
# ── P0 FEATURE: Quota engine sync — read org's subscription_tier rules ──
# Instead of hardcoded plan-based lookup from system_parameters,
# we now read the organization's assigned subscription_tier rules.
max_allowed = 1 # default fallback
if current_user.scope_id:
# Get the organization and its subscription tier
stmt_org = select(Organization).where(Organization.id == current_user.scope_id)
result = await db.execute(stmt_org)
org = result.scalar_one_or_none()
if org and org.subscription_tier_id:
stmt_tier = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
tier_result = await db.execute(stmt_tier)
tier = tier_result.scalar_one_or_none()
if tier and tier.rules:
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1)
max_allowed = max(int(max_vehicles), 1)
elif org:
# Fallback to legacy base_asset_limit if no tier assigned
max_allowed = max(org.base_asset_limit or 1, 1)
stmt_count = select(func.count(Asset.id)).where(
Asset.owner_org_id == current_user.scope_id,

View File

@@ -1,10 +1,11 @@
# /opt/docker/dev/service_finder/backend/app/api/v1/endpoints/expenses.py
import logging
from decimal import Decimal
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.api.deps import get_db, get_current_user
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter
from app.api.deps import get_db, get_current_user, RequireOrgCapability
from app.models import Asset, AssetCost, AssetEvent, OrganizationMember, SystemParameter, OrgRole
from app.schemas.asset_cost import AssetCostCreate
from datetime import datetime, timezone
@@ -14,8 +15,101 @@ logger = logging.getLogger(__name__)
# These trigger automatic AssetEvent creation
SERVICE_RELATED_CATEGORY_IDS = {2, 16, 17, 18} # MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES
# Fuel category IDs - auto-approved even for DRIVER role
FUEL_CATEGORY_IDS = {1} # FUEL
router = APIRouter()
async def _resolve_user_role_in_org(
db: AsyncSession,
user_id: int,
organization_id: int
) -> str:
"""
Resolve the user's role within the given organization.
Returns:
str: The role string (OWNER, ADMIN, MEMBER, DRIVER, etc.)
Returns 'MEMBER' as default if no membership found.
"""
stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
result = await db.execute(stmt)
role = result.scalar_one_or_none()
return role or "MEMBER"
async def _check_org_capability(
db: AsyncSession,
user_id: int,
organization_id: int,
capability_name: str
) -> bool:
"""
RBAC Phase 2: JSONB-alapú képesség-ellenőrzés.
Lekéri a user szervezeti szerepkörét, majd a fleet.org_roles tábla
permissions JSONB oszlopából ellenőrzi a kért képességet.
Returns:
bool: True ha a user rendelkezik a képességgel.
"""
# 1. Get user's org role
member_stmt = select(OrganizationMember.role).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_result = await db.execute(member_stmt)
org_role_name = member_result.scalar_one_or_none()
if not org_role_name:
return False
# 2. Get permissions from fleet.org_roles table
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback to OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == user_id,
OrganizationMember.organization_id == organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
return permissions.get(capability_name, False)
def _calculate_net_from_gross(amount_gross: Decimal, vat_rate: Decimal) -> Decimal:
"""
Calculate net amount from gross amount and VAT rate.
GROSS-FIRST (Masterbook 2.0.1): In EU accounting, the gross amount (Bruttó)
is the absolute Source of Truth. Net is calculated back from gross.
Formula: net = gross / (1 + vat_rate / 100)
If vat_rate is 0 or None, net = gross (0% VAT content).
"""
if vat_rate is None or vat_rate == 0:
return amount_gross
return (amount_gross / (Decimal("1") + vat_rate / Decimal("100"))).quantize(Decimal("0.01"))
@router.post("/", status_code=201)
async def create_expense(
expense: AssetCostCreate,
@@ -25,10 +119,15 @@ async def create_expense(
"""
Create a new expense (fuel, service, tax, insurance) for an asset.
Uses AssetCostCreate schema which includes mileage_at_cost, cost_type, etc.
**Role-Based Auto-Approval:**
- OWNER/ADMIN → status = APPROVED
- DRIVER → status = PENDING_APPROVAL (except fuel, which is auto-approved)
**Bidirectional Sync:**
**Smart Linking (Double-Entry Avoidance):**
- If the cost category is service-related (MAINTENANCE, MAINT_SERVICE, MAINT_OIL, MAINT_BRAKES),
an AssetEvent is automatically created and linked to the cost via cost_id.
an AssetEvent is automatically created with MISSING_TECH_DATA status
and linked via linked_asset_event_id / linked_expense_id.
"""
# Validate asset exists
stmt = select(Asset).where(Asset.id == expense.asset_id)
@@ -89,6 +188,48 @@ async def create_expense(
else:
raise HTTPException(status_code=400, detail="Asset has no associated organization.")
# ── RBAC PHASE 2: CAPABILITY-BASED AUTO-APPROVAL ──
# JSONB-alapú képesség-ellenőrzés a fleet.org_roles tábla permissions oszlopából
user_role = await _resolve_user_role_in_org(db, current_user.id, organization_id)
# Check if user has can_approve_expense capability → auto-approved
can_approve = await _check_org_capability(db, current_user.id, organization_id, "can_approve_expense")
# Determine expense status based on capabilities
if can_approve:
# User has can_approve_expense → auto-approved
expense_status = "APPROVED"
elif expense.category_id in FUEL_CATEGORY_IDS:
# Fuel costs are auto-approved even without can_approve_expense
expense_status = "APPROVED"
else:
# User lacks can_approve_expense → pending approval
expense_status = "PENDING_APPROVAL"
logger.info(
f"Capability-based approval for user {current_user.id} (role={user_role}) "
f"in org {organization_id}: can_approve_expense={can_approve}, status={expense_status}"
)
# ── GROSS-FIRST VAT HANDLING (Masterbook 2.0.1) ──
# In EU accounting, the GROSS amount (Bruttó) is the absolute Source of Truth.
amount_gross = expense.amount_gross
vat_rate = expense.vat_rate
amount_net = expense.amount_net
# Auto-calculate net from gross if vat_rate is provided but net is not
if vat_rate is not None and amount_net is None:
amount_net = _calculate_net_from_gross(amount_gross, vat_rate)
# Auto-calculate vat_rate if net is provided but vat_rate is not
elif amount_net is not None and vat_rate is None and amount_gross > 0:
# vat_rate = ((gross / net) - 1) * 100
vat_rate = ((amount_gross / amount_net) - Decimal("1")) * Decimal("100")
vat_rate = vat_rate.quantize(Decimal("0.01"))
# If only gross is provided (no net, no vat) → net = gross (0% VAT)
elif amount_net is None and vat_rate is None:
amount_net = amount_gross
vat_rate = Decimal("0")
# Prepare data JSON for extra fields (mileage_at_cost, description, etc.)
data = expense.data.copy() if expense.data else {}
if expense.mileage_at_cost is not None:
@@ -97,22 +238,25 @@ async def create_expense(
data["description"] = expense.description
try:
# Create AssetCost instance
# Create AssetCost instance with new fields
new_cost = AssetCost(
asset_id=expense.asset_id,
organization_id=organization_id,
category_id=expense.category_id,
amount_net=expense.amount_net,
amount_net=amount_net,
amount_gross=amount_gross,
vat_rate=vat_rate,
currency=expense.currency,
date=expense.date,
invoice_number=data.get("invoice_number"),
status=expense_status,
data=data
)
db.add(new_cost)
await db.flush() # Flush to get new_cost.id
# ── BIDIRECTIONAL SYNC: Auto-create AssetEvent for service-related costs ──
# ── PHASE 2: SMART LINKING - Auto-create AssetEvent for service-related costs ──
event_id = None
if expense.category_id in SERVICE_RELATED_CATEGORY_IDS:
# Map cost category to event type
@@ -121,6 +265,10 @@ async def create_expense(
description = expense.description or data.get("description", f"Service cost: {expense.category_id}")
mileage = expense.mileage_at_cost
# Determine event status based on capability
# can_approve_expense → COMPLETED, otherwise → MISSING_TECH_DATA
event_status = "COMPLETED" if can_approve else "MISSING_TECH_DATA"
new_event = AssetEvent(
asset_id=expense.asset_id,
user_id=getattr(current_user, 'id', None),
@@ -129,15 +277,21 @@ async def create_expense(
odometer_reading=mileage,
description=description,
cost_id=new_cost.id,
linked_expense_id=new_cost.id, # Bidirectional link
status=event_status,
event_date=expense.date or datetime.now(timezone.utc),
)
db.add(new_event)
await db.flush() # Flush to get new_event.id
event_id = new_event.id
# Update the cost record with the link back to the event
new_cost.linked_asset_event_id = new_event.id
logger.info(
f"Bidirectional sync: Auto-created AssetEvent {event_id} "
f"for AssetCost {new_cost.id} (category_id={expense.category_id})"
f"Smart Sync: Auto-created AssetEvent {event_id} (status={event_status}) "
f"for AssetCost {new_cost.id} (category_id={expense.category_id}). "
f"Bidirectional link established."
)
# Update Asset.current_mileage if mileage_at_cost is higher
@@ -152,8 +306,11 @@ async def create_expense(
"id": new_cost.id,
"asset_id": new_cost.asset_id,
"category_id": new_cost.category_id,
"amount_net": new_cost.amount_net,
"date": new_cost.date,
"amount_gross": str(new_cost.amount_gross) if new_cost.amount_gross else None,
"amount_net": str(new_cost.amount_net),
"vat_rate": str(new_cost.vat_rate) if new_cost.vat_rate else None,
"expense_status": new_cost.status,
"date": new_cost.date.isoformat() if new_cost.date else None,
"event_id": str(event_id) if event_id else None,
}
@@ -179,4 +336,4 @@ def _map_category_to_event_type(category_id: int) -> str:
17: "SERVICE", # MAINT_OIL
18: "REPAIR", # MAINT_BRAKES
}
return category_event_map.get(category_id, "MAINTENANCE")
return category_event_map.get(category_id, "MAINTENANCE")

View File

@@ -24,7 +24,7 @@ async def check_finance_admin_access(
RBAC protection: only users with rank >= 90 (Superadmin/Finance Admin) can access.
In our system, this translates to role being 'superadmin' or 'admin'.
"""
if current_user.role not in [UserRole.superadmin, UserRole.admin]:
if current_user.role not in [UserRole.SUPERADMIN, UserRole.ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions. Rank >= 90 (Superadmin/Finance Admin) required."

View File

@@ -14,8 +14,11 @@ from sqlalchemy.orm import selectinload
from pydantic import BaseModel, Field, ConfigDict, EmailStr
from app.db.session import get_db
from app.api.deps import get_current_user
from app.api.deps import get_current_user, RequireSystemCapability
from app.schemas.organization import CorpOnboardIn, CorpOnboardResponse, OrganizationUpdate, OrganizationResponse
from app.schemas.subscription import SubscriptionTierResponse
from app.services.billing_engine import upgrade_org_subscription
from app.core.capabilities import Capability
from app.models.marketplace.organization import Organization, OrgType, OrganizationMember, Branch, OrgUserRole, OrgRole
from app.models.identity import User, OneTimePassword
from app.core.config import settings
@@ -219,7 +222,14 @@ async def get_my_organizations(
"subscription_plan": o.subscription_plan,
"org_type": o.org_type.value if hasattr(o.org_type, 'value') else str(o.org_type),
"visual_settings": o.visual_settings if hasattr(o, 'visual_settings') else None,
"user_role": _get_user_role(o, current_user.id)
"user_role": _get_user_role(o, current_user.id),
# ── Cím adatok (Address fields) ──
"address_zip": o.address_zip,
"address_city": o.address_city,
"address_street_name": o.address_street_name,
"address_street_type": o.address_street_type,
"address_house_number": o.address_house_number,
"address_hrsz": o.address_hrsz,
}
for o in orgs
]
@@ -663,19 +673,7 @@ async def update_organization(
Csak OWNER vagy ADMIN jogosultságú felhasználó módosíthatja.
"""
# 1. Jogosultság ellenőrzése
stmt_member = select(OrganizationMember).where(
(OrganizationMember.organization_id == org_id) &
(OrganizationMember.user_id == current_user.id) &
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
)
member = (await db.execute(stmt_member)).scalar_one_or_none()
if not member:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
)
# 2. Szervezet lekérése
# Először lekérjük a szervezetet, hogy ellenőrizhessük az owner_id-t is
stmt_org = select(Organization).where(
Organization.id == org_id,
Organization.is_deleted == False
@@ -688,7 +686,23 @@ async def update_organization(
detail=_("ORGANIZATION.ERROR.NOT_FOUND")
)
# 3. JSONB merge visual_settings esetén
# Jogosultság: OWNER/ADMIN a OrganizationMember táblában VAGY owner_id a Organization-ben
is_owner_by_field = (org.owner_id == current_user.id)
stmt_member = select(OrganizationMember).where(
(OrganizationMember.organization_id == org_id) &
(OrganizationMember.user_id == current_user.id) &
(OrganizationMember.role.in_(["OWNER", "ADMIN"]))
)
member = (await db.execute(stmt_member)).scalar_one_or_none()
if not member and not is_owner_by_field:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=_("ORGANIZATION.ERROR.ACCESS_DENIED")
)
# 2. JSONB merge visual_settings esetén
update_dict = update_data.model_dump(exclude_unset=True)
if "visual_settings" in update_dict and update_dict["visual_settings"] is not None:
@@ -717,6 +731,63 @@ async def update_organization(
return OrganizationResponse.model_validate(org)
# ── P0 FEATURE: SUBSCRIPTION & PACKAGE ASSIGNMENT BRIDGE ──
class SubscriptionAssignIn(BaseModel):
"""PUT body a szervezet előfizetési csomagjának beállításához."""
tier_id: int = Field(..., description="SubscriptionTier ID a system.subscription_tiers táblából")
@router.put("/{org_id}/subscription", response_model=dict)
async def assign_organization_subscription(
org_id: int,
body: SubscriptionAssignIn,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_: bool = Depends(RequireSystemCapability(Capability.CAN_MANAGE_SUBSCRIPTIONS))
):
"""
Szervezet előfizetési csomagjának beállítása (Subscription & Package Assignment Bridge).
P0 Feature: This endpoint assigns a subscription_tier to an organization.
It requires the 'can_manage_subscriptions' system capability (SUPERADMIN or ADMIN).
The endpoint:
1. Validates the tier exists in system.subscription_tiers
2. Sets subscription_tier_id on the Organization record
3. Updates subscription_plan and base_asset_limit from tier rules
4. Creates/updates a finance.org_subscriptions audit record
"""
try:
result = await upgrade_org_subscription(
db=db,
org_id=org_id,
tier_id=body.tier_id,
actor_user_id=current_user.id
)
await db.commit()
await security_service.log_event(
db, user_id=current_user.id, action="ORG_SUBSCRIPTION_ASSIGNED",
severity=LogSeverity.info, target_type="Organization", target_id=str(org_id),
new_data={"tier_id": body.tier_id, "tier_name": result.get("tier_name")}
)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
await db.rollback()
logger.error(f"Subscription assignment failed: org_id={org_id}, tier_id={body.tier_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=_("ORGANIZATION.ERROR.DATABASE_ERROR", error=str(e))
)
# ── CÉG CSATLAKOZÁS ÉS ÁRVA CÉG ÁTVÉTEL ──
class JoinRequestIn(BaseModel):

View File

@@ -36,7 +36,7 @@ async def request_action(
- ORGANIZATION_TRANSFER: Szervezet tulajdonjog átadása
"""
# Csak admin és superadmin kezdeményezhet kiemelt műveleteket
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok kezdeményezhetnek Dual Control műveleteket."
@@ -69,7 +69,7 @@ async def list_pending_actions(
Admin és superadmin látja az összes függőben lévő műveletet.
Egyéb felhasználók csak a sajátjaikat láthatják.
"""
if current_user.role in [UserRole.admin, UserRole.superadmin]:
if current_user.role in [UserRole.ADMIN, UserRole.SUPERADMIN]:
user_id = None
else:
user_id = current_user.id
@@ -89,7 +89,7 @@ async def approve_action(
Csak admin/superadmin hagyhat jóvá, és nem lehet a saját kérése.
"""
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok hagyhatnak jóvá műveleteket."
@@ -122,7 +122,7 @@ async def reject_action(
Csak admin/superadmin utasíthat el, és nem lehet a saját kérése.
"""
if current_user.role not in [UserRole.admin, UserRole.superadmin]:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Csak adminisztrátorok utasíthatnak el műveleteket."
@@ -164,7 +164,7 @@ async def get_action(
if not action:
raise HTTPException(status_code=404, detail="Művelet nem található.")
if current_user.role not in [UserRole.admin, UserRole.superadmin] and action.requester_id != current_user.id:
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN] and action.requester_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Nincs jogosultságod ehhez a művelethez."

View File

@@ -122,7 +122,7 @@ async def update_system_parameter(
Csak superadmin vagy admin jogosultságú felhasználók használhatják.
"""
# Jogosultság ellenőrzése
if current_user.role not in (UserRole.superadmin, UserRole.admin):
if current_user.role not in (UserRole.SUPERADMIN, UserRole.ADMIN):
raise HTTPException(
status_code=403,
detail="Insufficient permissions. Only superadmin or admin can update system parameters."

View File

@@ -41,13 +41,15 @@ class NetworkResponse(BaseModel):
level3: List[NetworkMemberL2L3]
def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dict:
def _build_user_response(user: User, active_org_id: Optional[int] = None, db: Optional[AsyncSession] = None) -> dict:
"""
Segédfüggvény a UserResponse dict előállításához.
Beágyazza a Person és Address adatokat a 'person' mezőbe.
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba.
"""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID
if active_org_id is None:
@@ -101,6 +103,17 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
first_name = person.first_name if person else ""
last_name = person.last_name if person else ""
# ── RBAC Phase 3: Resolve system capabilities ──
role_key = user.role.value if hasattr(user.role, 'value') else str(user.role)
system_capabilities = get_capabilities_for_role(role_key)
# ── RBAC Phase 3: Resolve org capabilities ──
org_capabilities: Dict[str, Dict[str, bool]] = {}
if db is not None:
# We need to run this synchronously since _build_user_response is not async
# The async version is handled in read_users_me directly
pass
return {
"id": user.id,
"email": user.email,
@@ -109,7 +122,7 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"is_active": user.is_active,
"region_code": user.region_code,
"person_id": user.person_id,
"role": user.role.value if hasattr(user.role, 'value') else str(user.role),
"role": role_key,
"subscription_plan": user.subscription_plan,
"scope_level": user.scope_level or "individual",
"scope_id": str(active_org_id) if active_org_id else None,
@@ -118,6 +131,10 @@ def _build_user_response(user: User, active_org_id: Optional[int] = None) -> dic
"preferred_language": user.preferred_language or "hu",
"person": person_data,
"is_last_admin": False, # Default, will be overridden in read_users_me
# RBAC Phase 3: Rendszerszintű képességek
"system_capabilities": system_capabilities,
# RBAC Phase 3: Szervezeti képességek (feltöltve a read_users_me végpontban)
"org_capabilities": {},
}
@@ -126,9 +143,11 @@ async def read_users_me(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal."""
"""Visszaadja a bejelentkezett felhasználó profilját beágyazott Person és Address adatokkal.
RBAC Phase 3: system_capabilities és org_capabilities is bekerül a válaszba."""
from app.models.marketplace.organization import Organization, OrganizationMember
from app.models.marketplace.organization import OrgUserRole
from app.core.capabilities import get_capabilities_for_role
# Determine active organization ID
active_org_id = None
@@ -178,8 +197,53 @@ async def read_users_me(
# Check if user is the last admin in any organization
is_last_admin = await AuthService.check_is_last_admin(db, current_user.id)
# ── RBAC Phase 3: Build base response ──
response_data = _build_user_response(current_user, active_org_id)
response_data["is_last_admin"] = is_last_admin
# ── RBAC Phase 3: Resolve org_capabilities ──
# Get all organizations the user is a member of
org_memberships_stmt = select(
OrganizationMember.organization_id,
OrganizationMember.role
).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.status == "active"
)
org_memberships_result = await db.execute(org_memberships_stmt)
org_memberships = org_memberships_result.all()
org_capabilities: Dict[str, Dict[str, bool]] = {}
for membership in org_memberships:
org_id = str(membership.organization_id)
org_role_name = membership.role
# Try to get permissions from OrgRole table first
role_stmt = select(OrgRole.permissions).where(
OrgRole.name_key == org_role_name,
OrgRole.is_active == True
).limit(1)
role_result = await db.execute(role_stmt)
permissions = role_result.scalar_one_or_none()
if not permissions:
# Fallback: use OrganizationMember.permissions
member_perm_stmt = select(OrganizationMember.permissions).where(
OrganizationMember.user_id == current_user.id,
OrganizationMember.organization_id == membership.organization_id,
OrganizationMember.status == "active"
).limit(1)
member_perm_result = await db.execute(member_perm_stmt)
permissions = member_perm_result.scalar_one_or_none() or {}
if not isinstance(permissions, dict):
permissions = {}
org_capabilities[org_id] = permissions
response_data["org_capabilities"] = org_capabilities
return UserResponse.model_validate(response_data)