From 9f54bc3ab15b8234027718f475bd991d27ab10a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 8 Mar 2026 19:42:10 +0100 Subject: [PATCH] feat(phase4+5): role hierarchy, tenant config, fallback material, dead code removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4.1 — Role Hierarchy: - UserRole enum: add global_admin (platform operator) + tenant_admin (per-tenant admin); keep legacy 'admin' for backward compat - Role sets: ADMIN_ROLES, TENANT_ADMIN_ROLES, PM_ROLES, RLS_BYPASS_ROLES - New auth guards: require_global_admin(), require_tenant_admin_or_above(), require_pm_or_above(), is_admin(), is_privileged() - Legacy require_admin / require_admin_or_pm now check both old+new roles - Migration 049: ADD VALUE global_admin + tenant_admin with AUTOCOMMIT workaround; backfills admin → global_admin - Seed: new admin users created with global_admin role Phase 4.3 — RLS bypass updated for global_admin in get_db + set_tenant_context Phase 4.4 — Tenant Feature Flags: - Migration 050: tenant_config JSONB on tenants table - Tenant model: tenant_config field + get_config() accessor - Defaults: max_concurrent_renders=3, fallback_material, invoice_prefix etc. Phase 5.1 — Fallback Material: - blender_render.py: replace PALETTE_LINEAR/PALETTE_HEX/_assign_palette_material with _assign_failed_material() → SCHAEFFLER_059999_FailedMaterial (magenta) - Unmatched parts now logged explicitly before rendering Phase 5.2 — Remove EEVEE fallback: - render_blender.py: EEVEE→Cycles silent retry removed; hard failure on EEVEE error Phase 5.3 — Remove Blender version check: - render_blender.py: deleted MIN_BLENDER_VERSION = (5, 0, 1) constant Co-Authored-By: Claude Sonnet 4.6 --- .../alembic/versions/049_role_hierarchy.py | 44 +++++++++++ .../versions/050_tenant_feature_flags.py | 46 +++++++++++ backend/app/core/middleware.py | 2 +- backend/app/database.py | 10 ++- backend/app/domains/auth/models.py | 22 +++++- backend/app/domains/tenants/models.py | 19 ++++- backend/app/services/render_blender.py | 10 +-- backend/app/utils/auth.py | 58 +++++++++++++- backend/app/utils/seed_templates.py | 2 +- render-worker/scripts/blender_render.py | 76 ++++++++----------- 10 files changed, 225 insertions(+), 64 deletions(-) create mode 100644 backend/alembic/versions/049_role_hierarchy.py create mode 100644 backend/alembic/versions/050_tenant_feature_flags.py diff --git a/backend/alembic/versions/049_role_hierarchy.py b/backend/alembic/versions/049_role_hierarchy.py new file mode 100644 index 0000000..60f6b8d --- /dev/null +++ b/backend/alembic/versions/049_role_hierarchy.py @@ -0,0 +1,44 @@ +"""Extend UserRole enum: add global_admin and tenant_admin. + +Existing 'admin' users are backfilled to 'global_admin'. +The legacy 'admin' value is kept in the enum so existing tokens/sessions +remain valid during the transition period. It will be removed in a later +cleanup migration once all call sites are updated. + +Revision ID: 049 +Revises: 048 +""" +from alembic import op +import sqlalchemy as sa + +revision = "049" +down_revision = "048" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # PostgreSQL requires ALTER TYPE ADD VALUE to be committed in its own transaction + # before the new value can be used. Execute each ADD VALUE with AUTOCOMMIT. + conn = op.get_bind() + + with conn.execution_options(isolation_level="AUTOCOMMIT"): + conn.execute(sa.text("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'global_admin'")) + conn.execute(sa.text("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'tenant_admin'")) + + # Now in a normal transaction: backfill existing 'admin' → 'global_admin' + conn.execute( + sa.text("UPDATE users SET role = 'global_admin'::userrole WHERE role = 'admin'::userrole") + ) + + +def downgrade() -> None: + # Restore global_admin/tenant_admin back to admin (for rollback) + op.execute( + "UPDATE users SET role = 'admin'::userrole WHERE role = 'global_admin'::userrole" + ) + op.execute( + "UPDATE users SET role = 'client'::userrole WHERE role = 'tenant_admin'::userrole" + ) + # Note: cannot DROP enum values in PostgreSQL without recreating the type + # The values will remain in the enum but unused after downgrade diff --git a/backend/alembic/versions/050_tenant_feature_flags.py b/backend/alembic/versions/050_tenant_feature_flags.py new file mode 100644 index 0000000..211bb5c --- /dev/null +++ b/backend/alembic/versions/050_tenant_feature_flags.py @@ -0,0 +1,46 @@ +"""Add tenant_config JSONB column to tenants table. + +Stores per-tenant feature flags and limits: + max_concurrent_renders, render_engines_allowed, fallback_material, + max_order_size, notifications_enabled, invoice_prefix. + +Revision ID: 050 +Revises: 049 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "050" +down_revision = "049" +branch_labels = None +depends_on = None + +_DEFAULT_CONFIG = """{ + "max_concurrent_renders": 3, + "render_engines_allowed": ["cycles", "eevee"], + "max_order_size": 500, + "fallback_material": "SCHAEFFLER_059999_FailedMaterial", + "notifications_enabled": true, + "invoice_prefix": "INV" +}""" + + +def upgrade() -> None: + op.add_column( + "tenants", + sa.Column( + "tenant_config", + JSONB, + nullable=True, + server_default=_DEFAULT_CONFIG, + ), + ) + # Backfill existing rows with defaults + op.execute( + f"UPDATE tenants SET tenant_config = '{_DEFAULT_CONFIG}'::jsonb WHERE tenant_config IS NULL" + ) + + +def downgrade() -> None: + op.drop_column("tenants", "tenant_config") diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py index 27630dd..b9f6351 100644 --- a/backend/app/core/middleware.py +++ b/backend/app/core/middleware.py @@ -44,6 +44,6 @@ class TenantContextMiddleware(BaseHTTPMiddleware): pass # invalid/expired tokens are handled per-endpoint request.state.tenant_id = tenant_id - request.state.role = role + request.state.role = role # "global_admin"|"tenant_admin"|"project_manager"|"client"|"admin" return await call_next(request) diff --git a/backend/app/database.py b/backend/app/database.py index 3eb94be..d269ce9 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -34,7 +34,9 @@ async def get_db(request: "Request | None" = None) -> AsyncGenerator[AsyncSessio tenant_id = getattr(request.state, "tenant_id", None) role = getattr(request.state, "role", None) if tenant_id: - if role == "admin": + # global_admin and legacy admin bypass RLS to see all tenants + _bypass_roles = {"global_admin", "admin"} + if role in _bypass_roles: await session.execute(text("SET LOCAL app.current_tenant_id = 'bypass'")) else: await session.execute( @@ -69,7 +71,8 @@ async def get_db_for_tenant( if user and hasattr(user, "tenant_id") and user.tenant_id: role = getattr(user, "role", None) role_value = role.value if hasattr(role, "value") else str(role) if role else "" - if role_value == "admin": + _bypass = {"global_admin", "admin"} + if role_value in _bypass: await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'")) else: await db.execute( @@ -120,7 +123,8 @@ async def set_tenant_context(db: AsyncSession, user: Optional[object]) -> None: if user and hasattr(user, "tenant_id") and user.tenant_id: role = getattr(user, "role", None) role_value = role.value if hasattr(role, "value") else str(role) if role else "" - if role_value == "admin": + _bypass = {"global_admin", "admin"} + if role_value in _bypass: await db.execute(text("SET LOCAL app.current_tenant_id = 'bypass'")) else: await db.execute( diff --git a/backend/app/domains/auth/models.py b/backend/app/domains/auth/models.py index d00da2d..cdc9759 100644 --- a/backend/app/domains/auth/models.py +++ b/backend/app/domains/auth/models.py @@ -8,9 +8,27 @@ import enum class UserRole(str, enum.Enum): + # New role hierarchy (Phase 4) + global_admin = "global_admin" # platform operator — bypasses RLS, all tenants + tenant_admin = "tenant_admin" # per-tenant admin — full control within own tenant + project_manager = "project_manager" # order/render management within tenant + client = "client" # read own orders, create draft orders + + # Legacy alias — kept for backward compat during migration; use global_admin for new code admin = "admin" - project_manager = "project_manager" - client = "client" + + +# Roles that have administrative privileges (global_admin + legacy admin) +ADMIN_ROLES = {"global_admin", "admin"} + +# Roles with tenant-level admin access +TENANT_ADMIN_ROLES = {"global_admin", "tenant_admin", "admin"} + +# Roles with project management or above +PM_ROLES = {"global_admin", "tenant_admin", "project_manager", "admin"} + +# Roles that bypass RLS (see TenantContextMiddleware + get_db) +RLS_BYPASS_ROLES = {"global_admin", "admin"} class User(Base): diff --git a/backend/app/domains/tenants/models.py b/backend/app/domains/tenants/models.py index 23ced34..f12f90c 100644 --- a/backend/app/domains/tenants/models.py +++ b/backend/app/domains/tenants/models.py @@ -2,9 +2,18 @@ import uuid from datetime import datetime from sqlalchemy import String, DateTime, Boolean from sqlalchemy.orm import Mapped, mapped_column, relationship -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import UUID, JSONB from app.database import Base +_DEFAULT_TENANT_CONFIG = { + "max_concurrent_renders": 3, + "render_engines_allowed": ["cycles", "eevee"], + "max_order_size": 500, + "fallback_material": "SCHAEFFLER_059999_FailedMaterial", + "notifications_enabled": True, + "invoice_prefix": "INV", +} + class Tenant(Base): __tablename__ = "tenants" @@ -14,6 +23,14 @@ class Tenant(Base): slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + tenant_config: Mapped[dict | None] = mapped_column( + JSONB, nullable=True, default=_DEFAULT_TENANT_CONFIG + ) + + def get_config(self, key: str, default=None): + """Safe accessor for tenant_config fields.""" + cfg = self.tenant_config or {} + return cfg.get(key, default) # Relationships (lazy=noload — loaded explicitly when needed) users: Mapped[list] = relationship("User", back_populates="tenant", lazy="noload") diff --git a/backend/app/services/render_blender.py b/backend/app/services/render_blender.py index 3b28913..905fb74 100644 --- a/backend/app/services/render_blender.py +++ b/backend/app/services/render_blender.py @@ -14,8 +14,6 @@ from pathlib import Path logger = logging.getLogger(__name__) -MIN_BLENDER_VERSION = (5, 0, 1) - def _glb_from_step(step_path: Path, glb_path: Path, quality: str = "low") -> None: """Convert STEP → GLB via OCC (export_step_to_gltf.py, no Blender needed). @@ -226,12 +224,8 @@ def render_still( log_lines = [l for l in stdout_lines if "[blender_render]" in l] - # EEVEE fallback to Cycles on non-signal error - if returncode > 0 and engine == "eevee": - logger.warning("EEVEE failed (exit %d) — retrying with Cycles", returncode) - returncode, stdout_lines2, stderr_lines2 = _run("cycles") - engine_used = "cycles (eevee fallback)" - log_lines.extend(l for l in stdout_lines2 if "[blender_render]" in l) + # EEVEE fallback removed (Phase 5.2): EEVEE Next in Blender 5.0+ is stable. + # If EEVEE fails, it is a hard failure — no silent retry. if returncode != 0: stdout_tail = "\n".join(stdout_lines[-50:]) if stdout_lines else "" diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index 462e726..c24eb24 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -59,14 +59,68 @@ async def get_current_user( return user +def _role_value(user: User) -> str: + """Return the string value of a user's role (works for both enum and plain str).""" + role = user.role + return role.value if hasattr(role, "value") else str(role) + + +# ── New role-hierarchy guards ───────────────────────────────────────────────── + +async def require_global_admin(user: User = Depends(get_current_user)) -> User: + """GlobalAdmin only (platform operator). Replaces require_admin() for new code.""" + from app.domains.auth.models import ADMIN_ROLES + if _role_value(user) not in ADMIN_ROLES: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Global admin access required") + return user + + +async def require_tenant_admin_or_above(user: User = Depends(get_current_user)) -> User: + """TenantAdmin, GlobalAdmin, or legacy admin.""" + from app.domains.auth.models import TENANT_ADMIN_ROLES + if _role_value(user) not in TENANT_ADMIN_ROLES: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant admin access required") + return user + + +async def require_pm_or_above(user: User = Depends(get_current_user)) -> User: + """ProjectManager, TenantAdmin, GlobalAdmin, or legacy admin.""" + from app.domains.auth.models import PM_ROLES + if _role_value(user) not in PM_ROLES: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Project manager or above access required", + ) + return user + + +def is_admin(user: User) -> bool: + """Helper: True if user has any admin-level role.""" + from app.domains.auth.models import ADMIN_ROLES + return _role_value(user) in ADMIN_ROLES + + +def is_privileged(user: User) -> bool: + """Helper: True if user can manage orders/renders (PM or above).""" + from app.domains.auth.models import PM_ROLES + return _role_value(user) in PM_ROLES + + +# ── Legacy guards (kept for backward compatibility) ─────────────────────────── +# These now accept both old ("admin") and new ("global_admin") roles. + async def require_admin(user: User = Depends(get_current_user)) -> User: - if user.role.value != "admin": + """Backward-compat alias for require_global_admin(). Prefer require_global_admin() in new code.""" + from app.domains.auth.models import ADMIN_ROLES + if _role_value(user) not in ADMIN_ROLES: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required") return user async def require_admin_or_pm(user: User = Depends(get_current_user)) -> User: - if user.role.value not in ("admin", "project_manager"): + """Backward-compat alias for require_pm_or_above(). Prefer require_pm_or_above() in new code.""" + from app.domains.auth.models import PM_ROLES + if _role_value(user) not in PM_ROLES: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Admin or Project Manager access required", diff --git a/backend/app/utils/seed_templates.py b/backend/app/utils/seed_templates.py index 74f01e9..cc79adf 100644 --- a/backend/app/utils/seed_templates.py +++ b/backend/app/utils/seed_templates.py @@ -161,8 +161,8 @@ async def seed(db_url: str, admin_email: str = "admin@schaeffler.com", admin_pas admin = User( email=admin_email, password_hash=hash_password(admin_password), + role=UserRole.global_admin, full_name="Schaeffler Admin", - role=UserRole.admin, ) session.add(admin) print(f" + Admin user: {admin_email}") diff --git a/render-worker/scripts/blender_render.py b/render-worker/scripts/blender_render.py index c02172b..c598cf7 100644 --- a/render-worker/scripts/blender_render.py +++ b/render-worker/scripts/blender_render.py @@ -27,29 +27,8 @@ if hasattr(sys.stdout, "reconfigure"): import bpy from mathutils import Vector, Matrix -# ── Colour palette (matches Three.js renderer) ─────────────────────────────── - -PALETTE_HEX = [ - "#4C9BE8", "#E85B4C", "#4CBE72", "#E8A84C", "#A04CE8", - "#4CD4E8", "#E84CA8", "#7EC850", "#E86B30", "#5088C8", -] - -def _srgb_to_linear(c: int) -> float: - """Convert 0-255 sRGB integer to linear float.""" - v = c / 255.0 - return v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 - -def _hex_to_linear(hex_color: str) -> tuple: - """Return (r, g, b, 1.0) in Blender linear colour space.""" - h = hex_color.lstrip('#') - return ( - _srgb_to_linear(int(h[0:2], 16)), - _srgb_to_linear(int(h[2:4], 16)), - _srgb_to_linear(int(h[4:6], 16)), - 1.0, - ) - -PALETTE_LINEAR = [_hex_to_linear(h) for h in PALETTE_HEX] +# Fallback material name — magenta, immediately visible when material assignment fails +FAILED_MATERIAL_NAME = "SCHAEFFLER_059999_FailedMaterial" # ── Parse arguments ─────────────────────────────────────────────────────────── @@ -156,20 +135,20 @@ def _apply_smooth(part_obj, angle_deg): bpy.ops.object.shade_flat() -def _assign_palette_material(part_obj, index): - """Assign a palette colour material to a mesh part.""" - color = PALETTE_LINEAR[index % len(PALETTE_LINEAR)] - mat = bpy.data.materials.new(name=f"Part_{index}") - mat.use_nodes = True - bsdf = mat.node_tree.nodes.get("Principled BSDF") - if bsdf: - bsdf.inputs["Base Color"].default_value = color - bsdf.inputs["Metallic"].default_value = 0.35 - bsdf.inputs["Roughness"].default_value = 0.40 - try: - bsdf.inputs["Specular IOR Level"].default_value = 0.5 - except KeyError: - pass +def _assign_failed_material(part_obj): + """Assign the standard fallback material (magenta) when no library material matches. + + Tries to reuse SCHAEFFLER_059999_FailedMaterial from the library first. + Creates a simple magenta Principled BSDF if the library material is not loaded. + """ + mat = bpy.data.materials.get(FAILED_MATERIAL_NAME) + if mat is None: + mat = bpy.data.materials.new(name=FAILED_MATERIAL_NAME) + mat.use_nodes = True + bsdf = mat.node_tree.nodes.get("Principled BSDF") + if bsdf: + bsdf.inputs["Base Color"].default_value = (1.0, 0.0, 1.0, 1.0) # magenta + bsdf.inputs["Roughness"].default_value = 0.6 part_obj.data.materials.clear() part_obj.data.materials.append(mat) @@ -490,13 +469,18 @@ if use_template: if _stripped != kl: mat_map_lower.setdefault(_stripped, v) _apply_material_library(parts, material_library_path, mat_map_lower) - # Parts not matched by library get palette fallback - for i, part in enumerate(parts): + # Parts not matched by library get the failed-material fallback (magenta) + unmatched = [] + for part in parts: if not part.data.materials or len(part.data.materials) == 0: - _assign_palette_material(part, i) + _assign_failed_material(part) + unmatched.append(part.name) + if unmatched: + print(f"[blender_render] WARNING: {len(unmatched)} parts unmatched, assigned {FAILED_MATERIAL_NAME}: {unmatched[:5]}", flush=True) else: - for i, part in enumerate(parts): - _assign_palette_material(part, i) + # No material library — assign fallback to all parts + for part in parts: + _assign_failed_material(part) # ── Shadow catcher (Cycles only, template mode only) ───────────────────── if shadow_catcher: @@ -555,10 +539,10 @@ else: import time as _time _t_smooth_a = _time.time() - for i, part in enumerate(parts): + for part in parts: _apply_smooth(part, smooth_angle) - _assign_palette_material(part, i) - print(f"[blender_render] smooth+palette: {len(parts)} parts ({_time.time()-_t_smooth_a:.1f}s)", flush=True) + _assign_failed_material(part) + print(f"[blender_render] smooth+fallback-material: {len(parts)} parts ({_time.time()-_t_smooth_a:.1f}s)", flush=True) # Apply material library on top of palette colours (same logic as Mode B). # material_library_path / material_map are parsed from argv even in Mode A @@ -576,7 +560,7 @@ else: if _stripped != kl: mat_map_lower.setdefault(_stripped, v) _apply_material_library(parts, material_library_path, mat_map_lower) - # Parts not matched by the library keep their palette material (already set above) + # Parts not matched by the library keep their fallback material (already set above) if needs_auto_camera: # ── Combined bounding box / bounding sphere ──────────────────────────────