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

@@ -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())