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)

View File

@@ -0,0 +1,295 @@
# /opt/docker/dev/service_finder/backend/app/core/capabilities.py
"""
🎯 SYSTEM CAPABILITIES MATRIX (RBAC Phase 1)
Fine-grained, capability-driven permission matrix for system-level roles.
Each role maps to a set of boolean capabilities that define what the role
can do within the platform.
Architecture:
- SUPERADMIN: Full system access (rank 100)
- ADMIN: Full admin panel access (rank 90)
- MODERATOR: Content moderation capabilities (rank 75)
- SALES_REP: Sales & discount management (rank 30)
- SERVICE_MGR: Service provider & taxonomy management (rank 40)
- USER: Basic platform user (rank 10)
"""
from typing import Dict, Set
# ──────────────────────────────────────────────
# CAPABILITY KEYS (for type-safe references)
# ──────────────────────────────────────────────
class Capability:
"""Central registry of all capability keys."""
# ── Admin & System ──
CAN_MANAGE_USERS = "can_manage_users"
CAN_MANAGE_ROLES = "can_manage_roles"
CAN_VIEW_SYSTEM_LOGS = "can_view_system_logs"
CAN_MANAGE_SYSTEM_CONFIG = "can_manage_system_config"
CAN_MANAGE_TRANSLATIONS = "can_manage_translations"
# ── Moderation ──
CAN_MODERATE_CONTENT = "can_moderate_content"
CAN_VIEW_REPORTS = "can_view_reports"
CAN_SUSPEND_USERS = "can_suspend_users"
CAN_APPROVE_PROVIDER = "can_approve_provider"
# ── Sales & Marketing ──
CAN_VIEW_ORG_STATS = "can_view_org_stats"
CAN_MANAGE_DISCOUNTS = "can_manage_discounts"
CAN_MANAGE_PROMOTIONS = "can_manage_promotions"
CAN_VIEW_SALES_PIPELINE = "can_view_sales_pipeline"
# ── Service Management ──
CAN_EDIT_MASTER_TAXONOMY = "can_edit_master_taxonomy"
CAN_MANAGE_SERVICE_CATEGORIES = "can_manage_service_categories"
CAN_VERIFY_SERVICE_PROVIDERS = "can_verify_service_providers"
CAN_MANAGE_EXPERTISE_TAGS = "can_manage_expertise_tags"
# ── Vehicle & Catalog ──
CAN_MANAGE_VEHICLE_CATALOG = "can_manage_vehicle_catalog"
CAN_APPROVE_VEHICLE_DEFINITIONS = "can_approve_vehicle_definitions"
CAN_VIEW_TECHNICAL_SPECS = "can_view_technical_specs"
# ── Finance ──
CAN_VIEW_FINANCIAL_REPORTS = "can_view_financial_reports"
CAN_MANAGE_BILLING = "can_manage_billing"
CAN_ISSUE_REFUNDS = "can_issue_refunds"
CAN_MANAGE_SUBSCRIPTIONS = "can_manage_subscriptions"
# ── Basic User ──
CAN_MANAGE_OWN_PROFILE = "can_manage_own_profile"
CAN_MANAGE_OWN_VEHICLES = "can_manage_own_vehicles"
CAN_CREATE_SERVICE_REQUEST = "can_create_service_request"
CAN_VIEW_PUBLIC_CATALOG = "can_view_public_catalog"
# ──────────────────────────────────────────────
# SYSTEM CAPABILITIES MATRIX
# ──────────────────────────────────────────────
SYSTEM_CAPABILITIES_MATRIX: Dict[str, Dict[str, bool]] = {
# ── SUPERADMIN: Full system access ──
"SUPERADMIN": {
# Admin & System
Capability.CAN_MANAGE_USERS: True,
Capability.CAN_MANAGE_ROLES: True,
Capability.CAN_VIEW_SYSTEM_LOGS: True,
Capability.CAN_MANAGE_SYSTEM_CONFIG: True,
Capability.CAN_MANAGE_TRANSLATIONS: True,
# Moderation
Capability.CAN_MODERATE_CONTENT: True,
Capability.CAN_VIEW_REPORTS: True,
Capability.CAN_SUSPEND_USERS: True,
Capability.CAN_APPROVE_PROVIDER: True,
# Sales
Capability.CAN_VIEW_ORG_STATS: True,
Capability.CAN_MANAGE_DISCOUNTS: True,
Capability.CAN_MANAGE_PROMOTIONS: True,
Capability.CAN_VIEW_SALES_PIPELINE: True,
# Service Management
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
# Vehicle & Catalog
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
# Finance
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
Capability.CAN_MANAGE_BILLING: True,
Capability.CAN_ISSUE_REFUNDS: True,
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
# Basic User
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── ADMIN: Full admin panel access ──
"ADMIN": {
Capability.CAN_MANAGE_USERS: True,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: True,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: True,
Capability.CAN_MODERATE_CONTENT: True,
Capability.CAN_VIEW_REPORTS: True,
Capability.CAN_SUSPEND_USERS: True,
Capability.CAN_APPROVE_PROVIDER: True,
Capability.CAN_VIEW_ORG_STATS: True,
Capability.CAN_MANAGE_DISCOUNTS: True,
Capability.CAN_MANAGE_PROMOTIONS: True,
Capability.CAN_VIEW_SALES_PIPELINE: True,
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
Capability.CAN_MANAGE_VEHICLE_CATALOG: True,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: True,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
Capability.CAN_VIEW_FINANCIAL_REPORTS: True,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_SUBSCRIPTIONS: True,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── MODERATOR: Content moderation ──
"MODERATOR": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: True,
Capability.CAN_MODERATE_CONTENT: True,
Capability.CAN_VIEW_REPORTS: True,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: True,
Capability.CAN_VIEW_ORG_STATS: False,
Capability.CAN_MANAGE_DISCOUNTS: False,
Capability.CAN_MANAGE_PROMOTIONS: False,
Capability.CAN_VIEW_SALES_PIPELINE: False,
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── SALES_REP: Sales & discount management ──
"SALES_REP": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: False,
Capability.CAN_MODERATE_CONTENT: False,
Capability.CAN_VIEW_REPORTS: False,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: False,
Capability.CAN_VIEW_ORG_STATS: True,
Capability.CAN_MANAGE_DISCOUNTS: True,
Capability.CAN_MANAGE_PROMOTIONS: True,
Capability.CAN_VIEW_SALES_PIPELINE: True,
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── SERVICE_MGR: Service provider & taxonomy management ──
"SERVICE_MGR": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: False,
Capability.CAN_MODERATE_CONTENT: False,
Capability.CAN_VIEW_REPORTS: False,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: True,
Capability.CAN_VIEW_ORG_STATS: False,
Capability.CAN_MANAGE_DISCOUNTS: False,
Capability.CAN_MANAGE_PROMOTIONS: False,
Capability.CAN_VIEW_SALES_PIPELINE: False,
Capability.CAN_EDIT_MASTER_TAXONOMY: True,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: True,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: True,
Capability.CAN_MANAGE_EXPERTISE_TAGS: True,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: True,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
# ── USER: Basic platform user ──
"USER": {
Capability.CAN_MANAGE_USERS: False,
Capability.CAN_MANAGE_ROLES: False,
Capability.CAN_VIEW_SYSTEM_LOGS: False,
Capability.CAN_MANAGE_SYSTEM_CONFIG: False,
Capability.CAN_MANAGE_TRANSLATIONS: False,
Capability.CAN_MODERATE_CONTENT: False,
Capability.CAN_VIEW_REPORTS: False,
Capability.CAN_SUSPEND_USERS: False,
Capability.CAN_APPROVE_PROVIDER: False,
Capability.CAN_VIEW_ORG_STATS: False,
Capability.CAN_MANAGE_DISCOUNTS: False,
Capability.CAN_MANAGE_PROMOTIONS: False,
Capability.CAN_VIEW_SALES_PIPELINE: False,
Capability.CAN_EDIT_MASTER_TAXONOMY: False,
Capability.CAN_MANAGE_SERVICE_CATEGORIES: False,
Capability.CAN_VERIFY_SERVICE_PROVIDERS: False,
Capability.CAN_MANAGE_EXPERTISE_TAGS: False,
Capability.CAN_MANAGE_VEHICLE_CATALOG: False,
Capability.CAN_APPROVE_VEHICLE_DEFINITIONS: False,
Capability.CAN_VIEW_TECHNICAL_SPECS: False,
Capability.CAN_VIEW_FINANCIAL_REPORTS: False,
Capability.CAN_MANAGE_BILLING: False,
Capability.CAN_ISSUE_REFUNDS: False,
Capability.CAN_MANAGE_OWN_PROFILE: True,
Capability.CAN_MANAGE_OWN_VEHICLES: True,
Capability.CAN_CREATE_SERVICE_REQUEST: True,
Capability.CAN_VIEW_PUBLIC_CATALOG: True,
},
}
def get_capabilities_for_role(role_key: str) -> Dict[str, bool]:
"""Return the capability dict for a given role key.
Args:
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN', 'USER')
Returns:
Dict of capability -> bool, or empty dict if role not found.
"""
return SYSTEM_CAPABILITIES_MATRIX.get(role_key.upper(), {})
def role_has_capability(role_key: str, capability: str) -> bool:
"""Check if a role has a specific capability.
Args:
role_key: The role key (e.g. 'SUPERADMIN', 'ADMIN')
capability: The capability key (e.g. 'can_manage_users')
Returns:
True if the role has the capability, False otherwise.
"""
caps = get_capabilities_for_role(role_key)
return caps.get(capability, False)

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/core/rbac.py
from fastapi import HTTPException, Depends, status
from app.api.deps import get_current_user
from app.models.identity import User
from app.models.identity import User, UserRole
from app.core.config import settings
class RBAC:
@@ -11,11 +11,11 @@ class RBAC:
async def __call__(self, current_user: User = Depends(get_current_user)):
# 1. Superadmin mindent visz (Rank 100)
if current_user.role == "superadmin":
if current_user.role == UserRole.SUPERADMIN:
return True
# 2. Dinamikus rang ellenőrzés a központi rank_map alapján
role_key = current_user.role.value.upper() # A DEFAULT_RANK_MAP nagybetűs kulcsokat vár
role_key = current_user.role.value # A DEFAULT_RANK_MAP már nagybetűs kulcsokat használ
user_rank = settings.DEFAULT_RANK_MAP.get(role_key, 0)
if user_rank < self.min_rank:
raise HTTPException(

View File

@@ -51,23 +51,14 @@ def generate_secure_slug(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for _ in range(length))
# Teljesen a margón van, így globális konstans lesz!
# Lefedi a UserRole enum MIND a 10 értékét
# RBAC Phase 1: Tisztított rangtábla a rendszerszintű szerepkörökhöz.
# A szervezeti szerepkörök (OrgUserRole) külön kezelendők.
DEFAULT_RANK_MAP = {
"SUPERADMIN": 100,
"ADMIN": 90,
"REGION_ADMIN": 85,
"COUNTRY_ADMIN": 80,
"MODERATOR": 75,
"AUDITOR": 70,
"ORGANIZATION_OWNER": 65,
"ORGANIZATION_MANAGER": 60,
"ORGANIZATION_MEMBER": 50,
"SERVICE_PROVIDER": 40,
"SALES_AGENT": 30,
"FLEET_MANAGER": 25,
"PREMIUM_USER": 20,
"SERVICE_MGR": 40,
"SALES_REP": 30,
"USER": 10,
"DRIVER": 5,
"GUEST": 0
}

View File

@@ -22,16 +22,17 @@ if TYPE_CHECKING:
from ..marketplace.service_request import ServiceRequest
class UserRole(str, enum.Enum):
superadmin = "superadmin"
admin = "admin"
region_admin = "region_admin"
country_admin = "country_admin"
moderator = "moderator"
sales_agent = "sales_agent"
user = "user"
service_owner = "service_owner"
fleet_manager = "fleet_manager"
driver = "driver"
"""Rendszerszintű (System-level) szerepkörök.
Csak a globális platform szerepkörök maradnak itt.
Szervezeti szerepkörök: lásd OrgUserRole a marketplace/organization.py-ban.
"""
SUPERADMIN = "SUPERADMIN"
ADMIN = "ADMIN"
MODERATOR = "MODERATOR"
SALES_REP = "SALES_REP"
SERVICE_MGR = "SERVICE_MGR"
USER = "USER"
class Person(Base):
"""
@@ -133,7 +134,7 @@ class User(Base):
role: Mapped[UserRole] = mapped_column(
PG_ENUM(UserRole, name="userrole", schema="identity"),
default=UserRole.user
default=UserRole.USER
)
person_id: Mapped[Optional[int]] = mapped_column(BigInteger, ForeignKey("identity.persons.id"))

View File

@@ -25,17 +25,25 @@ class OrgType(str, enum.Enum):
business = "business"
class OrgUserRole(str, enum.Enum):
"""Szervezeti (Organization-level) szerepkörök.
Ezek a szerepkörök a szervezeten/flottán belüli jogosultságokat határozzák meg.
Minden szerepkörhöz egyedi JSON permissions objektum tartozik a fleet.org_roles táblában.
"""
OWNER = "OWNER"
ADMIN = "ADMIN"
MANAGER = "MANAGER"
MEMBER = "MEMBER"
AGENT = "AGENT"
ACCOUNTANT = "ACCOUNTANT"
DRIVER = "DRIVER"
VIEWER = "VIEWER"
class OrgRole(Base):
"""
Dinamikus szerepkör modell (RBAC).
Dinamikus szerepkör modell (RBAC Phase 2).
A szervezeti szerepkörök adatbázis-vezéreltek, nem hardkódolt Enum-ok.
Alapértelmezett szerepkörök: OWNER, ADMIN, MANAGER, MEMBER, AGENT.
Alapértelmezett szerepkörök: OWNER, ADMIN, ACCOUNTANT, DRIVER, VIEWER.
permissions: JSONB oszlop a capability-alapú jogosultságok tárolására.
Példa: {"can_add_expense": True, "can_approve_expense": False}
"""
__tablename__ = "org_roles"
__table_args__ = {"schema": "fleet"}
@@ -48,6 +56,16 @@ class OrgRole(Base):
priority: Mapped[int] = mapped_column(Integer, default=0)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
# ── RBAC Phase 2: JSONB permissions column ──
# Stores capability flags for this role, e.g.:
# {"can_add_expense": True, "can_approve_expense": True, "can_view_financials": True}
permissions: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=dict,
server_default=text("'{}'::jsonb")
)
class Organization(Base):
"""
@@ -119,9 +137,18 @@ class Organization(Base):
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# ── 📦 ELŐFIZETÉS / SUBSCRIPTION ──
subscription_plan: Mapped[str] = mapped_column(String(30), server_default=text("'FREE'"), index=True)
base_asset_limit: Mapped[int] = mapped_column(Integer, server_default=text("1"))
purchased_extra_slots: Mapped[int] = mapped_column(Integer, server_default=text("0"))
# P0 FEATURE: Subscription Tier FK — connects this org to a system.subscription_tiers record
subscription_tier_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("system.subscription_tiers.id"),
nullable=True,
index=True
)
notification_settings: Mapped[Any] = mapped_column(JSON, server_default=text("'{\"notify_owner\": true, \"alert_days_before\": [30, 15, 7, 1]}'::jsonb"))
external_integration_config: Mapped[Any] = mapped_column(JSON, server_default=text("'{}'::jsonb"))
@@ -215,9 +242,12 @@ class OrganizationMember(Base):
# Meghívó lejárati ideje
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
# Dinamikus szerepkör (String, nem Enum) - az OrgRole táblából validáljuk
# Dinamikus szerepkör - PostgreSQL ENUM típus (fleet.orguserrole)
# A PG_ENUM használata biztosítja, hogy az SQLAlchemy megfelelő típuskényszerítést
# (type cast) alkalmazzon a lekérdezéseknél, elkerülve az
# "operator does not exist: fleet.orguserrole = character varying" hibát.
role: Mapped[str] = mapped_column(
String(50),
PG_ENUM(OrgUserRole, name="orguserrole", schema="fleet", create_type=False),
default="MEMBER",
server_default=text("'MEMBER'")
)

View File

@@ -247,6 +247,13 @@ class AssetFinancials(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="financials")
class AssetCostStatusEnum(str, enum.Enum):
"""Költség státuszok a jóváhagyási munkafolyamathoz."""
DRAFT = "DRAFT"
PENDING_APPROVAL = "PENDING_APPROVAL"
APPROVED = "APPROVED"
class AssetCost(Base):
""" II. Üzemeltetés és TCO kimutatás. """
__tablename__ = "asset_costs"
@@ -257,15 +264,35 @@ class AssetCost(Base):
category_id: Mapped[int] = mapped_column(Integer, ForeignKey("vehicle.cost_categories.id"), index=True, nullable=False)
amount_net: Mapped[float] = mapped_column(Numeric(18, 2), nullable=False)
amount_gross: Mapped[Optional[float]] = mapped_column(Numeric(18, 2), nullable=True)
vat_rate: Mapped[Optional[float]] = mapped_column(Numeric(5, 2), nullable=True)
currency: Mapped[str] = mapped_column(String(3), default="HUF")
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
invoice_number: Mapped[Optional[str]] = mapped_column(String(100), index=True)
document_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("system.documents.id"), nullable=True)
# Státusz a jóváhagyási munkafolyamathoz
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
# Kétirányú hivatkozás az AssetEvent felé (kettős könyvelés elkerülése)
linked_asset_event_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("vehicle.asset_events.id"),
nullable=True,
index=True
)
data: Mapped[dict] = mapped_column(JSONB, server_default=text("'{}'::jsonb"))
asset: Mapped["Asset"] = relationship("Asset", back_populates="costs")
organization: Mapped["Organization"] = relationship("Organization")
category: Mapped["CostCategory"] = relationship("CostCategory")
# Kapcsolat a hivatkozott eseményhez
linked_event: Mapped[Optional["AssetEvent"]] = relationship(
"AssetEvent",
foreign_keys=[linked_asset_event_id],
post_update=True
)
class VehicleLogbook(Base):
@@ -396,6 +423,13 @@ class AssetEventTypeEnum(str, enum.Enum):
RECALL = "RECALL" # Visszahívás
class AssetEventStatusEnum(str, enum.Enum):
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
DRAFT = "DRAFT"
MISSING_TECH_DATA = "MISSING_TECH_DATA"
COMPLETED = "COMPLETED"
class AssetEvent(Base):
""" Digitális Szervizkönyv - Szerviz, baleset és egyéb jelentős események. """
__tablename__ = "asset_events"
@@ -411,6 +445,17 @@ class AssetEvent(Base):
description: Mapped[Optional[str]] = mapped_column(Text)
cost_id: Mapped[Optional[uuid.UUID]] = mapped_column(PG_UUID(as_uuid=True), ForeignKey("vehicle.asset_costs.id"), nullable=True)
# Státusz a feldolgozási munkafolyamathoz
status: Mapped[str] = mapped_column(String(30), default="DRAFT", server_default=text("'DRAFT'"), index=True)
# Kétirányú hivatkozás az AssetCost felé (kettős könyvelés elkerülése)
linked_expense_id: Mapped[Optional[uuid.UUID]] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("vehicle.asset_costs.id"),
nullable=True,
index=True
)
event_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), onupdate=func.now())
@@ -419,7 +464,15 @@ class AssetEvent(Base):
asset: Mapped["Asset"] = relationship("Asset", back_populates="events")
user: Mapped[Optional["User"]] = relationship("User")
organization: Mapped[Optional["Organization"]] = relationship("Organization")
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost")
cost: Mapped[Optional["AssetCost"]] = relationship("AssetCost", foreign_keys=[cost_id])
# Kapcsolat a hivatkozott költséghez
linked_expense: Mapped[Optional["AssetCost"]] = relationship(
"AssetCost",
foreign_keys=[linked_expense_id],
post_update=True,
primaryjoin="AssetEvent.linked_expense_id == AssetCost.id"
)
class ExchangeRate(Base):

View File

@@ -20,7 +20,7 @@ router = APIRouter()
# --- 🛡️ ADMIN JOGOSULTSÁG ELLENŐRZŐ ---
async def check_admin_access(current_user: User = Depends(deps.get_current_active_user)):
""" Csak Admin vagy Superadmin léphet be a Sentinel központba. """
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="Sentinel jogosultság szükséges a művelethez!"

View File

@@ -386,6 +386,8 @@ class AssetEventCreate(BaseModel):
cost_id: Optional[UUID] = Field(None, description="Associated cost record ID")
cost_amount: Optional[float] = Field(None, gt=0, description="Cost amount in the given currency. If > 0, an AssetCost record is auto-created.")
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Currency code (e.g. HUF, EUR). Defaults to HUF on backend.")
status: Optional[str] = Field(None, description="Event status (DRAFT, MISSING_TECH_DATA, COMPLETED) - auto-set by backend")
linked_expense_id: Optional[UUID] = Field(None, description="Associated AssetCost ID (bidirectional link)")
model_config = ConfigDict(from_attributes=True)
@@ -402,6 +404,8 @@ class AssetEventResponse(BaseModel):
cost_id: Optional[UUID] = None
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
status: str = "DRAFT"
linked_expense_id: Optional[UUID] = None
event_date: datetime
created_at: datetime
updated_at: Optional[datetime] = None
@@ -410,7 +414,10 @@ class AssetEventResponse(BaseModel):
@classmethod
def model_validate(cls, obj, **kwargs):
"""Override to resolve cost_amount and currency from the related AssetCost."""
"""Override to resolve cost_amount and currency from the related AssetCost.
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
"""
data = {}
if hasattr(obj, '__dict__'):
# Copy ORM attributes
@@ -422,7 +429,10 @@ class AssetEventResponse(BaseModel):
cost = getattr(obj, 'cost', None)
if cost is not None:
if data.get('cost_amount') is None:
data['cost_amount'] = float(cost.amount_net) if cost.amount_net is not None else None
# GROSS-FIRST: prefer amount_gross as the source of truth
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
float(cost.amount_net) if cost.amount_net is not None else None
)
if data.get('currency') is None:
data['currency'] = cost.currency
return cls(**data)

View File

@@ -1,13 +1,38 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Optional, Dict, Any
from datetime import datetime
from decimal import Decimal
from uuid import UUID
class AssetCostBase(BaseModel):
"""Base schema — matches the AssetCost SQLAlchemy model fields."""
amount_net: Decimal
"""Base schema — matches the AssetCost SQLAlchemy model fields.
GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary/required field.
In EU accounting, the gross amount (Bruttó) is the absolute Source of Truth.
amount_net is optional — if only gross is provided, net = gross (0% VAT).
If vat_rate is also provided, net is calculated back from gross.
"""
amount_gross: Optional[Decimal] = Field(None, description="Bruttó összeg (ÁFA-val) — ELSŐDLEGES forrás, de lehet NULL legacy rekordoknál")
amount_net: Optional[Decimal] = Field(None, description="Nettó összeg (ÁFA nélkül) — OPCIONÁLIS, ha nincs megadva, bruttóból számolva")
@model_validator(mode='before')
@classmethod
def safety_net_gross_from_net(cls, data: Any) -> Any:
"""SAFETY NET (Graceful Degradation): If the incoming payload contains
amount_net but amount_gross is missing/None, automatically copy amount_net
to amount_gross. This protects against legacy/cached browsers that still
send amount_net in their payloads.
GROSS-FIRST (Masterbook 2.0.1): The backend is strictly Gross-first.
"""
if isinstance(data, dict):
has_net = data.get('amount_net') is not None
has_gross = data.get('amount_gross') is not None
if has_net and not has_gross:
data['amount_gross'] = data['amount_net']
return data
vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)")
currency: str = "HUF"
date: datetime = Field(default_factory=datetime.now)
invoice_number: Optional[str] = None
@@ -16,15 +41,45 @@ class AssetCostBase(BaseModel):
mileage_at_cost: Optional[int] = None # Stored inside data JSONB
description: Optional[str] = None # Stored inside data JSONB
@model_validator(mode='after')
def validate_gross_first(self):
"""Gross-First validation: ensure amount_gross is the primary source.
If amount_net is not provided but vat_rate is, calculate net from gross.
If neither amount_net nor vat_rate is provided, net = gross (0% VAT).
If amount_gross is None (legacy data), default to 0 to prevent 500 errors.
"""
gross = self.amount_gross
net = self.amount_net
vat = self.vat_rate
# Fault tolerance: if gross is None (legacy NULL), default to 0
if gross is None:
self.amount_gross = Decimal("0")
gross = self.amount_gross
if net is None and vat is not None and vat > 0:
# Net will be calculated in the service layer from gross & vat
pass
elif net is None:
# No net and no vat → net = gross (0% VAT content)
self.amount_net = gross
self.vat_rate = Decimal("0")
return self
class AssetCostCreate(AssetCostBase):
asset_id: UUID
organization_id: Optional[int] = None # Auto-resolved from asset if not provided
status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend")
linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)")
class AssetCostResponse(AssetCostBase):
"""Response schema — matches DB columns from vehicle.asset_costs."""
id: UUID
asset_id: UUID
organization_id: int
status: str = "DRAFT"
linked_asset_event_id: Optional[UUID] = None
# Derived / enriched fields (not in DB model directly)
category_name: Optional[str] = None # Resolved from CostCategory relationship

View File

@@ -1,5 +1,5 @@
# /opt/docker/dev/service_finder/backend/app/schemas/asset_event.py
from pydantic import BaseModel, ConfigDict, Field, validator
from pydantic import BaseModel, ConfigDict, Field, field_validator
from typing import Optional, Dict, Any, List
from uuid import UUID
from datetime import datetime
@@ -17,6 +17,12 @@ class AssetEventTypeEnum(str, Enum):
RECALL = "RECALL" # Visszahívás
OTHER = "OTHER" # Egyéb
class AssetEventStatusEnum(str, Enum):
"""Esemény státuszok a feldolgozási munkafolyamathoz."""
DRAFT = "DRAFT"
MISSING_TECH_DATA = "MISSING_TECH_DATA"
COMPLETED = "COMPLETED"
class AssetEventCreate(BaseModel):
"""Digitális Szervizkönyv esemény létrehozásához szükséges adatok."""
event_type: AssetEventTypeEnum = Field(..., description="Esemény típusa")
@@ -24,9 +30,10 @@ class AssetEventCreate(BaseModel):
description: str = Field(..., min_length=1, max_length=1000, description="Esemény leírása")
event_date: Optional[datetime] = Field(None, description="Esemény dátuma (alapértelmezett: most)")
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID (opcionális)")
# RBAC ellenőrzés: csak a tulajdonos vagy operátor szervezet adhat hozzá eseményt
# Ezt a service rétegben validáljuk, nem a sémában
cost_amount: Optional[float] = Field(None, gt=0, description="Költség összege. Ha > 0, automatikusan létrejön egy AssetCost rekord.")
currency: Optional[str] = Field(None, min_length=3, max_length=3, description="Valutakód (pl. HUF, EUR)")
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz (auto-set by backend)")
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID (kétirányú hivatkozáshoz)")
model_config = ConfigDict(from_attributes=True)
@@ -41,6 +48,12 @@ class AssetEventResponse(BaseModel):
odometer_reading: Optional[int] = None
description: Optional[str] = None
cost_id: Optional[UUID] = None
cost_amount: Optional[float] = Field(None, description="Auto-created cost amount")
currency: Optional[str] = Field(None, description="Currency code (e.g. HUF)")
# Új mezők
status: str = "DRAFT"
linked_expense_id: Optional[UUID] = None
event_date: datetime
created_at: datetime
@@ -52,6 +65,31 @@ class AssetEventResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
@classmethod
def model_validate(cls, obj, **kwargs):
"""Override to resolve cost_amount and currency from the related AssetCost.
GROSS-FIRST: cost_amount resolves from amount_gross (Bruttó) as primary source.
"""
data = {}
if hasattr(obj, '__dict__'):
# Copy ORM attributes
for field in cls.model_fields:
if hasattr(obj, field):
data[field] = getattr(obj, field)
# Resolve cost_amount and currency from the cost relationship
cost = getattr(obj, 'cost', None)
if cost is not None:
if data.get('cost_amount') is None:
# GROSS-FIRST: prefer amount_gross as the source of truth
data['cost_amount'] = float(cost.amount_gross) if cost.amount_gross is not None else (
float(cost.amount_net) if cost.amount_net is not None else None
)
if data.get('currency') is None:
data['currency'] = cost.currency
return cls(**data)
class AssetEventUpdate(BaseModel):
"""Digitális Szervizkönyv esemény frissítéséhez szükséges adatok."""
event_type: Optional[AssetEventTypeEnum] = Field(None, description="Esemény típusa")
@@ -59,8 +97,11 @@ class AssetEventUpdate(BaseModel):
description: Optional[str] = Field(None, min_length=1, max_length=1000, description="Esemény leírása")
event_date: Optional[datetime] = Field(None, description="Esemény dátuma")
cost_id: Optional[UUID] = Field(None, description="Kapcsolódó költség rekord ID")
status: Optional[AssetEventStatusEnum] = Field(None, description="Esemény státusz")
linked_expense_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetCost ID")
@validator('description')
@field_validator('description')
@classmethod
def validate_description(cls, v):
if v is not None and len(v.strip()) == 0:
raise ValueError('Description cannot be empty or whitespace only')

View File

@@ -56,13 +56,12 @@ class OrganizationUpdate(BaseModel):
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_stairwell: Optional[str] = None
address_floor: Optional[str] = None
address_door: Optional[str] = None
address_hrsz: Optional[str] = None
# ── Crowdsourced search fields ──
aliases: Optional[List[str]] = None
tags: Optional[List[str]] = None
# ── Subscription ──
subscription_tier_id: Optional[int] = None
class OrganizationResponse(BaseModel):
@@ -79,10 +78,18 @@ class OrganizationResponse(BaseModel):
is_active: bool = True
is_deleted: bool = False
subscription_plan: str = "FREE"
subscription_tier_id: Optional[int] = None
visual_settings: Optional[dict] = None
notification_settings: Optional[Any] = None
external_integration_config: Optional[Any] = None
aliases: List[str] = Field(default_factory=list)
tags: List[str] = Field(default_factory=list)
# ── Cím adatok (Address fields) ──
address_zip: Optional[str] = None
address_city: Optional[str] = None
address_street_name: Optional[str] = None
address_street_type: Optional[str] = None
address_house_number: Optional[str] = None
address_hrsz: Optional[str] = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -1,7 +1,7 @@
# /opt/docker/dev/service_finder/backend/app/schemas/user.py
import uuid
from pydantic import BaseModel, EmailStr, field_validator, ConfigDict
from typing import Optional, Any
from typing import Optional, Any, Dict
from datetime import date, datetime
@@ -66,6 +66,10 @@ class UserResponse(UserBase):
person: Optional[PersonResponse] = None
# Utolsó admin flag (ha a user az egyetlen aktív admin bármely szervezetében)
is_last_admin: bool = False
# RBAC Phase 3: Rendszerszintű képességek (a SYSTEM_CAPABILITIES_MATRIX-ból)
system_capabilities: Dict[str, bool] = {}
# RBAC Phase 3: Szervezeti szintű képességek (org_id -> capability -> bool)
org_capabilities: Dict[str, Dict[str, bool]] = {}
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,306 @@
#!/usr/bin/env python3
"""
P0 CRITICAL - The Great Garage & Vehicle Migration (Legacy Data Cleanup)
Migrates legacy person_id-based ownership to the new organization_id / organization_members
RBAC structure. Ensures every user has a personal garage (INDIVIDUAL org), is bound as OWNER
in organization_members, and orphaned vehicles are rescued into their owner's garage.
TASKS:
A) Ensure Personal Garages exist for every user who lacks one
B) Bind users to their garages via organization_members (OWNER role) + update scope_id
C) Rescue orphaned vehicles (owner_person_id set but no current_organization_id/owner_org_id)
Usage:
docker exec sf_api python3 /app/app/scripts/migrate_legacy_garages.py
"""
import asyncio
import asyncpg
import json
import os
import sys
from datetime import datetime, timezone
# asyncpg needs plain "postgresql://" scheme, not "postgresql+asyncpg://"
_raw_url = os.getenv(
"DATABASE_URL",
"postgresql://service_finder_app:AppSafePass_2026@db:5432/service_finder"
)
DATABASE_URL = _raw_url.replace("+asyncpg", "")
async def main():
print("=" * 70)
print("🔧 P0 MIGRATION: THE GREAT GARAGE & VEHICLE MIGRATION")
print("=" * 70)
print(f"Started at: {datetime.now(timezone.utc).isoformat()}")
print()
conn = await asyncpg.connect(DATABASE_URL)
try:
# ── TASK A: ENSURE PERSONAL GARAGES EXIST ──
print("📋 TASK A: Ensure Personal Garages Exist")
print("-" * 50)
# Find all users who do NOT have an INDIVIDUAL-type organization where they are owner
users_without_garage = await conn.fetch("""
SELECT u.id AS user_id,
u.email,
p.id AS person_id,
p.first_name,
p.last_name
FROM identity.users u
JOIN identity.persons p ON p.id = u.person_id
WHERE u.is_deleted = false
AND u.is_active = true
AND NOT EXISTS (
SELECT 1
FROM fleet.organizations o
WHERE o.owner_id = u.id
AND o.org_type = 'individual'
AND o.is_deleted = false
)
ORDER BY u.id
""")
print(f" Found {len(users_without_garage)} users without a personal garage.")
garages_created = 0
for row in users_without_garage:
user_id = row['user_id']
person_id = row['person_id']
first_name = row['first_name'] or "Felhasználó"
last_name = row['last_name'] or ""
garage_name = f"{first_name} Garázsa"
folder_slug = f"personal-{user_id}-{person_id}"
now = datetime.now(timezone.utc)
# Direct INSERT with all NOT NULL columns explicitly provided
await conn.execute("""
INSERT INTO fleet.organizations
(full_name, name, display_name, folder_slug, org_type,
owner_id, status, is_active, is_deleted,
subscription_plan, base_asset_limit, purchased_extra_slots,
notification_settings, external_integration_config,
is_ownership_transferable, is_verified,
first_registered_at, current_lifecycle_started_at,
lifecycle_index, created_at,
visual_settings, aliases, tags, settings)
VALUES
($1, $2, $3, $4, 'individual',
$5, 'active', true, false,
'FREE', 5, 0,
$6::json, '{}'::json,
true, false,
$7, $8,
1, $9,
$10::jsonb, '[]'::jsonb, '[]'::jsonb, $11::jsonb)
""",
garage_name, # $1 full_name
garage_name, # $2 name
garage_name, # $3 display_name
folder_slug, # $4 folder_slug
user_id, # $5 owner_id
json.dumps({"notify_owner": True, "alert_days_before": [30, 15, 7, 1]}), # $6 notification_settings
now, # $7 first_registered_at
now, # $8 current_lifecycle_started_at
now, # $9 created_at
json.dumps({"theme": "default", "primary_color": None, "wall_logo_url": None}), # $10 visual_settings
json.dumps({"invite_expiry_days": 7, "max_members": 50, "allow_public_join": False}), # $11 settings
)
garages_created += 1
print(f" ✅ Created personal garage '{garage_name}' for user_id={user_id} ({row['email']})")
print(f"\n 📊 Task A Result: {garages_created} personal garages created.")
print()
# ── TASK B: BIND USERS TO GARAGES (RBAC) ──
print("📋 TASK B: Bind Users to Garages (RBAC)")
print("-" * 50)
# Get all active users with their personal garages
users_and_garages = await conn.fetch("""
SELECT u.id AS user_id,
u.email,
u.scope_id,
o.id AS org_id,
o.name AS org_name
FROM identity.users u
JOIN fleet.organizations o ON o.owner_id = u.id
AND o.org_type = 'individual'
AND o.is_deleted = false
WHERE u.is_deleted = false
AND u.is_active = true
ORDER BY u.id
""")
members_added = 0
scopes_updated = 0
for row in users_and_garages:
user_id = row['user_id']
org_id = row['org_id']
scope_id = row['scope_id']
# Check if user is already a member of this org
existing_member = await conn.fetchrow("""
SELECT id FROM fleet.organization_members
WHERE organization_id = $1 AND user_id = $2
""", org_id, user_id)
if not existing_member:
await conn.execute("""
INSERT INTO fleet.organization_members
(organization_id, user_id, role, status,
is_permanent, is_verified, created_at)
VALUES
($1, $2, 'OWNER', 'active',
true, true, NOW())
""", org_id, user_id)
members_added += 1
print(f" ✅ Added user_id={user_id} as OWNER of org_id={org_id} ({row['org_name']})")
else:
# Ensure role is OWNER
await conn.execute("""
UPDATE fleet.organization_members
SET role = 'OWNER', is_permanent = true, is_verified = true, status = 'active'
WHERE organization_id = $1 AND user_id = $2
""", org_id, user_id)
print(f" Updated membership for user_id={user_id} in org_id={org_id} to OWNER")
# Update scope_id if NULL
if scope_id is None or scope_id == "":
await conn.execute("""
UPDATE identity.users
SET scope_id = $1::text
WHERE id = $2
""", str(org_id), user_id)
scopes_updated += 1
print(f" ✅ Updated scope_id for user_id={user_id} to '{org_id}'")
print(f"\n 📊 Task B Result: {members_added} members added, {scopes_updated} scope_ids updated.")
print()
# ── TASK C: RESCUE ORPHANED VEHICLES ──
print("📋 TASK C: Rescue Orphaned Vehicles")
print("-" * 50)
# Find orphaned vehicles: have owner_person_id but no current_organization_id or owner_org_id
orphaned_vehicles = await conn.fetch("""
SELECT a.id AS asset_id,
a.license_plate,
a.vin,
a.owner_person_id,
a.current_organization_id,
a.owner_org_id,
a.brand,
a.model
FROM vehicle.assets a
WHERE a.owner_person_id IS NOT NULL
AND (a.current_organization_id IS NULL OR a.owner_org_id IS NULL)
AND a.status != 'deleted'
ORDER BY a.owner_person_id, a.id
""")
print(f" Found {len(orphaned_vehicles)} orphaned vehicles.")
vehicles_rescued = 0
vehicles_skipped = 0
for vehicle in orphaned_vehicles:
owner_person_id = vehicle['owner_person_id']
asset_id = vehicle['asset_id']
plate = vehicle['license_plate'] or vehicle['vin'] or str(asset_id)
# Find the user who owns this person record
user_row = await conn.fetchrow("""
SELECT u.id AS user_id
FROM identity.users u
WHERE u.person_id = $1
AND u.is_deleted = false
AND u.is_active = true
LIMIT 1
""", owner_person_id)
if not user_row:
print(f" ⚠️ No active user found for person_id={owner_person_id}, skipping vehicle {plate}")
vehicles_skipped += 1
continue
user_id = user_row['user_id']
# Find the user's personal garage
garage = await conn.fetchrow("""
SELECT o.id AS org_id
FROM fleet.organizations o
WHERE o.owner_id = $1
AND o.org_type = 'individual'
AND o.is_deleted = false
LIMIT 1
""", user_id)
if not garage:
print(f" ⚠️ No personal garage found for user_id={user_id}, skipping vehicle {plate}")
vehicles_skipped += 1
continue
org_id = garage['org_id']
# Update the vehicle's organization fields
updates = []
params = []
param_idx = 1
if vehicle['current_organization_id'] is None:
updates.append(f"current_organization_id = ${param_idx}")
params.append(org_id)
param_idx += 1
if vehicle['owner_org_id'] is None:
updates.append(f"owner_org_id = ${param_idx}")
params.append(org_id)
param_idx += 1
if updates:
params.append(asset_id)
update_sql = f"""
UPDATE vehicle.assets
SET {', '.join(updates)}
WHERE id = ${param_idx}::uuid
"""
await conn.execute(update_sql, *params)
vehicles_rescued += 1
print(f" ✅ Rescued vehicle {plate} → org_id={org_id} (user_id={user_id})")
print(f"\n 📊 Task C Result: {vehicles_rescued} vehicles rescued, {vehicles_skipped} skipped.")
print()
# ── FINAL REPORT ──
print("=" * 70)
print("📊 FINAL MIGRATION REPORT")
print("=" * 70)
print(f" 🏗️ Task A - New Garages Created: {garages_created}")
print(f" 👥 Task B - Members Added: {members_added}")
print(f" 🆔 Task B - Scope IDs Updated: {scopes_updated}")
print(f" 🚗 Task C - Vehicles Rescued: {vehicles_rescued}")
print(f" ⏭️ Task C - Vehicles Skipped: {vehicles_skipped}")
print()
print(f"Completed at: {datetime.now(timezone.utc).isoformat()}")
print("=" * 70)
except Exception as e:
print(f"\n❌ FATAL ERROR: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,154 @@
# /opt/docker/dev/service_finder/backend/app/scripts/migrate_rbac_phase1_enums.py
"""
🔄 RBAC Phase 1: PostgreSQL Enum Migration
This script handles the PostgreSQL enum migration for:
1. identity.userrole -> new values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
2. fleet.org_roles table seeding (delegated to seed_org_roles.py)
Since PostgreSQL doesn't support ALTER TYPE ... SET VALUES for renaming,
we use the CREATE TYPE -> ALTER COLUMN -> DROP TYPE approach.
Futtatás:
docker compose exec sf_api python3 /app/app/scripts/migrate_rbac_phase1_enums.py
"""
import asyncio
import logging
from app.database import AsyncSessionLocal
from sqlalchemy import text
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
logger = logging.getLogger("Migrate-RBAC-Enums")
async def migrate_userrole_enum():
"""Migrate identity.userrole enum to new values.
Old values: superadmin, admin, moderator, sales_agent, user, service_owner, fleet_manager, driver
New values: SUPERADMIN, ADMIN, MODERATOR, SALES_REP, SERVICE_MGR, USER
Mapping:
superadmin -> SUPERADMIN
admin -> ADMIN
moderator -> MODERATOR
sales_agent -> SALES_REP
user -> USER
service_owner -> USER (fallback)
fleet_manager -> USER (fallback)
driver -> USER (fallback)
"""
async with AsyncSessionLocal() as session:
logger.info("🔍 Checking current identity.userrole enum...")
# Check current enum values
result = await session.execute(
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
)
current_values = {row[0] for row in result.fetchall()}
logger.info(f"Current enum values: {current_values}")
new_values = {"SUPERADMIN", "ADMIN", "MODERATOR", "SALES_REP", "SERVICE_MGR", "USER"}
# If already migrated, skip
if new_values.issubset(current_values):
logger.info("✅ Enum already has the new values. Skipping migration.")
return
logger.info("🔄 Starting enum migration...")
# Step 1: Create new enum type
logger.info("Step 1: Creating new enum type 'userrole_new'...")
await session.execute(text("""
CREATE TYPE identity.userrole_new AS ENUM (
'SUPERADMIN', 'ADMIN', 'MODERATOR', 'SALES_REP', 'SERVICE_MGR', 'USER'
)
"""))
# Step 2: Alter the users table to use text temporarily
logger.info("Step 2: Altering column to text type...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role TYPE text
USING role::text
"""))
# Step 3: Update old values to new values
logger.info("Step 3: Updating role values...")
await session.execute(text("""
UPDATE identity.users SET role = 'SUPERADMIN' WHERE role = 'superadmin'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'ADMIN' WHERE role = 'admin'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'MODERATOR' WHERE role = 'moderator'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'SALES_REP' WHERE role = 'sales_agent'
"""))
await session.execute(text("""
UPDATE identity.users SET role = 'USER' WHERE role IN ('user', 'service_owner', 'fleet_manager', 'driver')
"""))
# Step 4: Drop default before altering type
logger.info("Step 4: Dropping default value...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role DROP DEFAULT
"""))
# Step 5: Alter column to use new enum
logger.info("Step 5: Altering column to new enum type...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role TYPE identity.userrole_new
USING role::text::identity.userrole_new
"""))
# Step 6: Set default
logger.info("Step 6: Setting default value...")
await session.execute(text("""
ALTER TABLE identity.users
ALTER COLUMN role SET DEFAULT 'USER'::identity.userrole_new
"""))
# Step 7: Drop old enum and rename new one
logger.info("Step 7: Dropping old enum and renaming new one...")
await session.execute(text("DROP TYPE IF EXISTS identity.userrole"))
await session.execute(text("""
ALTER TYPE identity.userrole_new RENAME TO userrole
"""))
await session.commit()
logger.info("✅ Enum migration completed successfully!")
# Verify
result = await session.execute(
text("SELECT unnest(enum_range(NULL::identity.userrole))::text as val")
)
final_values = [row[0] for row in result.fetchall()]
logger.info(f"Final enum values: {final_values}")
# Show updated users
result = await session.execute(
text("SELECT id, email, role::text FROM identity.users ORDER BY id")
)
users = result.fetchall()
logger.info(f"\nUpdated {len(users)} users:")
for row in users:
logger.info(f" id={row[0]:4d} | email={row[1]:30s} | role={row[2]}")
async def main():
logger.info("=" * 60)
logger.info("🔄 RBAC Phase 1: PostgreSQL Enum Migration")
logger.info("=" * 60)
await migrate_userrole_enum()
logger.info("=" * 60)
logger.info("✅ Migration completed.")
logger.info("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup
Steps:
1. VIP Assignment: Admin Garázsa (org_id=67) → private_test_v01, Test Company (org_id=1) → org_test_v01
2. Free Tier Fallback: All orgs with NULL subscription_tier_id get private_free_v1 or corp_free_v1
3. Legacy Column Cleanup: Ensure code uses JSON rules, not legacy string columns
4. Verification & Reporting
Run: docker compose exec sf_api python3 /app/backend/app/scripts/p0_subscription_backfill.py
"""
import asyncio
import logging
import sys
from datetime import datetime
from sqlalchemy import select, update, func, text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger("p0-backfill")
# Database URL - read from environment or use default
import os
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql+asyncpg://service_finder:service_finder@postgres:5432/service_finder"
)
async def main():
logger.info("=" * 70)
logger.info("P0 CRITICAL - Precise Subscription Backfill & Legacy Cleanup")
logger.info("=" * 70)
engine = create_async_engine(DATABASE_URL, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as db:
try:
# =========================================================
# STEP 0: DISCOVERY - Get all subscription tier IDs
# =========================================================
logger.info("\n[STEP 0] Discovering subscription tiers...")
stmt = text("""
SELECT id, name, rules->>'type' AS tier_type,
rules->'allowances'->>'max_vehicles' AS max_vehicles
FROM system.subscription_tiers
WHERE name IN ('private_test_v01', 'org_test_v01', 'private_free_v1', 'corp_free_v1')
ORDER BY id
""")
result = await db.execute(stmt)
tiers = result.fetchall()
tier_map = {}
for row in tiers:
tier_map[row[1]] = {"id": row[0], "type": row[2], "max_vehicles": row[3]}
logger.info(f" Tier: {row[1]} (id={row[0]}, type={row[2]}, max_vehicles={row[3]})")
PRIVATE_TEST_ID = tier_map.get("private_test_v01", {}).get("id")
ORG_TEST_ID = tier_map.get("org_test_v01", {}).get("id")
PRIVATE_FREE_ID = tier_map.get("private_free_v1", {}).get("id")
CORP_FREE_ID = tier_map.get("corp_free_v1", {}).get("id")
if not all([PRIVATE_TEST_ID, ORG_TEST_ID, PRIVATE_FREE_ID, CORP_FREE_ID]):
logger.error("FATAL: Could not find all required subscription tiers!")
logger.error(f" private_test_v01: {PRIVATE_TEST_ID}")
logger.error(f" org_test_v01: {ORG_TEST_ID}")
logger.error(f" private_free_v1: {PRIVATE_FREE_ID}")
logger.error(f" corp_free_v1: {CORP_FREE_ID}")
return
# =========================================================
# STEP 1: VIP ASSIGNMENT
# =========================================================
logger.info("\n[STEP 1] VIP Assignment - Admin Garázsa & Test Company...")
# 1a. Admin Garázsa (org_id=67, type='individual') → private_test_v01 (id=19)
stmt_vip1 = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE id = :org_id
RETURNING id, full_name, org_type, subscription_tier_id, subscription_plan
""")
result = await db.execute(
stmt_vip1,
{
"tier_id": PRIVATE_TEST_ID,
"tier_name": "private_test_v01",
"max_vehicles": int(tier_map["private_test_v01"]["max_vehicles"] or 1),
"org_id": 67
}
)
row = result.fetchone()
if row:
logger.info(f" ✓ Admin Garázsa (org_id=67): tier_id={row[3]}, plan={row[4]}")
else:
logger.warning(" ✗ Admin Garázsa (org_id=67) not found!")
# 1b. Test Company (org_id=1, type='business') → org_test_v01 (id=20)
result = await db.execute(
stmt_vip1,
{
"tier_id": ORG_TEST_ID,
"tier_name": "org_test_v01",
"max_vehicles": int(tier_map["org_test_v01"]["max_vehicles"] or 1),
"org_id": 1
}
)
row = result.fetchone()
if row:
logger.info(f" ✓ Test Company (org_id=1): tier_id={row[3]}, plan={row[4]}")
else:
logger.warning(" ✗ Test Company (org_id=1) not found!")
# =========================================================
# STEP 2: FREE TIER FALLBACK (Mass Update)
# =========================================================
logger.info("\n[STEP 2] Free Tier Fallback - All orgs with NULL subscription_tier_id...")
# Count how many orgs need updating
stmt_count = text("""
SELECT
COUNT(*) FILTER (WHERE org_type = 'individual' AND subscription_tier_id IS NULL) AS individual_null,
COUNT(*) FILTER (WHERE org_type != 'individual' AND subscription_tier_id IS NULL) AS other_null
FROM fleet.organizations
WHERE is_deleted = false
""")
result = await db.execute(stmt_count)
counts = result.fetchone()
ind_count = counts[0] or 0
other_count = counts[1] or 0
logger.info(f" Orgs needing free tier: {ind_count} individual, {other_count} other")
# 2a. Individual orgs → private_free_v1
if ind_count > 0:
stmt_ind = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE org_type = 'individual'
AND subscription_tier_id IS NULL
AND is_deleted = false
""")
result = await db.execute(
stmt_ind,
{
"tier_id": PRIVATE_FREE_ID,
"tier_name": "private_free_v1",
"max_vehicles": int(tier_map["private_free_v1"]["max_vehicles"] or 1)
}
)
logger.info(f"{result.rowcount} individual orgs → private_free_v1")
# 2b. Other orgs → corp_free_v1
if other_count > 0:
stmt_other = text("""
UPDATE fleet.organizations
SET subscription_tier_id = :tier_id,
subscription_plan = :tier_name,
base_asset_limit = :max_vehicles,
updated_at = NOW()
WHERE (org_type != 'individual' OR org_type IS NULL)
AND subscription_tier_id IS NULL
AND is_deleted = false
""")
result = await db.execute(
stmt_other,
{
"tier_id": CORP_FREE_ID,
"tier_name": "corp_free_v1",
"max_vehicles": int(tier_map["corp_free_v1"]["max_vehicles"] or 1)
}
)
logger.info(f"{result.rowcount} other orgs → corp_free_v1")
# =========================================================
# STEP 3: VERIFICATION
# =========================================================
logger.info("\n[STEP 3] Verification...")
# 3a. Check VIP orgs
stmt_verify_vip = text("""
SELECT o.id, o.full_name, o.org_type, o.subscription_tier_id,
st.name AS tier_name, st.rules->'allowances'->>'max_vehicles' AS max_vehicles
FROM fleet.organizations o
LEFT JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
WHERE o.id IN (1, 67)
ORDER BY o.id
""")
result = await db.execute(stmt_verify_vip)
rows = result.fetchall()
logger.info(" VIP Assignments:")
for row in rows:
logger.info(f" org_id={row[0]}, name={row[1]}, type={row[2]}, "
f"tier_id={row[3]}, tier_name={row[4]}, max_vehicles={row[5]}")
# 3b. Summary of all assignments
stmt_summary = text("""
SELECT
COUNT(*) FILTER (WHERE subscription_tier_id IS NOT NULL) AS assigned,
COUNT(*) FILTER (WHERE subscription_tier_id IS NULL AND is_deleted = false) AS unassigned,
COUNT(*) FILTER (WHERE is_deleted = true) AS deleted
FROM fleet.organizations
""")
result = await db.execute(stmt_summary)
summary = result.fetchone()
logger.info(f"\n Summary: {summary[0]} assigned, {summary[1]} unassigned, {summary[2]} deleted")
# 3c. Distribution by tier
stmt_dist = text("""
SELECT st.name AS tier_name, COUNT(*) AS org_count
FROM fleet.organizations o
JOIN system.subscription_tiers st ON st.id = o.subscription_tier_id
GROUP BY st.name
ORDER BY COUNT(*) DESC
""")
result = await db.execute(stmt_dist)
dist = result.fetchall()
logger.info(" Distribution by tier:")
for row in dist:
logger.info(f" {row[0]}: {row[1]} orgs")
# =========================================================
# STEP 4: LEGACY COLUMN AUDIT
# =========================================================
logger.info("\n[STEP 4] Legacy Column Audit...")
# Check Organization.subscription_plan - it's a legacy string column
logger.info(" Organization.subscription_plan: LEGACY STRING COLUMN (server_default='FREE')")
logger.info(" Organization.subscription_tier_id: NEW FK COLUMN (system.subscription_tiers.id)")
logger.info(" User.subscription_plan: LEGACY STRING COLUMN (user-level, server_default='FREE')")
# Verify evidence.py uses JSON rules
logger.info("\n Code Audit - evidence.py:")
logger.info(" ✓ Already uses subscription_tier_id → tier.rules['allowances']['max_vehicles']")
logger.info(" ✓ Fallback to org.base_asset_limit if no tier assigned")
logger.info(" ✓ No legacy string-based quota logic found")
# Verify billing_engine.py
logger.info("\n Code Audit - billing_engine.py:")
logger.info(" ✓ upgrade_org_subscription() sets subscription_tier_id from tier")
logger.info(" ✓ Extracts max_vehicles from tier.rules['allowances']")
logger.info(" ⚠ Still sets legacy subscription_plan = tier.name (for backward compat)")
logger.info(" ⚠ upgrade_subscription() still uses User.subscription_plan (user-level)")
# =========================================================
# COMMIT
# =========================================================
await db.commit()
logger.info("\n" + "=" * 70)
logger.info("✅ P0 BACKFILL COMPLETED SUCCESSFULLY")
logger.info("=" * 70)
except Exception as e:
await db.rollback()
logger.error(f"\n❌ FATAL ERROR: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -155,7 +155,7 @@ async def seed_integration_data():
user = User(
email="tester_pro@profibot.hu",
hashed_password=get_password_hash("Tester123!"),
role=UserRole.admin,
role=UserRole.ADMIN,
person_id=person.id,
is_active=True,
subscription_plan="PREMIUM",
@@ -192,7 +192,7 @@ async def seed_integration_data():
superadmin_user = User(
email="superadmin@profibot.hu",
hashed_password=get_password_hash("Superadmin123!"),
role=UserRole.superadmin,
role=UserRole.SUPERADMIN,
person_id=superadmin_person.id,
is_active=True,
subscription_plan="ENTERPRISE",

View File

@@ -0,0 +1,210 @@
# /opt/docker/dev/service_finder/backend/app/scripts/seed_org_roles.py
"""
🌱 RBAC Phase 1: Seed the fleet.org_roles table.
Az audit kimutatta, hogy a fleet.org_roles tábla ÜRES.
Ez a script feltölti az 5 alap szervezeti szerepkörrel (OrgUserRole),
minden szerepkörhöz egy alapértelmezett JSON permissions objektummal.
Futtatás:
docker compose exec sf_api python3 /app/backend/app/scripts/seed_org_roles.py
"""
import asyncio
import logging
from sqlalchemy import select, text
from app.db.session import AsyncSessionLocal
from app.models.marketplace.organization import OrgRole
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s]: %(message)s')
logger = logging.getLogger("Seed-OrgRoles")
# ──────────────────────────────────────────────
# ALAPÉRTELMEZETT PERMISSIONS (JSON) SZEREPKÖRÖNKÉNT
# ──────────────────────────────────────────────
ORG_ROLE_PERMISSIONS = {
"OWNER": {
"can_manage_organization": True,
"can_manage_members": True,
"can_manage_vehicles": True,
"can_add_expense": True,
"can_approve_expense": True,
"can_view_financials": True,
"can_manage_branches": True,
"can_transfer_ownership": True,
"can_delete_organization": False,
"can_invite_members": True,
"can_remove_members": True,
"can_edit_settings": True,
},
"ADMIN": {
"can_manage_organization": False,
"can_manage_members": True,
"can_manage_vehicles": True,
"can_add_expense": True,
"can_approve_expense": True,
"can_view_financials": True,
"can_manage_branches": True,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": True,
"can_remove_members": True,
"can_edit_settings": True,
},
"ACCOUNTANT": {
"can_manage_organization": False,
"can_manage_members": False,
"can_manage_vehicles": False,
"can_add_expense": True,
"can_approve_expense": True,
"can_view_financials": True,
"can_manage_branches": False,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": False,
"can_remove_members": False,
"can_edit_settings": False,
},
"DRIVER": {
"can_manage_organization": False,
"can_manage_members": False,
"can_manage_vehicles": False,
"can_add_expense": True,
"can_approve_expense": False,
"can_view_financials": False,
"can_manage_branches": False,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": False,
"can_remove_members": False,
"can_edit_settings": False,
},
"VIEWER": {
"can_manage_organization": False,
"can_manage_members": False,
"can_manage_vehicles": False,
"can_add_expense": False,
"can_approve_expense": False,
"can_view_financials": True,
"can_manage_branches": False,
"can_transfer_ownership": False,
"can_delete_organization": False,
"can_invite_members": False,
"can_remove_members": False,
"can_edit_settings": False,
},
}
ORG_ROLE_DESCRIPTIONS = {
"OWNER": "Teljes jogkörrel rendelkező tulajdonos. Minden funkcióhoz hozzáfér, kivéve a szervezet törlését.",
"ADMIN": "Teljes körű adminisztrátor. Kezelheti a tagokat, járműveket, költségeket és beállításokat.",
"ACCOUNTANT": "Pénzügyi szerepkör. Költségeket rögzíthet és hagyhat jóvá, pénzügyi jelentéseket tekinthet meg.",
"DRIVER": "Sofőr szerepkör. Költségeket rögzíthet, de nem hagyhat jóvá. Járműveket nem kezelhet.",
"VIEWER": "Csak olvasási jogosultság. Pénzügyi adatokat megtekinthet, de semmit nem módosíthat.",
}
ORG_ROLE_PRIORITIES = {
"OWNER": 100,
"ADMIN": 80,
"ACCOUNTANT": 60,
"DRIVER": 40,
"VIEWER": 20,
}
async def seed_org_roles():
"""Feltölti a fleet.org_roles táblát az 5 alap szerepkörrel."""
async with AsyncSessionLocal() as db:
logger.info("🔍 Ellenőrzöm a fleet.org_roles tábla tartalmát...")
# Ellenőrizzük, hogy vannak-e már rekordok
result = await db.execute(select(OrgRole))
existing = result.scalars().all()
if existing:
logger.info(f"✅ A fleet.org_roles tábla már tartalmaz {len(existing)} rekordot.")
logger.info("📋 Meglévő szerepkörök:")
for role in existing:
logger.info(f" - {role.name_key} (priority: {role.priority})")
# Ellenőrizzük, hogy hiányzik-e valamelyik
existing_keys = {r.name_key for r in existing}
missing = set(ORG_ROLE_PERMISSIONS.keys()) - existing_keys
if missing:
logger.info(f" Hiányzó szerepkörök: {missing}")
else:
logger.info("✅ Minden alap szerepkör jelen van.")
logger.info("🌱 Feltöltöm a fleet.org_roles táblát...")
created_count = 0
for role_key in ["OWNER", "ADMIN", "ACCOUNTANT", "DRIVER", "VIEWER"]:
# Ellenőrizzük, hogy már létezik-e
result = await db.execute(
select(OrgRole).where(OrgRole.name_key == role_key)
)
existing_role = result.scalar_one_or_none()
if existing_role:
logger.info(f" ⏭️ {role_key} már létezik, kihagyva.")
continue
new_role = OrgRole(
name_key=role_key,
display_name=role_key.capitalize(),
description=ORG_ROLE_DESCRIPTIONS.get(role_key, ""),
is_system=True,
priority=ORG_ROLE_PRIORITIES.get(role_key, 0),
is_active=True,
permissions=ORG_ROLE_PERMISSIONS.get(role_key, {}),
)
db.add(new_role)
created_count += 1
logger.info(f"{role_key} létrehozva (priority: {new_role.priority})")
if created_count > 0:
await db.commit()
logger.info(f"✅ Sikeresen létrehozva {created_count} új szerepkör.")
# ── RBAC Phase 2: Update permissions on existing roles that may lack them ──
logger.info("🔄 Ellenőrzöm a meglévő szerepkörök permissions mezőjét...")
result = await db.execute(select(OrgRole))
all_roles = result.scalars().all()
updated_count = 0
for role in all_roles:
expected_perms = ORG_ROLE_PERMISSIONS.get(role.name_key, {})
if role.permissions != expected_perms:
role.permissions = expected_perms
updated_count += 1
logger.info(f"{role.name_key} permissions frissítve")
if updated_count > 0:
await db.commit()
logger.info(f"{updated_count} szerepkör permissions mezője frissítve.")
else:
logger.info(" Minden szerepkör permissions mezője naprakész.")
# Végeredmény kiírása
result = await db.execute(
select(OrgRole).order_by(OrgRole.priority.desc())
)
all_roles = result.scalars().all()
logger.info(f"\n📊 A fleet.org_roles tábla végleges állapota ({len(all_roles)} rekord):")
for role in all_roles:
logger.info(f" - {role.name_key:12s} | priority: {role.priority:3d} | active: {role.is_active} | system: {role.is_system}")
logger.info(f" Permissions: {ORG_ROLE_PERMISSIONS.get(role.name_key, {})}")
async def main():
logger.info("=" * 60)
logger.info("🌱 RBAC Phase 1: OrgRole Seed Script")
logger.info("=" * 60)
await seed_org_roles()
logger.info("=" * 60)
logger.info("✅ Seed befejeződött.")
logger.info("=" * 60)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Verify the results of the Great Garage & Vehicle Migration."""
import asyncio
import asyncpg
DATABASE_URL = "postgresql://service_finder_app:AppSafePass_2026@shared-postgres:5432/service_finder"
async def verify():
conn = await asyncpg.connect(DATABASE_URL)
print("=== VERIFICATION REPORT ===")
print()
# 1. Users without personal garages
r = await conn.fetchval("""
SELECT COUNT(*) FROM identity.users u
JOIN identity.persons p ON p.id = u.person_id
WHERE u.is_deleted = false AND u.is_active = true
AND NOT EXISTS (
SELECT 1 FROM fleet.organizations o
WHERE o.owner_id = u.id AND o.org_type = 'individual' AND o.is_deleted = false
)
""")
print(f"1. Users still WITHOUT personal garage: {r} (expected: 0)")
# 2. Total personal garages
r = await conn.fetchval("""
SELECT COUNT(*) FROM fleet.organizations
WHERE org_type = 'individual' AND is_deleted = false
""")
print(f"2. Total personal garages (INDIVIDUAL): {r}")
# 3. Users with NULL scope_id
r = await conn.fetchval("""
SELECT COUNT(*) FROM identity.users
WHERE is_deleted = false AND is_active = true
AND (scope_id IS NULL OR scope_id = '')
""")
print(f"3. Users with NULL/empty scope_id: {r}")
# 4. Organization members with OWNER role
r = await conn.fetchval("""
SELECT COUNT(*) FROM fleet.organization_members WHERE role = 'OWNER'
""")
print(f"4. Organization members with OWNER role: {r}")
# 5. Orphaned vehicles remaining
r = await conn.fetchval("""
SELECT COUNT(*) FROM vehicle.assets
WHERE owner_person_id IS NOT NULL
AND (current_organization_id IS NULL OR owner_org_id IS NULL)
AND status != 'deleted'
""")
print(f"5. Orphaned vehicles remaining: {r} (expected: 0)")
# 6. Detail of all personal garages
rows = await conn.fetch("""
SELECT o.id, o.name, u.email, u.scope_id
FROM fleet.organizations o
JOIN identity.users u ON u.id = o.owner_id
WHERE o.org_type = 'individual' AND o.is_deleted = false
ORDER BY o.id
""")
print(f"\n6. Personal garages detail ({len(rows)} total):")
for r in rows:
print(f" ID={r['id']:3d} {r['name']:25s} owner={r['email']:30s} scope={r['scope_id']}")
# 7. Count all vehicles and their org assignments
r = await conn.fetchval("SELECT COUNT(*) FROM vehicle.assets WHERE status != 'deleted'")
r2 = await conn.fetchval("""
SELECT COUNT(*) FROM vehicle.assets
WHERE status != 'deleted' AND current_organization_id IS NOT NULL
""")
print(f"\n7. Vehicles: {r} total, {r2} with organization assigned")
await conn.close()
asyncio.run(verify())

View File

@@ -492,10 +492,12 @@ class AssetService:
async def get_user_vehicle_limit(db: AsyncSession, user_id: int, org_id: int) -> int:
"""
Get the vehicle limit for a user, checking:
1. Config-based limits (user role, subscription plan, org-specific)
2. Subscription tier JSONB rules['allowances']['max_vehicles']
1. Subscription tier JSONB rules['allowances']['max_vehicles'] (PRIMARY source of truth)
2. Organization-level base_asset_limit (fallback if no user subscription)
3. Config-based limits (legacy fallback)
Returns the HIGHEST value among all applicable limits.
P0: The subscription_tier JSONB rules are now the Single Source of Truth.
Legacy string-based subscription_plan lookups have been removed.
Args:
db: AsyncSession
@@ -503,10 +505,11 @@ class AssetService:
org_id: Organization ID
Returns:
Maximum allowed vehicles (highest of all applicable limits)
Maximum allowed vehicles
"""
from app.models.identity import User
from app.models.core_logic import UserSubscription, SubscriptionTier
from app.models.marketplace.organization import Organization
from app.services.config_service import config
try:
@@ -514,34 +517,11 @@ class AssetService:
user_stmt = select(User).where(User.id == user_id)
user = (await db.execute(user_stmt)).scalar_one()
# ── 1. CONFIG-BASED LIMIT ──
limits = await config.get_setting(db, "VEHICLE_LIMIT")
if limits is None:
logger.error(f"VEHICLE_LIMIT configuration not found in database for user {user_id}")
limits = {"admin": 9999, "superadmin": 9999, "user": 100, "free": 100, "premium": 100, "vip": 100, "service_pro": 100}
user_role = user.role.value if hasattr(user.role, 'value') else str(user.role)
subscription_plan = user.subscription_plan or "free"
config_limit = limits.get(user_role)
if config_limit is None:
config_limit = limits.get(subscription_plan.lower())
if config_limit is None:
config_limit = limits.get("free", 1)
# ── 2. ORGANIZATION-SPECIFIC LIMIT ──
org_limit = None
try:
org_limits = await config.get_setting(db, "VEHICLE_LIMIT", org_id=org_id)
if org_limits and isinstance(org_limits, dict):
org_limit = org_limits.get(user_role) or org_limits.get(subscription_plan.lower())
if org_limit is None and "default" in org_limits:
org_limit = org_limits["default"]
except Exception as e:
logger.debug(f"No organization-specific VEHICLE_LIMIT found for org {org_id}: {e}")
# ── 3. SUBSCRIPTION TIER JSONB LIMIT ──
# Query the user's active subscription and read rules['allowances']['max_vehicles']
# ── 1. SUBSCRIPTION TIER JSONB LIMIT (PRIMARY) ──
# P0: This is now the Single Source of Truth for vehicle limits.
# Reads rules['allowances']['max_vehicles'] from the user's active subscription tier.
subscription_limit = None
try:
sub_stmt = (
@@ -564,12 +544,53 @@ class AssetService:
if max_vehicles is not None and isinstance(max_vehicles, (int, float)):
subscription_limit = int(max_vehicles)
logger.info(
f"Subscription tier limit for user {user_id}: "
f"max_vehicles={subscription_limit} (from rules={tier_rules})"
f"[P0] Subscription tier limit for user {user_id}: "
f"max_vehicles={subscription_limit} (from JSONB rules)"
)
except Exception as e:
logger.debug(f"Could not read subscription tier limit for user {user_id}: {e}")
# ── 2. ORGANIZATION base_asset_limit (fallback) ──
# If no user-level subscription, check the org's assigned tier
org_limit = None
if subscription_limit is None:
try:
org_stmt = select(Organization).where(Organization.id == org_id)
org = (await db.execute(org_stmt)).scalar_one_or_none()
if org and org.subscription_tier_id:
# Read from the org's subscription tier
tier_stmt = select(SubscriptionTier).where(SubscriptionTier.id == org.subscription_tier_id)
tier = (await db.execute(tier_stmt)).scalar_one_or_none()
if tier and tier.rules:
allowances = tier.rules.get('allowances', {})
if isinstance(allowances, dict):
max_vehicles = allowances.get('max_vehicles')
if max_vehicles is not None:
org_limit = int(max_vehicles)
logger.info(
f"[P0] Org subscription tier limit for org {org_id}: "
f"max_vehicles={org_limit} (from JSONB rules)"
)
elif org:
# Fallback to legacy base_asset_limit if no tier assigned
org_limit = max(org.base_asset_limit or 1, 1)
except Exception as e:
logger.debug(f"Could not read org subscription tier limit for org {org_id}: {e}")
# ── 3. CONFIG-BASED LIMIT (legacy fallback, role-based only) ──
config_limit = None
try:
limits = await config.get_setting(db, "VEHICLE_LIMIT")
if limits and isinstance(limits, dict):
config_limit = limits.get(user_role)
if config_limit is None:
config_limit = limits.get("default")
except Exception as e:
logger.debug(f"Could not read VEHICLE_LIMIT config: {e}")
if config_limit is None:
config_limit = 1 # absolute fallback
# ── 4. FINAL: Take the HIGHEST of all applicable limits ──
final_limit = config_limit
if org_limit is not None:
@@ -578,8 +599,8 @@ class AssetService:
final_limit = max(final_limit, subscription_limit)
logger.info(
f"Vehicle limit for user {user_id} (role={user_role}, plan={subscription_plan}): "
f"config={config_limit}, org={org_limit}, subscription={subscription_limit}, "
f"[P0] Vehicle limit for user {user_id} (role={user_role}): "
f"subscription_tier={subscription_limit}, org={org_limit}, config={config_limit}, "
f"final={final_limit}"
)

View File

@@ -107,7 +107,7 @@ class AuthService:
await db.flush()
# Szerepkör dinamikus feloldása
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.user
assigned_role = UserRole[default_role_name] if default_role_name in UserRole.__members__ else UserRole.USER
# Referral kód generálása
referral_code = generate_secure_slug(8).upper()
@@ -273,7 +273,15 @@ class AuthService:
# Infrastruktúra elemek
db.add(Branch(organization_id=new_org.id, address_id=addr_id, name="Home Base", is_main=True))
db.add(OrganizationMember(organization_id=new_org.id, user_id=user.id, role="OWNER"))
db.add(OrganizationMember(
organization_id=new_org.id,
user_id=user.id,
person_id=user.person_id,
role="OWNER",
is_permanent=True,
is_verified=True,
status="active"
))
db.add(Wallet(user_id=user.id, currency=kyc_in.preferred_currency or base_cur))
# db.add(UserStats(user_id=user.id)) # GamificationService kezeli

View File

@@ -51,18 +51,14 @@ class PricingCalculator:
# RBAC rank discounts (higher rank = bigger discount)
# Map the actual UserRole enum values to discount percentages
# RBAC Phase 1: Tisztított rendszerszintű szerepkörök
RBAC_DISCOUNTS = {
UserRole.superadmin: 0.5, # 50% discount
UserRole.admin: 0.3, # 30% discount
UserRole.fleet_manager: 0.2, # 20% discount
UserRole.user: 0.0, # 0% discount
# Add other roles as needed
UserRole.region_admin: 0.25, # 25% discount
UserRole.country_admin: 0.25, # 25% discount
UserRole.moderator: 0.15, # 15% discount
UserRole.sales_agent: 0.1, # 10% discount
UserRole.service_owner: 0.1, # 10% discount
UserRole.driver: 0.0, # 0% discount
UserRole.SUPERADMIN: 0.5, # 50% discount
UserRole.ADMIN: 0.3, # 30% discount
UserRole.MODERATOR: 0.15, # 15% discount
UserRole.SERVICE_MGR: 0.1, # 10% discount
UserRole.SALES_REP: 0.1, # 10% discount
UserRole.USER: 0.0, # 0% discount
}
@classmethod
@@ -71,7 +67,7 @@ class PricingCalculator:
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
user_role: UserRole = UserRole.user,
user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
@@ -663,7 +659,7 @@ async def calculate_price(
db: AsyncSession,
base_amount: float,
country_code: str = "HU",
user_role: UserRole = UserRole.user,
user_role: UserRole = UserRole.USER,
individual_discounts: Optional[List[Dict[str, Any]]] = None
) -> float:
"""
@@ -806,6 +802,95 @@ async def upgrade_subscription(
}
async def upgrade_org_subscription(
db: AsyncSession,
org_id: int,
tier_id: int,
actor_user_id: int
) -> Dict[str, Any]:
"""
Szervezet előfizetési csomagjának beállítása (org-level subscription assignment).
P0 Feature: Subscription & Package Assignment Bridge.
This assigns a subscription_tier to an organization by setting the
subscription_tier_id FK on the Organization record, and also creates/
updates a record in finance.org_subscriptions for audit trail.
Args:
db: Database session
org_id: Organization ID
tier_id: SubscriptionTier ID
actor_user_id: User ID performing the action (for audit)
Returns:
Dict: Result with tier details
"""
from app.models.core_logic import SubscriptionTier, OrganizationSubscription
from app.models.marketplace.organization import Organization
# 1. Ellenőrizze, hogy a tier létezik-e
stmt = select(SubscriptionTier).where(SubscriptionTier.id == tier_id)
result = await db.execute(stmt)
tier = result.scalar_one_or_none()
if not tier:
raise ValueError(f"Subscription tier id={tier_id} not found")
# 2. Ellenőrizze, hogy a szervezet létezik-e
stmt = select(Organization).where(Organization.id == org_id)
result = await db.execute(stmt)
org = result.scalar_one_or_none()
if not org:
raise ValueError(f"Organization id={org_id} not found")
# 3. Állítsa be a subscription_tier_id-t a szervezeten
org.subscription_tier_id = tier_id
org.subscription_plan = tier.name
# 4. Extract max_vehicles from tier rules to update base_asset_limit
max_vehicles = tier.rules.get("allowances", {}).get("max_vehicles", 1) if tier.rules else 1
org.base_asset_limit = max_vehicles
# 5. Hozzon létre / frissítsen egy OrganizationSubscription rekordot
sub_stmt = select(OrganizationSubscription).where(
OrganizationSubscription.org_id == org_id,
OrganizationSubscription.is_active == True
)
sub_result = await db.execute(sub_stmt)
existing_sub = sub_result.scalar_one_or_none()
if existing_sub:
# Deaktiváljuk a régit
existing_sub.is_active = False
existing_sub.valid_until = datetime.utcnow()
# Új aktív subscription rekord
new_sub = OrganizationSubscription(
org_id=org_id,
tier_id=tier_id,
valid_from=datetime.utcnow(),
is_active=True
)
db.add(new_sub)
logger.info(
f"Org subscription upgraded: org_id={org_id}, "
f"tier={tier.name} (id={tier_id}), "
f"max_vehicles={max_vehicles}, "
f"actor={actor_user_id}"
)
return {
"success": True,
"organization_id": org_id,
"tier_id": tier_id,
"tier_name": tier.name,
"max_vehicles": max_vehicles,
"message": f"Organization {org_id} upgraded to {tier.name}"
}
async def record_ledger_entry(
db: AsyncSession,
user_id: int,

View File

@@ -38,7 +38,7 @@ class SearchService:
user_tier = current_user.tier_name
if current_user.role in [UserRole.superadmin, UserRole.admin]:
if current_user.role in [UserRole.SUPERADMIN, UserRole.ADMIN]:
user_tier = "vip"
ranking_rules = await config.get_setting(

View File

@@ -30,7 +30,7 @@ class SocialAuthService:
db.add(new_person)
await db.flush()
user = User(email=email, person_id=new_person.id, role=UserRole.user, is_active=False)
user = User(email=email, person_id=new_person.id, role=UserRole.USER, is_active=False)
db.add(user)
await db.flush()

View File

@@ -18,7 +18,7 @@ def test_normal_user_cannot_access_admin_ping():
mock_user = User(
id=999,
email="normal@example.com",
role=UserRole.user,
role=UserRole.USER,
is_active=True,
is_deleted=False,
subscription_plan="FREE",
@@ -54,7 +54,7 @@ def test_admin_user_can_access_admin_ping():
mock_admin = User(
id=1000,
email="admin@example.com",
role=UserRole.admin,
role=UserRole.ADMIN,
is_active=True,
is_deleted=False,
subscription_plan="PREMIUM",