From 50107a91ba81dbe35b7933aa4fe1bf95be8946a5 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 19 Apr 2026 00:04:03 +0200 Subject: [PATCH] fixed proper splitting sysadmin/platformadmin and proper logic for mandate name(slug) and label(user) --- modules/auth/__init__.py | 9 +- modules/auth/authentication.py | 167 +++---- modules/datamodels/datamodelBilling.py | 2 +- modules/datamodels/datamodelUam.py | 100 ++++- modules/demoConfigs/investorDemo2026.py | 51 +-- modules/features/chatbot/mainChatbot.py | 9 +- .../features/chatbot/routeFeatureChatbot.py | 2 +- .../realEstate/routeFeatureRealEstate.py | 2 +- .../features/teamsbot/routeFeatureTeamsbot.py | 4 +- .../features/trustee/routeFeatureTrustee.py | 10 +- modules/interfaces/interfaceBootstrap.py | 418 +++++++++--------- modules/interfaces/interfaceDbApp.py | 150 ++++++- modules/interfaces/interfaceDbManagement.py | 8 +- modules/routes/routeAdminDatabaseHealth.py | 10 +- modules/routes/routeAdminDemoConfig.py | 8 +- modules/routes/routeAdminFeatures.py | 61 +-- modules/routes/routeAdminLogs.py | 6 +- modules/routes/routeAdminRbacRules.py | 28 +- .../routes/routeAdminUserAccessOverview.py | 11 +- modules/routes/routeAudit.py | 3 +- modules/routes/routeBilling.py | 15 +- modules/routes/routeDataFiles.py | 7 +- modules/routes/routeDataMandates.py | 141 ++++-- modules/routes/routeDataSources.py | 3 +- modules/routes/routeDataUsers.py | 105 +++-- modules/routes/routeI18n.py | 50 ++- modules/routes/routeInvitations.py | 4 +- modules/routes/routeRealEstate.py | 2 +- modules/routes/routeSecurityLocal.py | 14 +- modules/routes/routeStore.py | 10 +- modules/routes/routeSubscription.py | 6 +- modules/routes/routeSystem.py | 2 +- modules/routes/routeWorkflowDashboard.py | 16 +- modules/security/rbacCatalog.py | 34 +- modules/shared/mandateNameUtils.py | 121 +++++ modules/system/mainSystem.py | 4 +- modules/system/registry.py | 4 +- scripts/check_db_no_sysadmin_role.py | 132 ++++++ scripts/check_no_sysadmin_role.py | 108 +++++ tests/integration/mandates/__init__.py | 0 .../mandates/test_createMandate.py | 190 ++++++++ .../mandates/test_provisionMandate.py | 109 +++++ .../mandates/test_updateMandate.py | 215 +++++++++ .../rbac/test_platform_admin_flag.py | 290 ++++++++++++ tests/integration/users/__init__.py | 0 tests/integration/users/test_updateUser.py | 221 +++++++++ tests/test_phase123_basic.py | 2 +- tests/unit/bootstrap/__init__.py | 0 .../bootstrap/test_mandateNameMigration.py | 133 ++++++ tests/unit/rbac/test_sysadmin_migration.py | 209 +++++++++ tests/unit/shared/test_mandateNameUtils.py | 56 +++ 51 files changed, 2673 insertions(+), 589 deletions(-) create mode 100644 modules/shared/mandateNameUtils.py create mode 100644 scripts/check_db_no_sysadmin_role.py create mode 100644 scripts/check_no_sysadmin_role.py create mode 100644 tests/integration/mandates/__init__.py create mode 100644 tests/integration/mandates/test_createMandate.py create mode 100644 tests/integration/mandates/test_provisionMandate.py create mode 100644 tests/integration/mandates/test_updateMandate.py create mode 100644 tests/integration/rbac/test_platform_admin_flag.py create mode 100644 tests/integration/users/__init__.py create mode 100644 tests/integration/users/test_updateUser.py create mode 100644 tests/unit/bootstrap/__init__.py create mode 100644 tests/unit/bootstrap/test_mandateNameMigration.py create mode 100644 tests/unit/rbac/test_sysadmin_migration.py create mode 100644 tests/unit/shared/test_mandateNameUtils.py diff --git a/modules/auth/__init__.py b/modules/auth/__init__.py index 28bc1ed2..0a485767 100644 --- a/modules/auth/__init__.py +++ b/modules/auth/__init__.py @@ -7,7 +7,10 @@ High-level security functionality that depends on FastAPI and interfaces. Multi-Tenant Design: - RequestContext: Per-request context with user, mandate, feature instance, roles - getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header -- requireSysAdmin: FastAPI dependency for system-level admin operations +- requireSysAdmin: FastAPI dependency for INFRASTRUCTURE-level operations + (logs, tokens, DB-health, i18n-master). Includes RBAC bypass. +- requirePlatformAdmin: FastAPI dependency for CROSS-MANDATE GOVERNANCE + (user-/mandate-/RBAC-/feature-registry mgmt). No bypass. """ from .authentication import ( @@ -19,7 +22,7 @@ from .authentication import ( RequestContext, getRequestContext, requireSysAdmin, - requireSysAdminRole, + requirePlatformAdmin, ) from .jwtService import ( createAccessToken, @@ -45,7 +48,7 @@ __all__ = [ "RequestContext", "getRequestContext", "requireSysAdmin", - "requireSysAdminRole", + "requirePlatformAdmin", # JWT Service "createAccessToken", "createRefreshToken", diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index d15ff2fd..27cf1a31 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -272,7 +272,6 @@ class RequestContext: # Request-scoped cache: rules loaded only once per request self._cachedRules: Optional[List[tuple]] = None - self._cachedHasSysAdminRole: Optional[bool] = None def getRules(self) -> List[tuple]: """ @@ -299,18 +298,17 @@ class RequestContext: @property def isSysAdmin(self) -> bool: - """Convenience property to check if user has the isSysAdmin FLAG. - Category A only: true system operations (tokens, logs, databases).""" + """Convenience property: Infrastructure/System Operator flag. + For Category A (Logs, Tokens, DB-Health, i18n-Master, Registry). + Wirkt auch als RBAC-Engine-Bypass (siehe rbac.py:getUserPermissions).""" return getattr(self.user, 'isSysAdmin', False) - - @property - def hasSysAdminRole(self) -> bool: - """Check if user has sysadmin ROLE in root mandate (cached per request). - Use for admin operations (Categories B/C/D/E) instead of isSysAdmin flag.""" - if self._cachedHasSysAdminRole is None: - self._cachedHasSysAdminRole = _hasSysAdminRole(str(self.user.id)) - return self._cachedHasSysAdminRole + @property + def isPlatformAdmin(self) -> bool: + """Convenience property: Cross-Mandate-Governance flag. + For Categories B–E (User-/Mandate-/RBAC-/Feature-Registry über alle Mandanten). + KEIN RBAC-Bypass — Daten-Zugriff geht weiterhin über Mandanten-Mitgliedschaft.""" + return getattr(self.user, 'isPlatformAdmin', False) def getRequestContext( request: Request, @@ -323,33 +321,37 @@ def getRequestContext( Checks authorization and loads role IDs. Security Model: - - Regular users: Must be explicit members of mandates/feature instances - - SysAdmin users: Can access ANY mandate for administrative operations. - Root mandate roles (incl. sysadmin role) are loaded for RBAC-based authorization. - Routes use ctx.hasSysAdminRole for admin checks (not ctx.isSysAdmin flag). - + - Regular users: Must be explicit members of mandates/feature instances. + - isSysAdmin users: RBAC-Engine-Bypass; können jeden Mandant für + Infrastruktur-Operationen betreten ohne Mitgliedschaft. ``ctx.roleIds`` + bleibt leer (Bypass läuft direkt in ``rbac.py:getUserPermissions``). + - isPlatformAdmin users: Cross-Mandate-Governance; können jeden Mandant + betreten, aber Routen prüfen die Berechtigung explizit via + ``requirePlatformAdmin``. ``ctx.roleIds`` bleibt leer. + Args: request: FastAPI Request object mandateId: Mandate ID from X-Mandate-Id header featureInstanceId: Feature instance ID from X-Instance-Id header currentUser: Current authenticated user - + Returns: RequestContext with user, mandate, roles - + Raises: - HTTPException 403: If non-SysAdmin user is not member of mandate or has no feature access + HTTPException 403: If user is not member of mandate (and not Sys/Platform admin) """ ctx = RequestContext(user=currentUser) isSysAdmin = getattr(currentUser, 'isSysAdmin', False) - + isPlatformAdmin = getattr(currentUser, 'isPlatformAdmin', False) + # Get root interface for membership checks rootInterface = getRootInterface() - + if mandateId: # Check mandate membership membership = rootInterface.getUserMandate(currentUser.id, mandateId) - + if membership: # User is a member - load their roles if not membership.enabled: @@ -359,12 +361,16 @@ def getRequestContext( ) ctx.mandateId = mandateId ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id) - elif isSysAdmin: - # SysAdmin can access any mandate for admin operations - # Load root mandate roles for RBAC-based authorization (includes sysadmin role) + elif isSysAdmin or isPlatformAdmin: + # Platform-level authority can enter any mandate without membership. + # No fake role loading: isSysAdmin bypasses RBAC engine; platform-admin + # routes verify authority explicitly via requirePlatformAdmin. ctx.mandateId = mandateId - ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id)) - logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} with root mandate roles") + ctx.roleIds = [] + logger.debug( + f"Platform-level user {currentUser.id} accessing mandate {mandateId} " + f"(isSysAdmin={isSysAdmin}, isPlatformAdmin={isPlatformAdmin})" + ) else: # Regular user without membership - denied logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}") @@ -372,11 +378,11 @@ def getRequestContext( status_code=status.HTTP_403_FORBIDDEN, detail="Not member of mandate" ) - + if featureInstanceId: # Check feature access access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId) - + if access: # User has access - load their instance roles if not access.enabled: @@ -387,13 +393,15 @@ def getRequestContext( ctx.featureInstanceId = featureInstanceId instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id) ctx.roleIds.extend(instanceRoleIds) - elif isSysAdmin: - # SysAdmin can access any feature instance for admin operations + elif isSysAdmin or isPlatformAdmin: + # Platform-level authority can enter any feature instance without + # explicit access record. ctx.featureInstanceId = featureInstanceId - # If no roles loaded yet, load root mandate roles - if not ctx.roleIds: - ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id)) - logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} with root mandate roles") + logger.debug( + f"Platform-level user {currentUser.id} accessing feature instance " + f"{featureInstanceId} (isSysAdmin={isSysAdmin}, " + f"isPlatformAdmin={isPlatformAdmin})" + ) else: # Regular user without access - denied logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}") @@ -401,7 +409,7 @@ def getRequestContext( status_code=status.HTTP_403_FORBIDDEN, detail="No access to feature instance" ) - + return ctx @@ -444,95 +452,46 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User: # ============================================================================= -# SYSADMIN ROLE: RBAC-based admin checks (hybrid model) +# PLATFORM ADMIN: Flag-based cross-mandate governance (replaces sysadmin role) # ============================================================================= -def _getRootMandateRoleIds(rootInterface, userId: str) -> List[str]: +def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User: """ - Load the user's role IDs from the root mandate. - Used by auth middleware to provide RBAC roles for SysAdmin cross-mandate access. - - Args: - rootInterface: Root database interface - userId: User ID - - Returns: - List of role IDs from root mandate membership, empty list if no membership - """ - try: - rootMandateId = rootInterface._getRootMandateId() - if not rootMandateId: - return [] - membership = rootInterface.getUserMandate(userId, rootMandateId) - if not membership: - return [] - return rootInterface.getRoleIdsForUserMandate(membership.id) - except Exception as e: - logger.error(f"Error loading root mandate roles: {e}") - return [] + Require Platform-Admin flag for cross-mandate governance operations. + Verwendung für alle Operationen, die mandanten-übergreifend wirken: + User-Mgmt, Mandate-Mgmt, RBAC-Catalog, Feature-Registry, User-Access-Overview, + Cross-Mandate-Audit, Cross-Mandate-Billing-Übersicht, Subscription-Mgmt. -def _hasSysAdminRole(userId: str) -> bool: - """ - Check if a user has the sysadmin role in the root mandate. - - Standalone check that queries the database directly, independent of - request context. Used for authorization checks where the sysadmin - ROLE (not just the isSysAdmin flag) is required. - - Args: - userId: User ID to check - - Returns: - True if user has sysadmin role in root mandate - """ - try: - rootInterface = getRootInterface() - roleIds = _getRootMandateRoleIds(rootInterface, str(userId)) - for roleId in roleIds: - role = rootInterface.getRole(roleId) - if role and role.roleLabel == "sysadmin": - return True - return False - except Exception as e: - logger.error(f"Error checking sysadmin role: {e}") - return False + KEIN RBAC-Bypass: Daten-Zugriff auf einen einzelnen Mandanten erfordert + weiterhin Mitgliedschaft (oder zusätzlich isSysAdmin für Infrastruktur-Bypass). - -def requireSysAdminRole(currentUser: User = Depends(getCurrentUser)) -> User: - """ - Require sysadmin ROLE for admin operations. - - Unlike requireSysAdmin (which checks the isSysAdmin FLAG for system-level ops), - this dependency checks the sysadmin ROLE in the root mandate. - Use for admin operations that should be RBAC-controlled (Category E). - Args: currentUser: Current authenticated user - + Returns: - User if they have the sysadmin role - + User if they have isPlatformAdmin=True + Raises: - HTTPException 403: If user doesn't have sysadmin role + HTTPException 403: If user is not a Platform Admin """ - if not _hasSysAdminRole(str(currentUser.id)): + if not getattr(currentUser, 'isPlatformAdmin', False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="SysAdmin role required" + detail="Platform admin privileges required" ) - - # Audit + + # Audit for all Platform-Admin actions try: from modules.shared.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(currentUser.id), mandateId="system", - action="sysadmin_role_action", - details="Admin operation via sysadmin role" + action="platform_admin_action", + details="Cross-mandate governance operation" ) except Exception: pass - + return currentUser diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py index 8718413c..f662e28c 100644 --- a/modules/datamodels/datamodelBilling.py +++ b/modules/datamodels/datamodelBilling.py @@ -138,7 +138,7 @@ class BillingSettings(BaseModel): warningThresholdPercent: float = Field( default=10.0, - description="Benachrichtigung wenn das AI-Guthaben unter diesen Prozentsatz des Gesamtbudgets fällt", + description="Warning threshold as percentage", json_schema_extra={"label": "Warnschwelle (%)"}, ) diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index 9278ceb3..a73b4746 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -6,7 +6,15 @@ UAM models: User, Mandate, UserConnection. Multi-Tenant Design: - User gehört NICHT direkt zu einem Mandanten - Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py) -- isSysAdmin ist globales Admin-Flag für System-Zugriff (KEIN Daten-Zugriff!) +- Zwei orthogonale Plattform-Autoritäts-Flags: + * isSysAdmin → Infrastruktur-Operator (Logs, Tokens, DB-Health, + i18n-Master, Registry). RBAC-Engine-Bypass. + KEIN Cross-Mandate-Governance. + * isPlatformAdmin → Cross-Mandate-Governance (User-/Mandate-/RBAC-/ + Feature-Verwaltung über alle Mandanten). + KEIN RBAC-Bypass. + Beide einzeln vergebbar, einzeln auditierbar. + Siehe wiki/c-work/4-done/2026-04-sysadmin-authority-split.md """ import uuid @@ -15,6 +23,7 @@ from enum import Enum from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field from modules.datamodels.datamodelBase import PowerOnModel from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag +from modules.shared.mandateNameUtils import MANDATE_NAME_MAX_LEN, MANDATE_NAME_MIN_LEN from modules.shared.timeUtils import getUtcTimestamp @@ -66,6 +75,11 @@ class Mandate(PowerOnModel): """ Mandate (Mandant/Tenant) model. Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen. + + Semantik: + - ``name`` (Kurzzeichen): plattformweit eindeutiger, stabiler technischer Code (Slug), + Audit-/Referenz-Identifier. Nur Kleinbuchstaben, Ziffern und ``-`` (Länge 2–32). + - ``label`` (Voller Name): Anzeigename im UI, frei änderbar unabhängig vom Slug. """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), @@ -73,13 +87,26 @@ class Mandate(PowerOnModel): json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"}, ) name: str = Field( - description="Name of the mandate", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True, "label": "Name"}, + description="Unique stable mandate code (slug); lowercase, digits, hyphen segments only.", + min_length=MANDATE_NAME_MIN_LEN, + max_length=MANDATE_NAME_MAX_LEN, + pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$", + json_schema_extra={ + "frontend_type": "slug", + "frontend_readonly": False, + "frontend_required": True, + "label": "Kurzzeichen", + }, ) - label: Optional[str] = Field( - default=None, - description="Display label of the mandate", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Label"}, + label: str = Field( + description="Human-readable mandate name shown in the UI (Voller Name).", + min_length=1, + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": False, + "frontend_required": True, + "label": "Voller Name", + }, ) enabled: bool = Field( default=True, @@ -105,6 +132,30 @@ class Mandate(PowerOnModel): return False return v + @field_validator("name", mode="before") + @classmethod + def _stripName(cls, v): + if v is None: + return "" + if isinstance(v, str): + return v.strip() + return v + + @field_validator("label", mode="before") + @classmethod + def _coerceLabel(cls, v): + if v is None: + return "" + return v + + @field_validator("label") + @classmethod + def _validateMandateLabel(cls, v: str) -> str: + s = v.strip() + if len(s) < 1: + raise ValueError("Mandate Voller Name (label) must not be empty.") + return s + @i18nModel("Benutzerverbindung") class UserConnection(PowerOnModel): id: str = Field( @@ -224,8 +275,11 @@ class User(PowerOnModel): Multi-Tenant Design: - User gehört NICHT direkt zu einem Mandanten - Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py) - - Rollen werden über UserMandateRole gesteuert - - isSysAdmin = System-Zugriff, KEIN Daten-Zugriff + - Rollen werden über UserMandateRole gesteuert (mandanten-scoped) + - Plattform-Autorität via zwei orthogonalen Flags: + * isSysAdmin → Infrastruktur (Bypass der RBAC-Engine, KEIN + Cross-Mandate-Governance) + * isPlatformAdmin → Cross-Mandate-Governance (KEIN RBAC-Bypass) """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), @@ -283,10 +337,15 @@ class User(PowerOnModel): isSysAdmin: bool = Field( default=False, - description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!", + description=( + "Infrastructure/System Operator flag. Erlaubt RBAC-Engine-Bypass " + "und Zugriff auf Infrastruktur-Operationen (Logs, Tokens, DB-Health, " + "i18n-Master, Registry). Gibt KEIN Cross-Mandate-Governance-Recht " + "(dafür ist isPlatformAdmin zuständig)." + ), json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "System-Admin"}, ) - + @field_validator('isSysAdmin', mode='before') @classmethod def _coerceIsSysAdmin(cls, v): @@ -294,6 +353,25 @@ class User(PowerOnModel): if v is None: return False return v + + isPlatformAdmin: bool = Field( + default=False, + description=( + "Platform/Cross-Mandate Governance flag. Erlaubt mandanten-übergreifende " + "Verwaltungsoperationen (User-/Mandate-/RBAC-/Feature-Registry). " + "KEIN RBAC-Engine-Bypass und KEIN impliziter Zugriff auf Mandanten-Daten." + ), + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Plattform-Admin"}, + ) + + @field_validator('isPlatformAdmin', mode='before') + @classmethod + def _coerceIsPlatformAdmin(cls, v): + """Konvertiert None zu False (für bestehende DB-Einträge ohne isPlatformAdmin Feld).""" + if v is None: + return False + return v + authenticationAuthority: AuthAuthority = Field( default=AuthAuthority.LOCAL, diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index 179e574e..0490bd91 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -77,7 +77,7 @@ class InvestorDemo2026(_BaseDemoConfig): mandateIdAlpina = self._ensureMandate(db, _MANDATE_ALPINA, summary) userId = self._ensureUser(db, summary) - self._ensureRootMandateSysAdminRole(db, userId, summary) + self._ensurePlatformAdminFlag(db, userId, summary) if mandateIdHappy: self._ensureMembership(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary) @@ -195,47 +195,24 @@ class InvestorDemo2026(_BaseDemoConfig): summary["created"].append(f"User {_USER['fullName']}") return uid - def _ensureRootMandateSysAdminRole(self, db, userId: str, summary: Dict): - """Ensure the demo user is member of the root mandate with the sysadmin role. - Without this, hasSysAdminRole returns False and admin menus are hidden.""" - from modules.datamodels.datamodelUam import Mandate - from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole - from modules.datamodels.datamodelRbac import Role + def _ensurePlatformAdminFlag(self, db, userId: str, summary: Dict): + """Ensure the demo user has isPlatformAdmin=True for cross-mandate governance. + Without this, the admin UI menus would be hidden.""" + from modules.datamodels.datamodelUam import UserInDB - rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True}) - if not rootMandates: - summary["errors"].append("Root mandate not found — cannot assign sysadmin role") + existing = db.getRecord(UserInDB, userId) + if not existing: + summary["errors"].append(f"Demo user {userId} not found — cannot set isPlatformAdmin") return - rootMandateId = rootMandates[0].get("id") - - existing = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": rootMandateId}) - if existing: - userMandateId = existing[0].get("id") - else: - um = UserMandate(userId=userId, mandateId=rootMandateId, enabled=True) - created = db.recordCreate(UserMandate, um) - userMandateId = created.get("id") - summary["created"].append("Membership -> root mandate") - logger.info(f"Created root mandate membership for {_USER['username']}") - - sysadminRoles = db.getRecordset(Role, recordFilter={"mandateId": rootMandateId, "roleLabel": "sysadmin"}) - if not sysadminRoles: - summary["errors"].append("sysadmin role not found in root mandate") + currentFlag = bool(existing.get("isPlatformAdmin", False)) if isinstance(existing, dict) else bool(getattr(existing, "isPlatformAdmin", False)) + if currentFlag: + summary["skipped"].append("isPlatformAdmin already set") return - sysadminRoleId = sysadminRoles[0].get("id") - existingRole = db.getRecordset(UserMandateRole, recordFilter={ - "userMandateId": userMandateId, - "roleId": sysadminRoleId, - }) - if not existingRole: - umr = UserMandateRole(userMandateId=userMandateId, roleId=sysadminRoleId) - db.recordCreate(UserMandateRole, umr) - summary["created"].append("SysAdmin role in root mandate") - logger.info(f"Assigned sysadmin role in root mandate for {_USER['username']}") - else: - summary["skipped"].append("SysAdmin role in root mandate exists") + db.recordModify(UserInDB, userId, {"isPlatformAdmin": True}) + summary["created"].append("isPlatformAdmin flag") + logger.info(f"Set isPlatformAdmin=True for {_USER['username']}") def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict): from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py index b50fef79..6f3b7dae 100644 --- a/modules/features/chatbot/mainChatbot.py +++ b/modules/features/chatbot/mainChatbot.py @@ -116,11 +116,18 @@ TEMPLATE_ROLES = [ def getFeatureDefinition() -> Dict[str, Any]: - """Return the feature definition for registration.""" + """Return the feature definition for registration. + + The chatbot feature is currently soft-disabled via ``enabled=False``: its + catalog objects, template roles and routes stay loaded so already-running + instances keep working, but it is filtered out of the Store and the + Admin Feature-Instances "Neue Instanz" selection list. + """ return { "code": FEATURE_CODE, "label": FEATURE_LABEL, "icon": FEATURE_ICON, + "enabled": False, } diff --git a/modules/features/chatbot/routeFeatureChatbot.py b/modules/features/chatbot/routeFeatureChatbot.py index fa7ab93c..06cf985d 100644 --- a/modules/features/chatbot/routeFeatureChatbot.py +++ b/modules/features/chatbot/routeFeatureChatbot.py @@ -102,7 +102,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: ) # Verify user has access to this instance - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: # Check if user has FeatureAccess for this instance featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) hasAccess = any( diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index 2df04323..a8da37b4 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -116,7 +116,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: status_code=400, detail=f"Instance '{instanceId}' is not a realestate instance" ) - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) hasAccess = any( str(fa.featureInstanceId) == instanceId and fa.enabled diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index d316bde2..e5ed9425 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -138,7 +138,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: def _validateSessionOwnership(session: dict, context: RequestContext) -> None: """Raise 404 if the user does not own this session (sysAdmin bypasses).""" - if context.hasSysAdminRole: + if context.isPlatformAdmin: return if session.get("startedByUserId") != str(context.user.id): raise HTTPException(status_code=404, detail=f"Session '{session.get('id')}' not found") @@ -319,7 +319,7 @@ async def listSessions( """List sessions for a feature instance (filtered to own sessions unless sysAdmin).""" _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) - userId = None if context.hasSysAdminRole else str(context.user.id) + userId = None if context.isPlatformAdmin else str(context.user.id) sessions = interface.getSessions(instanceId, includeEnded=includeEnded, userId=userId) return {"sessions": sessions} diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index c4c96017..73752788 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -104,7 +104,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: ) # Verify user has access to this instance - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: # Check if user has FeatureAccess for this instance featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) hasAccess = any( @@ -138,7 +138,7 @@ def getQuickActions( from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES userRoleLabels: set = set() - if context.hasSysAdminRole: + if context.isPlatformAdmin: userRoleLabels.add("trustee-admin") else: rootInterface = getRootInterface() @@ -156,9 +156,9 @@ def getQuickActions( filteredActions = [] for action in QUICK_ACTIONS: required = set(action.get("requiredRoles", [])) - if not userRoleLabels and not context.hasSysAdminRole: + if not userRoleLabels and not context.isPlatformAdmin: continue - if context.hasSysAdminRole or required.intersection(userRoleLabels): + if context.isPlatformAdmin or required.intersection(userRoleLabels): resolved = { "id": action["id"], "label": resolveText(action.get("label", {})), @@ -1811,7 +1811,7 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str: mandateId = _validateInstanceAccess(instanceId, context) # SysAdmin role always has access - if context.hasSysAdminRole: + if context.isPlatformAdmin: return mandateId # Check for instance-roles.manage resource permission via AccessRules diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 707d9acc..5a556616 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -7,7 +7,7 @@ Contains all bootstrap logic including mandate, users, and RBAC rules. Multi-Tenant Design: - Rollen werden mit Kontext erstellt (mandateId=None für globale Template-Rollen) - AccessRules referenzieren roleId (FK), nicht roleLabel -- Admin-User bekommt isSysAdmin=True statt roleLabels +- Admin-User bekommt isSysAdmin=True UND isPlatformAdmin=True (statt einer Rolle) """ import logging @@ -61,6 +61,7 @@ def initBootstrap(db: DatabaseConnector) -> None: # Migrate existing mandate records: description -> label _migrateMandateDescriptionToLabel(db) + _migrateMandateNameLabelSlugRules(db) # Clean up duplicate roles and fix corrupted templates FIRST _deduplicateRoles(db) @@ -75,12 +76,14 @@ def initBootstrap(db: DatabaseConnector) -> None: # This also serves as migration for existing mandates that don't have instance roles yet _ensureAllMandatesHaveSystemRoles(db) - # Initialize sysadmin role in root mandate (NOT a template, mandate-specific) - # Hybrid model: isSysAdmin flag → system ops, sysadmin role → admin ops via RBAC + # Migration: eliminate the legacy ``sysadmin`` role in root mandate + # (replaced by ``User.isPlatformAdmin`` flag — see + # wiki/c-work/4-done/2026-04-sysadmin-authority-split.md). + # Idempotent: noop after first successful run. if mandateId: - _initSysAdminRole(db, mandateId) - - # Ensure UI rules for sysadmin role (created after initRbacRules, needs second pass) + _migrateAndDropSysAdminRole(db, mandateId) + + # Ensure UI rules for navigation items (admin/user/viewer roles) _ensureUiContextRules(db) # Initialize admin user @@ -420,32 +423,101 @@ def _migrateMandateDescriptionToLabel(db: DatabaseConnector) -> None: logger.debug("No mandate description->label migration needed") +def _migrateMandateNameLabelSlugRules(db: DatabaseConnector) -> None: + """ + Migration: normalize Mandate.name to the slug rules ([a-z0-9-], length 2..32, single + hyphen segments) and ensure Mandate.label is non-empty. + + Rules (see wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md): + 1. If ``label`` is empty/None → set ``label := name`` (or "Mandate" when both empty). + 2. If ``name`` is not a valid slug, or collides with an earlier mandate in stable id + order, allocate a unique slug from the (now non-empty) ``label`` using + ``slugifyMandateName`` + ``allocateUniqueMandateSlug``. + + Idempotent: a second run is a no-op because all valid names stay valid and stay unique. + Each rename and label fill-in is logged for audit. + """ + from modules.shared.mandateNameUtils import ( + allocateUniqueMandateSlug, + isValidMandateName, + slugifyMandateName, + ) + + allRows = db.getRecordset(Mandate) + if not allRows: + return + sortedRows = sorted(allRows, key=lambda r: str(r.get("id", ""))) + + used: set[str] = set() + labelFills = 0 + nameRenames: list[tuple[str, str, str]] = [] + + for rec in sortedRows: + mid = rec.get("id") + if not mid: + continue + name = (rec.get("name") or "").strip() + labelRaw = rec.get("label") + label = (labelRaw or "").strip() if labelRaw is not None else "" + + if not label: + label = name if name else "Mandate" + db.recordModify(Mandate, mid, {"label": label}) + labelFills += 1 + logger.info(f"Mandate {mid}: filled empty label with '{label}'") + + nameFits = isValidMandateName(name) + nameCollides = name in used + if nameFits and not nameCollides: + used.add(name) + continue + + base = slugifyMandateName(label) or "mn" + newName = allocateUniqueMandateSlug(base, used) + used.add(newName) + if newName != name: + db.recordModify(Mandate, mid, {"name": newName}) + nameRenames.append((str(mid), name, newName)) + logger.info(f"Mandate {mid}: renamed name '{name}' -> '{newName}'") + + if labelFills or nameRenames: + logger.info( + "Mandate name/label slug migration: %d label fill-in(s), %d name rename(s)", + labelFills, len(nameRenames), + ) + else: + logger.debug("No mandate name/label slug migration needed") + + def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]: """ Creates the Admin user if it doesn't exist. - Admin user gets isSysAdmin=True for system-level access. - Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships(). - + Admin user gets BOTH platform flags: + - isSysAdmin=True (Infrastructure: logs/tokens/DB-health) + - isPlatformAdmin=True (Cross-Mandate-Governance: user/mandate/RBAC mgmt) + Args: db: Database connector instance mandateId: Root mandate ID (for membership assignment, not on User) - + Returns: User ID if created or found, None otherwise """ existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"}) if existingUsers: userId = existingUsers[0].get("id") - existingIsSysAdmin = existingUsers[0].get("isSysAdmin", False) - - # Ensure admin user has isSysAdmin=True - if not existingIsSysAdmin: - logger.info(f"Updating admin user {userId} to set isSysAdmin=True") - db.recordModify(UserInDB, userId, {"isSysAdmin": True}) - + updates: Dict[str, bool] = {} + if not existingUsers[0].get("isSysAdmin", False): + updates["isSysAdmin"] = True + if not existingUsers[0].get("isPlatformAdmin", False): + updates["isPlatformAdmin"] = True + if updates: + logger.info(f"Updating admin user {userId} platform flags: {updates}") + db.recordModify(UserInDB, userId, updates) + logger.info(f"Admin user already exists with ID {userId}") return userId - + logger.info("Creating Admin user") adminUser = UserInDB( username="admin", @@ -454,6 +526,7 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s enabled=True, language="en", isSysAdmin=True, + isPlatformAdmin=True, authenticationAuthority=AuthAuthority.LOCAL, hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")), ) @@ -466,22 +539,30 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]: """ Creates the Event user if it doesn't exist. - Event user gets isSysAdmin=True for system operations. - Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships(). - + Event user gets isSysAdmin=True for infrastructure-level operations + (system events, internal callbacks). It does NOT need cross-mandate + governance, so isPlatformAdmin is left False. + Args: db: Database connector instance mandateId: Root mandate ID (for membership assignment, not on User) - + Returns: User ID if created or found, None otherwise """ existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "event"}) if existingUsers: userId = existingUsers[0].get("id") + # Defensive: revoke any historic platform-admin grant on the event user + if existingUsers[0].get("isPlatformAdmin", False): + logger.warning( + f"Event user {userId} had isPlatformAdmin=True; " + f"revoking (event user is infrastructure-only)" + ) + db.recordModify(UserInDB, userId, {"isPlatformAdmin": False}) logger.info(f"Event user already exists with ID {userId}") return userId - + logger.info("Creating Event user") eventUser = UserInDB( username="event", @@ -490,6 +571,7 @@ def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s enabled=True, language="en", isSysAdmin=True, + isPlatformAdmin=False, authenticationAuthority=AuthAuthority.LOCAL, hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")), ) @@ -503,20 +585,19 @@ def initRoles(db: DatabaseConnector) -> None: """ Initialize standard roles if they don't exist. Roles are created as GLOBAL (mandateId=None) template roles. - - NOTE: The "sysadmin" role is NOT a template - it's created separately in - _initSysAdminRole() as a root-mandate-specific role (isSystemRole=False). - These template roles (admin/user/viewer) are for mandate/feature-level access control. - + + NOTE: There is no platform-level "sysadmin" role any more — platform + authority lives on the User record via ``isSysAdmin`` and + ``isPlatformAdmin``. These template roles (admin/user/viewer) are + purely for mandate/feature-level access control. + Args: db: Database connector instance """ logger.info("Initializing roles") global _roleIdCache _roleIdCache = {} - - # Standard template roles for mandate/feature-level access - # NOTE: "sysadmin" role is created separately in _initSysAdminRole (root mandate only) + standardRoles = [ Role( roleLabel="admin", @@ -734,145 +815,99 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: return copiedCount -def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]: +def _migrateAndDropSysAdminRole(db: DatabaseConnector, mandateId: str) -> None: """ - Initialize the sysadmin role in the root mandate. - - The sysadmin role is a mandate-specific role (NOT a system template) that provides - full administrative access via RBAC. It only exists in the root mandate and is - NOT copied to other mandates (isSystemRole=False). - - Hybrid model: - - User.isSysAdmin flag → true system operations (Category A: tokens, logs, databases) - - sysadmin role → admin operations via RBAC (Categories B/C/D/E) - + One-shot migration: eliminate the legacy ``sysadmin`` role in the root mandate. + + Authority semantics moved to two orthogonal flags on User: + - ``isSysAdmin`` → Infrastructure-Operator (RBAC bypass) + - ``isPlatformAdmin`` → Cross-Mandate-Governance (no bypass) + + Migration steps (idempotent): + 1. Find sysadmin role(s) in root mandate. If none exist → done. + 2. For every UserMandateRole row referencing such a role: set + ``user.isPlatformAdmin = True`` (preserves cross-mandate authority). + 3. Delete those UserMandateRole rows. + 4. Delete AccessRules attached to the sysadmin role. + 5. Delete the sysadmin Role record. + Args: db: Database connector instance mandateId: Root mandate ID - - Returns: - Sysadmin role ID or None """ - # Check if sysadmin role already exists in root mandate - existingRoles = db.getRecordset( + sysadminRoles = db.getRecordset( Role, - recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None} + recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None}, ) - - if existingRoles: - sysadminRoleId = existingRoles[0].get("id") - logger.info(f"Sysadmin role already exists in root mandate with ID {sysadminRoleId}") - # Ensure AccessRules exist (migration safety) - _ensureSysAdminAccessRules(db, sysadminRoleId) - return sysadminRoleId - - # Create sysadmin role in root mandate - logger.info("Creating sysadmin role in root mandate") - sysadminRole = Role( - roleLabel="sysadmin", - description=coerce_text_multilingual("System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten"), - mandateId=mandateId, - featureInstanceId=None, - featureCode=None, - isSystemRole=False # NOT a template → NOT copied to other mandates - ) - createdRole = db.recordCreate(Role, sysadminRole) - sysadminRoleId = createdRole.get("id") - logger.info(f"Created sysadmin role with ID {sysadminRoleId}") - - # Create AccessRules for sysadmin role - _createSysAdminAccessRules(db, sysadminRoleId) - - return sysadminRoleId - - -def _createSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None: - """ - Create AccessRules for the sysadmin role. - - DATA + RESOURCE: generic item=None (full access). - UI: NO generic rule here — explicit ui.admin.* rules are created by - _ensureUiContextRules() (same logic as admin role). - - Args: - db: Database connector instance - sysadminRoleId: Sysadmin role ID - """ - rules = [ - # DATA: Full access to all data tables (generic rule, item=None) - AccessRule( - roleId=sysadminRoleId, - context=AccessRuleContext.DATA, - item=None, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - ), - # RESOURCE: Access to all system resources (generic rule, item=None) - AccessRule( - roleId=sysadminRoleId, - context=AccessRuleContext.RESOURCE, - item=None, - view=True, - read=None, - create=None, - update=None, - delete=None, - ), - ] - - for rule in rules: - db.recordCreate(AccessRule, rule) - - logger.info(f"Created {len(rules)} AccessRules for sysadmin role (UI rules via _ensureUiContextRules)") - - -def _ensureSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None: - """ - Ensure AccessRules exist for the sysadmin role (migration safety). - Creates missing rules without duplicating existing ones. - - Args: - db: Database connector instance - sysadminRoleId: Sysadmin role ID - """ - existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId}) - - if not existingRules: - logger.info("No AccessRules found for sysadmin role, creating them") - _createSysAdminAccessRules(db, sysadminRoleId) + if not sysadminRoles: + logger.debug("Sysadmin role migration: no legacy sysadmin role present, nothing to do") return - - # Check for DATA and RESOURCE contexts (UI is handled by _ensureUiContextRules) - existingContexts = {r.get("context") for r in existingRules} - - missingRules = [] - if AccessRuleContext.DATA.value not in existingContexts: - missingRules.append(AccessRule( - roleId=sysadminRoleId, - context=AccessRuleContext.DATA, - item=None, - view=True, - read=AccessLevel.ALL, - create=AccessLevel.ALL, - update=AccessLevel.ALL, - delete=AccessLevel.ALL, - )) - if AccessRuleContext.RESOURCE.value not in existingContexts: - missingRules.append(AccessRule( - roleId=sysadminRoleId, - context=AccessRuleContext.RESOURCE, - item=None, - view=True, - read=None, create=None, update=None, delete=None, - )) - - if missingRules: - for rule in missingRules: - db.recordCreate(AccessRule, rule) - logger.info(f"Created {len(missingRules)} missing AccessRules for sysadmin role") + + sysadminRoleIds = [str(r.get("id")) for r in sysadminRoles if r.get("id")] + logger.warning( + f"Sysadmin role migration: found {len(sysadminRoleIds)} legacy sysadmin role(s) " + f"in root mandate, migrating to isPlatformAdmin flag" + ) + + # 1) Promote every holder to isPlatformAdmin=True + promoted = 0 + for sysadminRoleId in sysadminRoleIds: + umRoleRows = db.getRecordset( + UserMandateRole, recordFilter={"roleId": sysadminRoleId} + ) + userMandateIds = [str(r.get("userMandateId")) for r in umRoleRows if r.get("userMandateId")] + if not userMandateIds: + continue + + # Resolve userIds via UserMandate + userIds = set() + for umId in userMandateIds: + ums = db.getRecordset(UserMandate, recordFilter={"id": umId}) + for um in ums: + uid = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None) + if uid: + userIds.add(str(uid)) + + for userId in userIds: + users = db.getRecordset(UserInDB, recordFilter={"id": userId}) + if not users: + continue + current = users[0].get("isPlatformAdmin", False) + if not current: + db.recordModify(UserInDB, userId, {"isPlatformAdmin": True}) + promoted += 1 + logger.warning( + f"Sysadmin role migration: granted isPlatformAdmin=True to user {userId}" + ) + + # 2) Delete UserMandateRole rows + for umRow in umRoleRows: + rowId = umRow.get("id") if isinstance(umRow, dict) else getattr(umRow, "id", None) + if rowId: + try: + db.recordDelete(UserMandateRole, str(rowId)) + except Exception as e: + logger.error(f"Sysadmin role migration: failed to drop UserMandateRole {rowId}: {e}") + + # 3) Delete AccessRules + accessRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId}) + for ar in accessRules: + arId = ar.get("id") if isinstance(ar, dict) else getattr(ar, "id", None) + if arId: + try: + db.recordDelete(AccessRule, str(arId)) + except Exception as e: + logger.error(f"Sysadmin role migration: failed to drop AccessRule {arId}: {e}") + + # 4) Delete the Role + try: + db.recordDelete(Role, sysadminRoleId) + except Exception as e: + logger.error(f"Sysadmin role migration: failed to drop Role {sysadminRoleId}: {e}") + + logger.warning( + f"Sysadmin role migration: completed; promoted {promoted} user(s) to isPlatformAdmin" + ) def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]: @@ -940,8 +975,9 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None: Create default role rules for generic access (item = null). Uses roleId instead of roleLabel. - NOTE: Sysadmin role rules are created separately in _initSysAdminRole(). - These default rules cover admin/user/viewer template roles. + NOTE: There is no sysadmin role any more — platform/infra authority is + governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User + record. These default rules cover admin/user/viewer template roles. Args: db: Database connector instance @@ -991,15 +1027,16 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None: These rules override generic rules for specific tables. Uses roleId instead of roleLabel. - NOTE: Sysadmin role rules are created separately in _initSysAdminRole(). - These table-specific rules cover admin/user/viewer template roles. + NOTE: There is no sysadmin role any more — platform/infra authority is + governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User + record. These table-specific rules cover admin/user/viewer template roles. Args: db: Database connector instance """ tableRules = [] - # Get role IDs for template roles (sysadmin is a separate mandate-level role) + # Get role IDs for template roles (platform authority lives on User flags) adminId = _getRoleId(db, "admin") userId = _getRoleId(db, "user") viewerId = _getRoleId(db, "viewer") @@ -1470,8 +1507,7 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None: mandateAdminRoleIds = [] mandateUserRoleIds = [] mandateViewerRoleIds = [] - sysadminRoleIds = [] - + mandateRoles = db.getRecordset( Role, recordFilter={"isSystemRole": False, "featureInstanceId": None} @@ -1487,12 +1523,12 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None: mandateUserRoleIds.append(roleId) elif label == "viewer": mandateViewerRoleIds.append(roleId) - elif label == "sysadmin": - sysadminRoleIds.append(roleId) - - # All role IDs per level (template + mandate-instance) - # sysadmin gets ALL UI rules (admin-only + public) — same logic, explicit rules - allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds + sysadminRoleIds + + # All role IDs per level (template + mandate-instance). + # Admin-only navigation items are governed by these admin roles plus the + # ``isPlatformAdmin`` flag (checked in routes via requirePlatformAdmin), + # NOT by a dedicated platform-level role. + allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds @@ -1860,7 +1896,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None: Store resources control which roles can activate features via the Store. - admin/user: view=True (can see and activate store features) - viewer: no store access - - sysadmin: covered by generic RESOURCE rule (item=None, view=True) + - isSysAdmin flag bypasses RBAC (rbac.py:getUserPermissions) Args: db: Database connector instance @@ -1998,9 +2034,11 @@ def assignInitialUserMemberships( Assign initial memberships to admin and event users via UserMandate + UserMandateRole. This is the NEW multi-tenant way of assigning roles. - Hybrid model: Initial users get BOTH the isSysAdmin flag (for system ops) - AND the "admin" + "sysadmin" roles in the root mandate (for RBAC-based admin ops). - + Initial users get the "admin" role in the root mandate. Platform-level + authority (cross-mandate governance + infrastructure ops) is conveyed via + the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User record itself + (see ``initAdminUser`` / ``initEventUser``). + Args: db: Database connector instance mandateId: Root mandate ID @@ -2018,13 +2056,7 @@ def assignInitialUserMemberships( if not adminRoleId: logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment") return - - # Find sysadmin role in root mandate (created by _initSysAdminRole) - sysadminRole = next((r for r in mandateRoles if r.get("roleLabel") == "sysadmin"), None) - sysadminRoleId = sysadminRole.get("id") if sysadminRole else None - if not sysadminRoleId: - logger.warning("Sysadmin role not found in root mandate - run _initSysAdminRole first") - + for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]: # Check if UserMandate already exists existingMemberships = db.getRecordset( @@ -2059,20 +2091,6 @@ def assignInitialUserMemberships( ) db.recordCreate(UserMandateRole, userMandateRole) logger.info(f"Assigned admin role to {userName} user in mandate") - - # Assign sysadmin role (in addition to admin role) - if sysadminRoleId: - existingSysadminRoles = db.getRecordset( - UserMandateRole, - recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId} - ) - if not existingSysadminRoles: - sysadminMandateRole = UserMandateRole( - userMandateId=userMandateId, - roleId=sysadminRoleId - ) - db.recordCreate(UserMandateRole, sysadminMandateRole) - logger.info(f"Assigned sysadmin role to {userName} user in root mandate") def _getPasswordHash(password: Optional[str]) -> Optional[str]: diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 4f43d0ca..2a88fecc 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -677,6 +677,7 @@ class AppObjects: externalUsername: str = None, externalEmail: str = None, isSysAdmin: bool = False, + isPlatformAdmin: bool = False, addExternalIdentityConnection: bool = True, ) -> User: """ @@ -714,6 +715,7 @@ class AppObjects: language=language, enabled=enabled, isSysAdmin=isSysAdmin, + isPlatformAdmin=isPlatformAdmin, authenticationAuthority=authenticationAuthority, hashedPassword=self._getPasswordHash(password) if password else None, ) @@ -755,15 +757,21 @@ class AppObjects: logger.error(f"Unexpected error creating user: {str(e)}") raise ValueError(f"Failed to create user: {str(e)}") - def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User], allowSysAdminChange: bool = False) -> User: + def updateUser( + self, + userId: str, + updateData: Union[Dict[str, Any], User], + allowAdminFlagChange: bool = False, + ) -> User: """Update a user's information. - + Args: userId: ID of the user to update updateData: User data to update (dict or User model) - allowSysAdminChange: If True, allows changing isSysAdmin field. - Only set to True when called by a SysAdmin explicitly - changing another user's admin status. + allowAdminFlagChange: If True, allows changing the privileged platform + flags ``isSysAdmin`` and ``isPlatformAdmin``. + Only set to True when called by a Platform Admin + explicitly changing another user's admin status. """ try: # Get user @@ -771,20 +779,35 @@ class AppObjects: if not user: raise ValueError(f"User {userId} not found") - # Convert updateData to dict if it's a User model + # Convert updateData to dict if it's a User model. + # + # IMPORTANT: When the route layer passes a Pydantic ``User`` instance, + # ``model_dump()`` returns ALL fields — including those the client + # never sent — populated with Pydantic defaults (e.g. ``isSysAdmin=False``). + # That historical pattern caused silent flag flips on inline-toggles. + # + # The PUT route now ships a plain dict carrying ONLY the explicitly + # changed fields, so this branch should rarely fire; however internal + # callers (``disableUser`` / ``enableUser`` / migration scripts) still + # use dict-style partials and must remain partial-safe. if isinstance(updateData, User): - updateDict = updateData.model_dump() + updateDict = updateData.model_dump(exclude_unset=True) + # Fallback for legacy callers that constructed a fully-defaulted + # User: if nothing was marked as explicitly set, treat the dump + # as authoritative but DROP privileged flags unconditionally + # unless allowAdminFlagChange is True. + if not updateDict: + updateDict = updateData.model_dump() else: - updateDict = updateData.copy() if isinstance(updateData, dict) else updateData + updateDict = updateData.copy() if isinstance(updateData, dict) else dict(updateData) - # Remove id field from updateDict if present - we'll use userId from parameter updateDict.pop("id", None) - - # SECURITY: Protect sensitive fields from being overwritten by profile updates. - # These fields should only be changed explicitly by admins, not through - # profile forms where they might be sent as default values (e.g., isSysAdmin=False). - protectedFields = ["isSysAdmin"] - if not allowSysAdminChange: + + # SECURITY: Protect privileged platform flags from accidental + # overwrite via profile forms or partial payloads from clients + # whose model defaults could pull the value down to False. + protectedFields = ["isSysAdmin", "isPlatformAdmin"] + if not allowAdminFlagChange: for field in protectedFields: updateDict.pop(field, None) @@ -1456,16 +1479,56 @@ class AppObjects: return Mandate(**filteredMandates[0]) - def createMandate(self, name: str, label: str = None, enabled: bool = True) -> Mandate: + def _existingMandateNames(self, excludeId: Optional[str] = None) -> List[str]: + """Return all mandate.name values currently in the DB (optionally excluding one id).""" + out: List[str] = [] + for r in self.db.getRecordset(Mandate): + if excludeId and str(r.get("id")) == str(excludeId): + continue + n = r.get("name") + if n: + out.append(n) + return out + + def _generateUniqueMandateName(self, label: str, excludeId: Optional[str] = None) -> str: + """Generate a slug from *label* that is unique across all mandates (Phase 3 helper).""" + from modules.shared.mandateNameUtils import allocateUniqueMandateSlug, slugifyMandateName + + base = slugifyMandateName(label or "") + return allocateUniqueMandateSlug(base, self._existingMandateNames(excludeId=excludeId)) + + def createMandate(self, name: str = None, label: str = None, enabled: bool = True) -> Mandate: """ Creates a new mandate if user has permission. Automatically copies system template roles (admin, user, viewer) to the new mandate. + + ``label`` (Voller Name) is required (non-empty). If ``name`` (Kurzzeichen) is omitted or empty, + a unique slug is generated from the label; otherwise it is validated and uniqueness-checked. """ if not self.checkRbacPermission(Mandate, "create"): raise PermissionError("No permission to create mandates") - # Create mandate data using model - mandateData = Mandate(name=name, label=label, enabled=enabled) + from modules.shared.mandateNameUtils import isValidMandateName + + effLabel = (label or "").strip() if label is not None else "" + if not effLabel and name: + effLabel = (name or "").strip() + if not effLabel: + raise ValueError("Mandate label (Voller Name) is required") + + rawName = (name or "").strip() if name else "" + if not rawName: + rawName = self._generateUniqueMandateName(effLabel) + else: + if not isValidMandateName(rawName): + raise ValueError( + "Mandate Kurzzeichen must be 2–32 characters: lowercase a–z, digits, " + "hyphens only (single-hyphen segments)." + ) + if rawName in self._existingMandateNames(): + raise ValueError(f"Mandate Kurzzeichen '{rawName}' is already in use") + + mandateData = Mandate(name=rawName, label=effLabel, enabled=enabled) # Create mandate record createdRecord = self.db.recordCreate(Mandate, mandateData) @@ -1484,24 +1547,31 @@ class AppObjects: return Mandate(**createdRecord) - def _provisionMandateForUser(self, userId: str, mandateName: str, planKey: str) -> Dict[str, Any]: + def _provisionMandateForUser(self, userId: str, mandateLabel: str, planKey: str) -> Dict[str, Any]: """ Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances. Internal method — bypasses RBAC (used during registration when user has no permissions yet). + + ``mandateLabel`` is the display name (Voller Name); a unique slug ``name`` (Kurzzeichen) is derived. """ from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS from modules.datamodels.datamodelFeatures import FeatureInstance from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.system.registry import loadFeatureMainModules - plan = BUILTIN_PLANS.get(planKey) if not plan: raise ValueError(f"Unknown plan: {planKey}") + effLabel = (mandateLabel or "").strip() + if not effLabel: + raise ValueError("mandateLabel (Voller Name) is required for provisioning") + + uniqueName = self._generateUniqueMandateName(effLabel) + mandateData = Mandate( - name=mandateName, - label=mandateName, + name=uniqueName, + label=effLabel, enabled=True, isSystem=False, ) @@ -1674,7 +1744,17 @@ class AppObjects: return activated def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate: - """Updates a mandate if user has access.""" + """ + Updates a mandate if user has access. + + Field-level rules: + - ``id`` always immutable. + - ``isSystem`` only sysadmin. + - ``name`` (Kurzzeichen) only platform/sysadmin; format and uniqueness are validated. + - ``label`` (Voller Name) must be non-empty if provided. + """ + from modules.shared.mandateNameUtils import isValidMandateName + try: # First check if user has permission to modify mandates if not self.checkRbacPermission(Mandate, "update", mandateId): @@ -1685,11 +1765,33 @@ class AppObjects: if not mandate: raise ValueError(f"Mandate {mandateId} not found") + _isSysAdmin = bool(getattr(self.currentUser, "isSysAdmin", False)) + _isPlatformAdmin = bool(getattr(self.currentUser, "isPlatformAdmin", False)) + _protectedFields = {"id"} - if not getattr(self.currentUser, "isSysAdmin", False): + if not _isSysAdmin: _protectedFields.add("isSystem") + if not (_isSysAdmin or _isPlatformAdmin): + _protectedFields.add("name") _sanitizedData = {k: v for k, v in updateData.items() if k not in _protectedFields} + if "name" in _sanitizedData: + newName = (_sanitizedData["name"] or "").strip() + if not isValidMandateName(newName): + raise ValueError( + "Mandate Kurzzeichen must be 2–32 characters: lowercase a–z, digits, " + "hyphens only (single-hyphen segments)." + ) + if newName != mandate.name and newName in self._existingMandateNames(excludeId=mandateId): + raise ValueError(f"Mandate Kurzzeichen '{newName}' is already in use") + _sanitizedData["name"] = newName + + if "label" in _sanitizedData: + newLabel = (_sanitizedData["label"] or "").strip() + if not newLabel: + raise ValueError("Mandate Voller Name (label) must not be empty.") + _sanitizedData["label"] = newLabel + # Update mandate data using model updatedData = mandate.model_dump() updatedData.update(_sanitizedData) diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 957e8e11..1a27a8db 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -635,12 +635,8 @@ class ComponentObjects: # Prompt methods def _isSysAdmin(self) -> bool: - """Check if the current user has sysadmin role (or isSysAdmin flag as fallback).""" - from modules.auth.authentication import _hasSysAdminRole - userId = getattr(self.currentUser, 'id', None) - if userId and _hasSysAdminRole(str(userId)): - return True - return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin + """Check if the current user has the isSysAdmin flag (infrastructure operator).""" + return bool(getattr(self.currentUser, 'isSysAdmin', False)) def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Enrich prompts with row-level _permissions based on ownership and isSystem flag. diff --git a/modules/routes/routeAdminDatabaseHealth.py b/modules/routes/routeAdminDatabaseHealth.py index a3e7a165..760ab53d 100644 --- a/modules/routes/routeAdminDatabaseHealth.py +++ b/modules/routes/routeAdminDatabaseHealth.py @@ -11,7 +11,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field from modules.auth import limiter -from modules.auth.authentication import requireSysAdminRole +from modules.auth.authentication import requireSysAdmin from modules.datamodels.datamodelUam import User from modules.system.databaseHealth import ( _cleanAllOrphans, @@ -41,7 +41,7 @@ class OrphanCleanRequest(BaseModel): def getDatabaseTableStats( request: Request, db: Optional[str] = None, - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Table statistics from pg_stat_user_tables (optional filter by database name).""" rows = _getTableStats(dbFilter=db) @@ -53,7 +53,7 @@ def getDatabaseTableStats( def getDatabaseOrphans( request: Request, db: Optional[str] = None, - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """FK orphan scan (optional filter by source database name).""" rows = _scanOrphans(dbFilter=db) @@ -65,7 +65,7 @@ def getDatabaseOrphans( def postDatabaseOrphansClean( request: Request, body: OrphanCleanRequest, - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Delete orphaned rows for a single FK relationship.""" try: @@ -90,7 +90,7 @@ def postDatabaseOrphansClean( @limiter.limit("2/minute") def postDatabaseOrphansCleanAll( request: Request, - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Run orphan cleanup for every relationship that currently has orphans.""" results: List[dict] = _cleanAllOrphans() diff --git a/modules/routes/routeAdminDemoConfig.py b/modules/routes/routeAdminDemoConfig.py index b85cc38c..d893c205 100644 --- a/modules/routes/routeAdminDemoConfig.py +++ b/modules/routes/routeAdminDemoConfig.py @@ -9,7 +9,7 @@ import logging from fastapi import APIRouter, Depends, HTTPException, Request, status from modules.auth import limiter -from modules.auth.authentication import requireSysAdminRole +from modules.auth.authentication import requirePlatformAdmin from modules.datamodels.datamodelUam import User from modules.security.rootAccess import getRootDbAppConnector @@ -25,7 +25,7 @@ router = APIRouter( @limiter.limit("30/minute") def listDemoConfigs( request: Request, - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requirePlatformAdmin), ) -> dict: """List all available demo configurations.""" from modules.demoConfigs import _getAvailableDemoConfigs @@ -41,7 +41,7 @@ def listDemoConfigs( def loadDemoConfig( code: str, request: Request, - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requirePlatformAdmin), ) -> dict: """Load (create) a demo configuration. Idempotent.""" from modules.demoConfigs import _getDemoConfigByCode @@ -66,7 +66,7 @@ def loadDemoConfig( def removeDemoConfig( code: str, request: Request, - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requirePlatformAdmin), ) -> dict: """Remove all data created by a demo configuration.""" from modules.demoConfigs import _getDemoConfigByCode diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 5532406c..ba867780 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -20,7 +20,7 @@ from pydantic import BaseModel, Field from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict from modules.routes.routeHelpers import _applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory -from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole +from modules.auth import limiter, getRequestContext, RequestContext, requirePlatformAdmin from modules.datamodels.datamodelUam import User, UserInDB from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.interfaces.interfaceDbApp import getRootInterface @@ -95,11 +95,18 @@ def list_features( """ try: # Features come from the RBAC Catalog (registered at startup from feature containers) - # NOT from the database - features are code-defined, not user-created + # NOT from the database - features are code-defined, not user-created. + # Hide meta-features (instantiable=False, e.g. ``system``) and soft- + # disabled features (enabled=False) so they don't appear in selection + # dropdowns like Admin > Feature-Instanzen > Neue Instanz. catalogService = getCatalogService() features = catalogService.getFeatureDefinitions() + features = [ + f for f in features + if f.get("instantiable", True) and f.get("enabled", True) + ] return features - + except Exception as e: logger.error(f"Error listing features: {e}") raise HTTPException( @@ -351,7 +358,7 @@ def create_feature( code: str = Query(..., description="Unique feature code"), label: Dict[str, str] = None, icon: str = Query("mdi-puzzle", description="Icon identifier"), - sysAdmin: User = Depends(requireSysAdminRole) + sysAdmin: User = Depends(requirePlatformAdmin) ) -> Dict[str, Any]: """ Create a new feature definition. @@ -520,7 +527,7 @@ def get_feature_instance( # Verify mandate access (unless SysAdmin) if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") @@ -660,14 +667,14 @@ def delete_feature_instance( # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check mandate admin permission - if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: + if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required to delete feature instances") @@ -727,14 +734,14 @@ def updateFeatureInstance( # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check mandate admin permission - if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: + if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required to update feature instances") @@ -810,14 +817,14 @@ def sync_instance_roles( # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission (Mandate-Admin or Feature-Admin) - if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: + if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to sync roles") @@ -863,10 +870,14 @@ def _syncInstanceWorkflows( instances created before template workflows were defined, or when the initial copy failed silently. - SysAdmin only. + PlatformAdmin only. """ try: - requireSysAdminRole(context.user) + if not context.isPlatformAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Platform admin privileges required", + ) rootInterface = getRootInterface() featureInterface = getFeatureInterface(rootInterface.db) @@ -975,7 +986,7 @@ def list_template_roles( pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"), column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"), - sysAdmin: User = Depends(requireSysAdminRole), + sysAdmin: User = Depends(requirePlatformAdmin), ): """List global template roles with pagination support.""" try: @@ -1035,7 +1046,7 @@ def create_template_role( roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"), featureCode: str = Query(..., description="Feature code this role belongs to"), description: Dict[str, str] = None, - sysAdmin: User = Depends(requireSysAdminRole) + sysAdmin: User = Depends(requirePlatformAdmin) ) -> Dict[str, Any]: """ Create a global template role for a feature. @@ -1145,7 +1156,7 @@ def list_feature_instance_users( # Verify mandate access (unless SysAdmin) if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") @@ -1259,14 +1270,14 @@ def add_user_to_feature_instance( # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission - if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: + if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to add users to feature instances") @@ -1367,14 +1378,14 @@ def remove_user_from_feature_instance( # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission - if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: + if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to remove users from feature instances") @@ -1457,14 +1468,14 @@ def update_feature_instance_user_roles( # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission - if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: + if not _hasMandateAdminRole(context) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to update user roles") @@ -1565,7 +1576,7 @@ def get_feature_instance_available_roles( # Verify mandate access if context.mandateId and str(instance.mandateId) != str(context.mandateId): - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance") @@ -1668,7 +1679,7 @@ def _renameFeatureInstance( userId = str(context.user.id) isInstanceAdmin = False - if context.hasSysAdminRole: + if context.isPlatformAdmin: isInstanceAdmin = True else: from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole @@ -1707,7 +1718,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool: A user is mandate admin if they have the 'admin' role at mandate level. """ - if context.hasSysAdminRole: + if context.isPlatformAdmin: return True if not context.roleIds: diff --git a/modules/routes/routeAdminLogs.py b/modules/routes/routeAdminLogs.py index 065eba9c..926c7370 100644 --- a/modules/routes/routeAdminLogs.py +++ b/modules/routes/routeAdminLogs.py @@ -12,7 +12,7 @@ import logging from datetime import datetime from fastapi import APIRouter, HTTPException, Depends, Request, Query from fastapi.responses import PlainTextResponse -from modules.auth import limiter, requireSysAdminRole +from modules.auth import limiter, requireSysAdmin from modules.shared.configuration import APP_CONFIG from modules.datamodels.datamodelUam import User @@ -63,7 +63,7 @@ def _readLastNLines(filePath: str, n: int) -> list[str]: def getLogEntries( request: Request, count: int = Query(default=200, ge=1, le=50000, description="Number of log entries to return"), - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requireSysAdmin), ) -> dict: """ Get the last N log entries from the gateway log files. @@ -104,7 +104,7 @@ def getLogEntries( def downloadLog( request: Request, count: int = Query(default=1000, ge=1, le=100000, description="Number of log entries to download"), - currentUser: User = Depends(requireSysAdminRole), + currentUser: User = Depends(requireSysAdmin), ) -> PlainTextResponse: """ Download the last N log entries as a plain text file. diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index ccd45b2f..5f3b5317 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -17,7 +17,7 @@ import logging import json import math -from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext +from modules.auth import limiter, getRequestContext, requirePlatformAdmin, RequestContext from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role from modules.datamodels.datamodelMembership import UserMandate @@ -242,7 +242,7 @@ def get_all_permissions( logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates") - if not roleIds and not reqContext.hasSysAdminRole: + if not roleIds and not reqContext.isPlatformAdmin: # No roles at all, return empty permissions for ctx in contextsToFetch: result[ctx.value.lower()] = {} @@ -362,7 +362,7 @@ def get_access_rules( - List of AccessRule objects """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -487,7 +487,7 @@ def get_access_rules_by_role( - List of AccessRule objects for the specified role """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -534,7 +534,7 @@ def get_access_rule( - AccessRule object """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -585,7 +585,7 @@ def create_access_rule( - Created AccessRule object """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -665,7 +665,7 @@ def update_access_rule( - Updated AccessRule object """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -753,7 +753,7 @@ def delete_access_rule( - Success message """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -836,7 +836,7 @@ def list_roles( - List of role dictionaries with role label, description, user count, and computed scopeType """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -1017,7 +1017,7 @@ def create_role( - Created role dictionary """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -1076,7 +1076,7 @@ def get_role( - Role dictionary """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -1137,7 +1137,7 @@ def update_role( - Updated role dictionary """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -1201,7 +1201,7 @@ def delete_role( - Success message """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) @@ -1357,7 +1357,7 @@ def getCatalogObjects( def cleanup_duplicate_access_rules( request: Request, dryRun: bool = Query(True, description="If true, only report duplicates without deleting"), - currentUser: User = Depends(requireSysAdminRole) + currentUser: User = Depends(requirePlatformAdmin) ) -> dict: """ Find and remove duplicate AccessRules. diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py index ab04d085..4906c093 100644 --- a/modules/routes/routeAdminUserAccessOverview.py +++ b/modules/routes/routeAdminUserAccessOverview.py @@ -75,7 +75,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool: Loads roles independently from request context (context.roleIds may be empty when no X-Mandate-Id header is sent, e.g., on admin pages). """ - if context.hasSysAdminRole: + if context.isPlatformAdmin: return True try: rootInterface = getRootInterface() @@ -123,7 +123,7 @@ def listUsersForOverview( try: interface = getRootInterface() - if context.hasSysAdminRole and not context.mandateId: + if context.isPlatformAdmin and not context.mandateId: # SysAdmin without mandate context: all users allUsers = interface.getAllUsers() elif context.mandateId: @@ -164,6 +164,7 @@ def listUsersForOverview( "email": userData.get("email"), "fullName": userData.get("fullName"), "isSysAdmin": userData.get("isSysAdmin", False), + "isPlatformAdmin": userData.get("isPlatformAdmin", False), "enabled": userData.get("enabled", True), }) @@ -217,7 +218,7 @@ def getUserAccessOverview( interface = getRootInterface() # MandateAdmin: verify the requested user shares at least one admin mandate - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: # Get admin's mandate IDs adminMandateIds = [] userMandates = interface.getUserMandates(str(context.user.id)) @@ -258,6 +259,7 @@ def getUserAccessOverview( "email": user.email, "fullName": user.fullName, "isSysAdmin": user.isSysAdmin, + "isPlatformAdmin": getattr(user, "isPlatformAdmin", False), "enabled": user.enabled, } @@ -481,7 +483,8 @@ def getUserAccessOverview( return { "user": userInfo, - "isSysAdmin": False, + "isSysAdmin": bool(getattr(user, "isSysAdmin", False)), + "isPlatformAdmin": bool(getattr(user, "isPlatformAdmin", False)), "roles": allRoles, "mandates": mandatesInfo, "uiAccess": uiAccess, diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py index 86fbcea3..a8ac4d72 100644 --- a/modules/routes/routeAudit.py +++ b/modules/routes/routeAudit.py @@ -131,8 +131,7 @@ def _enrichUserAndInstanceLabels( def _requireAuditAccess(context: RequestContext): """Raise 403 unless user has mandate-admin or compliance-viewer access.""" - from modules.auth.authentication import _hasSysAdminRole - if _hasSysAdminRole(str(context.user.id)): + if context.isPlatformAdmin: return from modules.interfaces.interfaceDbApp import getInterface diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index bd66abef..9b238df1 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -17,7 +17,7 @@ from datetime import date, datetime, timezone from pydantic import BaseModel, Field # Import auth module -from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext +from modules.auth import limiter, requirePlatformAdmin, getRequestContext, RequestContext # Import billing components from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface, _getRootInterface @@ -86,8 +86,7 @@ def _getBillingDataScope(user) -> BillingDataScope: """ scope = BillingDataScope(userId=user.id) - from modules.auth.authentication import _hasSysAdminRole - if _hasSysAdminRole(str(user.id)): + if bool(getattr(user, "isPlatformAdmin", False)): scope.isGlobalAdmin = True return scope @@ -141,8 +140,8 @@ def _getBillingDataScope(user) -> BillingDataScope: def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool: - """Check if user is SysAdmin or admin of the specified mandate.""" - if ctx.hasSysAdminRole: + """Check if user is PlatformAdmin or admin of the specified mandate.""" + if ctx.isPlatformAdmin: return True try: from modules.interfaces.interfaceDbApp import getRootInterface @@ -734,7 +733,7 @@ def addCredit( targetMandateId: str = Path(..., description="Mandate ID"), creditRequest: CreditAddRequest = Body(...), ctx: RequestContext = Depends(getRequestContext), - _admin = Depends(requireSysAdminRole) + _admin = Depends(requirePlatformAdmin) ): """ Add credit to a billing account (SysAdmin only). @@ -1461,7 +1460,7 @@ def getTransactionsAdmin( def getMandateViewBalances( request: Request, ctx: RequestContext = Depends(getRequestContext), - _admin = Depends(requireSysAdminRole) + _admin = Depends(requirePlatformAdmin) ): """ Get mandate-level balances (SysAdmin only). @@ -1484,7 +1483,7 @@ def getMandateViewTransactions( request: Request, limit: int = Query(default=100, ge=1, le=1000), ctx: RequestContext = Depends(getRequestContext), - _admin = Depends(requireSysAdminRole) + _admin = Depends(requirePlatformAdmin) ): """ Get all transactions across mandates (SysAdmin only). diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 439bfce5..f5b6f3d4 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -8,7 +8,6 @@ import json # Import auth module from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext -from modules.auth.authentication import _hasSysAdminRole # Import interfaces import modules.interfaces.interfaceDbManagement as interfaceDbManagement @@ -545,7 +544,7 @@ def _updateFolderScope( validScopes = {"personal", "featureInstance", "mandate", "global"} if scope not in validScopes: raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}") - if scope == "global" and not _hasSysAdminRole(context.user): + if scope == "global" and not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope")) try: mgmt = interfaceDbManagement.getInterface( @@ -847,7 +846,7 @@ def updateFileScope( if scope not in validScopes: raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}") - if scope == "global" and not context.hasSysAdminRole: + if scope == "global" and not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope")) managementInterface = interfaceDbManagement.getInterface( @@ -1041,7 +1040,7 @@ def update_file( detail=f"File with ID {fileId} not found" ) - if safeData.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)): + if safeData.get("scope") == "global" and not getattr(currentUser, "isSysAdmin", False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Only sysadmins can set global scope"), diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 3c3da3a5..e81f500f 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -5,7 +5,8 @@ Mandate routes for the backend API. Implements the endpoints for mandate management. MULTI-TENANT: -- Mandate CRUD is SysAdmin-only (mandates are system resources) +- Mandate create/delete and cross-mandate ops require PlatformAdmin +- Mandate read/update: PlatformAdmin or Mandate-Admin (label-only for the latter) - User management within mandates is Mandate-Admin (add/remove users) """ @@ -17,7 +18,7 @@ import json from pydantic import BaseModel, Field # Import auth module -from modules.auth import limiter, requireSysAdminRole, getRequestContext, getCurrentUser, RequestContext +from modules.auth import limiter, requirePlatformAdmin, getRequestContext, getCurrentUser, RequestContext # Import interfaces import modules.interfaces.interfaceDbApp as interfaceDbApp @@ -33,6 +34,8 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe from modules.routes.routeNotifications import create_access_change_notification from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException from modules.shared.i18nRegistry import apiRouteContext +from modules.shared.mandateNameUtils import isValidMandateName + routeApiMsg = apiRouteContext("routeDataMandates") @@ -101,8 +104,8 @@ def get_mandates( """ try: # Check admin access - isSysAdmin = context.hasSysAdminRole - if not isSysAdmin: + isPlatformAdmin = context.isPlatformAdmin + if not isPlatformAdmin: adminMandateIds = _getAdminMandateIds(context) if not adminMandateIds: raise HTTPException( @@ -135,7 +138,7 @@ def get_mandates( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - if isSysAdmin: + if isPlatformAdmin: crossPagination = parseCrossFilterPagination(column, pagination) try: from fastapi.responses import JSONResponse @@ -155,7 +158,7 @@ def get_mandates( return handleFilterValuesInMemory(mandateItems, column, pagination) if mode == "ids": - if isSysAdmin: + if isPlatformAdmin: return handleIdsMode(appInterface.db, Mandate, pagination) else: mandateItems = [] @@ -165,7 +168,7 @@ def get_mandates( mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m)) return handleIdsInMemory(mandateItems, pagination) - if isSysAdmin: + if isPlatformAdmin: result = appInterface.getAllMandates(pagination=paginationParams) else: allMandates = [] @@ -223,7 +226,7 @@ def get_mandate( try: mandateId = targetMandateId # Check access - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: adminMandateIds = _getAdminMandateIds(context) if mandateId not in adminMandateIds: raise HTTPException( @@ -254,37 +257,48 @@ def get_mandate( @limiter.limit("10/minute") def create_mandate( request: Request, - mandateData: dict = Body(..., description="Mandate data with at least 'name' field"), - currentUser: User = Depends(requireSysAdminRole) + mandateData: dict = Body(..., description="Mandate data: label (Voller Name) required unless name alone is provided; name (Kurzzeichen) optional — auto-generated from label if omitted"), + currentUser: User = Depends(requirePlatformAdmin) ) -> Mandate: """ Create a new mandate. - MULTI-TENANT: SysAdmin-only. + MULTI-TENANT: PlatformAdmin-only. """ try: logger.debug(f"Creating mandate with data: {mandateData}") - - # Validate required fields - name = mandateData.get('name') - if not name or (isinstance(name, str) and name.strip() == ''): + + labelRaw = mandateData.get("label") + nameRaw = mandateData.get("name") + labelStripped = str(labelRaw).strip() if labelRaw is not None else "" + if not labelStripped and nameRaw is not None: + labelStripped = str(nameRaw).strip() + if not labelStripped: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=routeApiMsg("Mandate name is required") + detail=routeApiMsg("Mandate Voller Name (label) is required"), ) - - # Get optional fields with defaults - label = mandateData.get('label') - enabled = mandateData.get('enabled', True) - + + nameToPass = None + if nameRaw is not None and str(nameRaw).strip() != "": + nameToPass = str(nameRaw).strip() + if not isValidMandateName(nameToPass): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg( + "Mandate Kurzzeichen (name) must be 2–32 characters: lowercase a–z, digits, hyphens only" + ), + ) + + enabled = mandateData.get("enabled", True) + appInterface = interfaceDbApp.getRootInterface() - - # Create mandate + newMandate = appInterface.createMandate( - name=name, - label=label, - enabled=enabled + name=nameToPass, + label=labelStripped, + enabled=bool(enabled) if enabled is not None else True, ) - + if not newMandate: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -329,11 +343,22 @@ def create_mandate( except Exception as subErr: logger.error(f"Failed to create subscription for mandate {newMandate.id}: {subErr}") - logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}") - + logger.info(f"Mandate {newMandate.id} created by PlatformAdmin {currentUser.id}") + return newMandate except HTTPException: raise + except ValueError as ve: + logger.warning(f"Create mandate validation: {ve}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg(str(ve)), + ) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=routeApiMsg("No permission to create mandates"), + ) except Exception as e: logger.error(f"Error creating mandate: {str(e)}") raise HTTPException( @@ -374,14 +399,13 @@ def update_mandate( """ Update an existing mandate. MULTI-TENANT: - - SysAdmin: full update - - MandateAdmin: only label + - PlatformAdmin: full update (including Kurzzeichen name) + - MandateAdmin: only label (Voller Name) """ - from modules.auth import _hasSysAdminRole as _checkSysAdminRole userId = str(currentUser.id) - isSysAdmin = _checkSysAdminRole(userId) + isPlatformAdmin = bool(getattr(currentUser, "isPlatformAdmin", False)) - if not isSysAdmin: + if not isPlatformAdmin: if not _isUserAdminOfMandate(userId, mandateId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -400,14 +424,38 @@ def update_mandate( detail=f"Mandate with ID {mandateId} not found" ) - if not isSysAdmin: + if not isPlatformAdmin: mandateData = {k: v for k, v in mandateData.items() if k in _MANDATE_ADMIN_EDITABLE_FIELDS} if not mandateData: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("No editable fields submitted") ) - + if "label" in mandateData: + lbl = mandateData["label"] + if lbl is None or str(lbl).strip() == "": + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg("Mandate Voller Name (label) must not be empty"), + ) + else: + if "name" in mandateData and mandateData["name"] is not None: + nm = str(mandateData["name"]).strip() + if nm and not isValidMandateName(nm): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg( + "Mandate Kurzzeichen (name) must be 2–32 characters: lowercase a–z, digits, hyphens only" + ), + ) + if "label" in mandateData and mandateData["label"] is not None: + lb = str(mandateData["label"]).strip() + if not lb: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg("Mandate Voller Name (label) must not be empty"), + ) + updatedMandate = appInterface.updateMandate(mandateId, mandateData) if not updatedMandate: @@ -416,11 +464,22 @@ def update_mandate( detail=routeApiMsg("Failed to update mandate") ) - logger.info(f"Mandate {mandateId} updated by user {currentUser.id} (sysadmin={isSysAdmin})") + logger.info(f"Mandate {mandateId} updated by user {currentUser.id} (platformAdmin={isPlatformAdmin})") return updatedMandate except HTTPException: raise + except ValueError as ve: + logger.warning(f"Update mandate validation: {ve}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg(str(ve)), + ) + except PermissionError: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=routeApiMsg("No permission to update mandate"), + ) except Exception as e: logger.error(f"Error updating mandate {mandateId}: {str(e)}") raise HTTPException( @@ -434,7 +493,7 @@ def delete_mandate( request: Request, mandateId: str = Path(..., description="ID of the mandate to delete"), force: bool = Query(False, description="Hard-delete with full cascade (irreversible)"), - currentUser: User = Depends(requireSysAdminRole) + currentUser: User = Depends(requirePlatformAdmin) ) -> Dict[str, Any]: """ Delete a mandate. @@ -507,7 +566,7 @@ def list_mandate_users( pagination: Optional pagination parameters (page, pageSize, search, filters, sort) """ # Check permission - if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole: + if not _hasMandateAdminRole(context, targetMandateId) and not context.isPlatformAdmin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required") @@ -912,7 +971,7 @@ def update_user_roles_in_mandate( # Add new role assignments for roleId in roleIds: rootInterface.addRoleToUserMandate(str(membership.id), roleId) - + # Audit - Log role assignment change audit_logger.logPermissionChange( userId=str(context.user.id), @@ -1020,7 +1079,7 @@ def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool: Check if the user has mandate admin role for the specified mandate. Works with or without X-Mandate-Id header (admin pages don't send it). """ - if context.hasSysAdminRole: + if context.isPlatformAdmin: return True # If mandate context matches, check roles from context directly diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py index 03f6e8e3..5df8a18b 100644 --- a/modules/routes/routeDataSources.py +++ b/modules/routes/routeDataSources.py @@ -7,7 +7,6 @@ from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body from modules.auth import limiter, getRequestContext, RequestContext -from modules.auth.authentication import _hasSysAdminRole from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.shared.i18nRegistry import apiRouteContext @@ -53,7 +52,7 @@ def _updateDataSourceScope( if scope not in _VALID_SCOPES: raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}") - if scope == "global" and not _hasSysAdminRole(context.user): + if scope == "global" and not context.isSysAdmin: raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope")) try: diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index bc32dfee..ea796aab 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -6,7 +6,7 @@ Implements the endpoints for user management. MULTI-TENANT: User management requires RequestContext. - mandateId from X-Mandate-Id header determines which users are visible -- SysAdmin can see all users across mandates +- isPlatformAdmin can see all users across mandates """ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query @@ -34,10 +34,10 @@ logger = logging.getLogger(__name__) def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool: """ Check if the current user has admin rights for the target user. - SysAdmin can manage all users. MandateAdmin can manage users in their mandates. + PlatformAdmin can manage all users. MandateAdmin can manage users in their mandates. Works without X-Mandate-Id header (admin pages don't send it). """ - if context.hasSysAdminRole: + if context.isPlatformAdmin: return True # Find mandates where current user is admin @@ -90,7 +90,7 @@ def _getUserFilterOrIds(context, paginationJson, column=None, idsMode=False): return handleIdsInMemory(items, paginationJson) return handleFilterValuesInMemory(items, column, paginationJson, requestLang) - if context.hasSysAdminRole: + if context.isPlatformAdmin: rootInterface = getRootInterface() if idsMode: return handleIdsMode(rootInterface.db, UserInDB, paginationJson) @@ -167,7 +167,7 @@ def get_user_options( if context.mandateId: result = appInterface.getUsersByMandate(str(context.mandateId), None) users = result.items if hasattr(result, 'items') else result - elif context.hasSysAdminRole: + elif context.isPlatformAdmin: users = appInterface.getAllUsers() else: raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) @@ -256,8 +256,8 @@ def get_users( items=users, pagination=None ) - elif context.hasSysAdminRole: - # SysAdmin without mandateId — DB-level pagination via interface + elif context.isPlatformAdmin: + # PlatformAdmin without mandateId — DB-level pagination via interface result = appInterface.getAllUsers(paginationParams) if paginationParams and hasattr(result, 'items'): @@ -375,8 +375,8 @@ def get_user( detail=f"User with ID {userId} not found" ) - # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) - if context.mandateId and not context.hasSysAdminRole: + # MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin) + if context.mandateId and not context.isPlatformAdmin: userMandate = appInterface.getUserMandate(userId, str(context.mandateId)) if not userMandate: raise HTTPException( @@ -402,6 +402,7 @@ class CreateUserRequest(BaseModel): language: str = "de" enabled: bool = True isSysAdmin: bool = False + isPlatformAdmin: bool = False password: Optional[str] = None @@ -415,10 +416,24 @@ def create_user( """ Create a new user. MULTI-TENANT: User is created and automatically added to the current mandate. + + Privileged platform flags (isSysAdmin, isPlatformAdmin) may only be set + by a Platform Admin. Non-PlatformAdmin requests have these flags reset + to False with a warning. """ appInterface = interfaceDbApp.getInterface(context.user) - - # Extract fields from request model and call createUser with individual parameters + + callerIsPlatformAdmin = context.isPlatformAdmin + requestedSysAdmin = bool(userData.isSysAdmin) and callerIsPlatformAdmin + requestedPlatformAdmin = bool(userData.isPlatformAdmin) and callerIsPlatformAdmin + + if (userData.isSysAdmin or userData.isPlatformAdmin) and not callerIsPlatformAdmin: + logger.warning( + f"Non-PlatformAdmin {context.user.id} attempted to create user with " + f"privileged flags (isSysAdmin={userData.isSysAdmin}, " + f"isPlatformAdmin={userData.isPlatformAdmin}); flags reset to False" + ) + newUser = appInterface.createUser( username=userData.username, password=userData.password, @@ -427,9 +442,10 @@ def create_user( language=userData.language, enabled=userData.enabled, authenticationAuthority=AuthAuthority.LOCAL, - isSysAdmin=userData.isSysAdmin + isSysAdmin=requestedSysAdmin, + isPlatformAdmin=requestedPlatformAdmin, ) - + # MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role if context.mandateId: userRole = appInterface.getRoleByLabel("user") @@ -438,14 +454,14 @@ def create_user( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("No 'user' role found in system — cannot assign user to mandate") ) - + appInterface.createUserMandate( userId=str(newUser.id), mandateId=str(context.mandateId), roleIds=[str(userRole.id)] ) logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}") - + return newUser @router.put("/{userId}", response_model=User) @@ -453,44 +469,67 @@ def create_user( def update_user( request: Request, userId: str = Path(..., description="ID of the user to update"), - userData: User = Body(...), + userData: Dict[str, Any] = Body(..., description="Partial user payload — only the fields present in the request body are updated."), context: RequestContext = Depends(getRequestContext) ) -> User: """ - Update an existing user. + Update an existing user (PARTIAL update). + + The request body is treated as a **partial** patch: only the keys actually + sent are applied; missing keys leave the stored value untouched. This is + intentional — sending a full ``User`` body would overwrite unrelated fields + (e.g. ``isSysAdmin``/``isPlatformAdmin``) with Pydantic defaults whenever a + client only ships a subset, which has historically caused privileged flags + to flip silently when toggling a single inline cell. + Self-service: Users can update their own profile (language, fullName, etc.). - Admin: MandateAdmin can update users in their mandates. SysAdmin for all. + Admin: MandateAdmin can update users in their mandates. + PlatformAdmin can update any user. + + Privileged flag changes (isSysAdmin, isPlatformAdmin) require: + - caller has isPlatformAdmin=True, AND + - target is NOT the caller (Self-Protection). """ isSelfUpdate = str(context.user.id) == str(userId) - - # Non-self updates require admin permission + if not isSelfUpdate and not _isAdminForUser(context, userId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required to update other users") ) - - # Use rootInterface for user lookup/update (avoids RBAC filtering on User table) + rootInterface = getRootInterface() - - # Check if the user exists + existingUser = rootInterface.getUser(userId) if not existingUser: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"User with ID {userId} not found" ) - - # SysAdmins may toggle the isSysAdmin flag on other users - callerIsSysAdmin = context.isSysAdmin or context.hasSysAdminRole - updatedUser = rootInterface.updateUser(userId, userData, allowSysAdminChange=(callerIsSysAdmin and not isSelfUpdate)) - + + if not isinstance(userData, dict): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=routeApiMsg("User update payload must be a JSON object") + ) + + # Defensive: drop ``id`` from payload — userId comes from the path and + # tampering with it from the body must never silently rebind the row. + sanitizedPayload = {k: v for k, v in userData.items() if k != "id"} + + callerIsPlatformAdmin = context.isPlatformAdmin + allowAdminFlagChange = callerIsPlatformAdmin and not isSelfUpdate + + updatedUser = rootInterface.updateUser( + userId, sanitizedPayload, allowAdminFlagChange=allowAdminFlagChange + ) + if not updatedUser: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Error updating the user") ) - + return updatedUser @router.post("/{userId}/reset-password") @@ -793,7 +832,7 @@ def delete_user( ) -> Dict[str, Any]: """ Delete a user. - MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin). + MULTI-TENANT: Can only delete users in the same mandate (unless PlatformAdmin). """ appInterface = interfaceDbApp.getInterface(context.user) @@ -805,8 +844,8 @@ def delete_user( detail=f"User with ID {userId} not found" ) - # MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin) - if context.mandateId and not context.hasSysAdminRole: + # MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin) + if context.mandateId and not context.isPlatformAdmin: userMandate = appInterface.getUserMandate(userId, str(context.mandateId)) if not userMandate: raise HTTPException( diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py index a50739d7..7f230324 100644 --- a/modules/routes/routeI18n.py +++ b/modules/routes/routeI18n.py @@ -4,9 +4,11 @@ Public and authenticated routes for UI language sets (DB-backed i18n). Architecture: -- xx = base set (meta): key = German plaintext, value = UI context for AI +- xx = base set (meta): key = source plaintext (German or English, as written + in the code via ``t("...")``), value = UI context for AI - All languages (incl. de) are AI-generated translations from xx -- AI translation pipeline uses context from xx to disambiguate translations +- AI translation pipeline uses context from xx to disambiguate translations; + the prompt forces the output language to be exactly the requested target. """ from __future__ import annotations @@ -23,7 +25,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Re from fastapi.responses import Response from pydantic import BaseModel, Field -from modules.auth import getCurrentUser, requireSysAdminRole +from modules.auth import getCurrentUser, requireSysAdmin, requirePlatformAdmin from modules.connectors.connectorDbPostgre import _get_cached_connector from modules.datamodels.datamodelAi import ( AiCallOptions, @@ -234,17 +236,31 @@ async def _translateBatch( jsonPayload = json.dumps(payload, ensure_ascii=False) systemPrompt = ( - f"Du bist ein professioneller Übersetzer für Software-UI-Texte. " - f"Du erhältst ein JSON-Array mit Objekten: {{\"key\": \"deutscher Text\", \"context\": \"UI-Kontext\"}}. " - f"Der Kontext beschreibt, wo der Text in der Anwendung verwendet wird (Datei, Komponente). " - f"Übersetze jeden «key» ins {targetLanguageLabel} (ISO {targetCode}). " - f"Behalte Platzhalter wie {{variable}} exakt bei. " - f"Antworte NUR mit einem JSON-Objekt — Keys = deutsche Originaltexte, Values = Übersetzungen. " - f"Kein Markdown, kein Kommentar." + f"You are a professional translator for software UI texts. " + f"You receive a JSON array of objects: {{\"key\": \"source text\", \"context\": \"UI context\"}}. " + f"The source text is written in German OR English. " + f"The context describes where the text is used in the application (file, component). " + f"\n\n" + f"HARD REQUIREMENTS (must all be satisfied):\n" + f"1. OUTPUT LANGUAGE: every translated value MUST be written in {targetLanguageLabel} " + f"(ISO code \"{targetCode}\"). Never output in German or English if that is not " + f"the target language. No mixing of languages.\n" + f"2. If the source is already in the target language, keep it (do not re-translate, " + f"do not paraphrase).\n" + f"3. KEEP the exact JSON keys from the input — do NOT translate or modify the keys.\n" + f"4. KEEP placeholders like {{variable}}, {{count}}, %s, %(name)s exactly as they are.\n" + f"5. Preserve leading/trailing whitespace, punctuation and capitalisation pattern.\n" + f"6. Answer ONLY with a JSON object mapping source-key -> translated value in " + f"{targetLanguageLabel}. No markdown fences, no comments, no explanations.\n" + f"7. If a key cannot be translated (empty, pure symbols, URLs), return the source unchanged." ) aiRequest = AiCallRequest( - prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}", + prompt=( + f"Translate the following UI labels into {targetLanguageLabel} " + f"(ISO {targetCode}). Source may be German or English. " + f"Respond with a pure JSON object only.\n{jsonPayload}" + ), context=systemPrompt, options=AiCallOptions( operationType=OperationTypeEnum.DATA_GENERATE, @@ -826,7 +842,7 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O @router.put("/sets/sync-xx") async def sync_xx_master( request: Request, - adminUser: User = Depends(requireSysAdminRole), + adminUser: User = Depends(requireSysAdmin), ): """Synchronise the xx base set from the frontend build artefact. @@ -844,7 +860,7 @@ async def sync_xx_master( @router.get("/sets/{code}/sync-diff") async def get_language_sync_diff( code: str, - adminUser: User = Depends(requireSysAdminRole), + adminUser: User = Depends(requirePlatformAdmin), ): """How many keys would be added/removed vs xx before running a full sync (SysAdmin).""" c = code.strip().lower() @@ -857,7 +873,7 @@ async def get_language_sync_diff( @router.put("/sets/{code}") async def update_language_set( code: str, - adminUser: User = Depends(requireSysAdminRole), + adminUser: User = Depends(requirePlatformAdmin), ): c = code.strip().lower() if c in ("update-all", "sync-xx", "sync-de"): @@ -873,7 +889,7 @@ async def update_language_set( @router.delete("/sets/{code}") async def delete_language_set( code: str, - adminUser: User = Depends(requireSysAdminRole), + adminUser: User = Depends(requirePlatformAdmin), ): c = code.strip().lower() if c in _PROTECTED_CODES: @@ -911,7 +927,7 @@ async def download_language_set( @router.get("/export") async def export_all_language_sets( - adminUser: User = Depends(requireSysAdminRole), + adminUser: User = Depends(requirePlatformAdmin), ): db = getMgmtInterface(adminUser, mandateId=None).db rows = db.getRecordset(UiLanguageSet) @@ -939,7 +955,7 @@ async def export_all_language_sets( @router.post("/import") async def import_language_sets( file: UploadFile = File(...), - adminUser: User = Depends(requireSysAdminRole), + adminUser: User = Depends(requirePlatformAdmin), ): if not file.filename or not file.filename.endswith(".json"): raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt.")) diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index ffc0f11a..7e852b54 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -186,7 +186,7 @@ def create_invitation( ) # Check admin permission - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: if str(context.mandateId) != mandateId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -891,7 +891,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool: """ Check if the user has mandate admin role in the current context. """ - if context.hasSysAdminRole: + if context.isPlatformAdmin: return True if not context.roleIds: diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index aa3d98f4..cc571c2f 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -121,7 +121,7 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s status_code=400, detail=f"Instance '{instanceId}' is not a realestate instance" ) - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) hasAccess = any( str(fa.featureInstanceId) == instanceId and fa.enabled diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index fa68b5b9..b6227cb0 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -210,13 +210,13 @@ def _ensureHomeMandate(rootInterface, user) -> None: except Exception as e: logger.warning(f"Could not check pending invitations for {user.username}: {e}") - homeMandateName = f"Home {user.username}" + homeMandateLabel = f"Home {user.username}" rootInterface._provisionMandateForUser( userId=userId, - mandateName=homeMandateName, + mandateLabel=homeMandateLabel, planKey="TRIAL_14D", ) - logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}") + logger.info(f"Created Home mandate '{homeMandateLabel}' for user {user.username}") @router.post("/login") @@ -464,10 +464,10 @@ def register_user( provisionResult = None if not hasPendingInvitations: try: - homeMandateName = f"Home {user.username}" + homeMandateLabel = f"Home {user.username}" provisionResult = appInterface._provisionMandateForUser( userId=str(user.id), - mandateName=homeMandateName, + mandateLabel=homeMandateLabel, planKey="TRIAL_14D", ) logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}") @@ -881,7 +881,7 @@ def onboarding_provision( "alreadyProvisioned": True, } - mandateName = (companyName.strip() if companyName and companyName.strip() + mandateLabel = (companyName.strip() if companyName and companyName.strip() else f"Home {currentUser.username}") if planKey not in ("TRIAL_14D", "STARTER_MONTHLY", "STARTER_YEARLY", "PROFESSIONAL_MONTHLY", "PROFESSIONAL_YEARLY", "MAX_MONTHLY", "MAX_YEARLY"): @@ -889,7 +889,7 @@ def onboarding_provision( result = appInterface._provisionMandateForUser( userId=userId, - mandateName=mandateName, + mandateLabel=mandateLabel, planKey=planKey, ) diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index 04517d9b..b1f8dbc6 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -59,7 +59,13 @@ class StoreFeatureResponse(BaseModel): def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]: - """Get all features available in the store.""" + """Get all features available in the store. + + Soft-disabled features (``enabled=False`` in their feature definition) are + skipped so that legacy or temporarily-deactivated modules do not appear in + the storefront, even if their ``resource.store.*`` catalog object is still + registered. + """ resourceObjects = catalogService.getResourceObjects() storeFeatures = [] for obj in resourceObjects: @@ -68,7 +74,7 @@ def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]: featureCode = meta.get("featureCode") if featureCode: featureDef = catalogService.getFeatureDefinition(featureCode) - if featureDef: + if featureDef and featureDef.get("enabled", True): storeFeatures.append(featureDef) return storeFeatures diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index a96cd0ae..dbdbe37d 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -46,7 +46,7 @@ def _resolveMandateId(context: RequestContext) -> str: def _assertMandateAdmin(context: RequestContext, mandateId: str) -> None: - if context.hasSysAdminRole: + if context.isPlatformAdmin: return try: from modules.interfaces.interfaceDbApp import getRootInterface @@ -303,7 +303,7 @@ def forceCancel( context: RequestContext = Depends(getRequestContext), ): """Sysadmin: immediately expire any non-terminal subscription.""" - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( @@ -485,7 +485,7 @@ def getAllSubscriptions( context: RequestContext = Depends(getRequestContext), ): """SysAdmin: list ALL subscriptions across all mandates with enriched metadata.""" - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) if mode == "filterValues": diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 8db0350f..b8ad72af 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -478,7 +478,7 @@ def get_navigation( Endpoint: GET /api/navigation """ try: - isSysAdmin = reqContext.hasSysAdminRole + isSysAdmin = reqContext.isPlatformAdmin userId = str(reqContext.user.id) if reqContext.user else None # Get user's role IDs for permission checking diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py index 96075a26..626f0872 100644 --- a/modules/routes/routeWorkflowDashboard.py +++ b/modules/routes/routeWorkflowDashboard.py @@ -106,7 +106,7 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]: - mandate admin: mandateId IN user's mandates - normal user: ownerId = userId """ - if context.hasSysAdminRole: + if context.isPlatformAdmin: return None userId = str(context.user.id) if context.user else None @@ -128,7 +128,7 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]: - sysadmin: None (no filter, sees all) - normal user: mandateId IN user's mandates """ - if context.hasSysAdminRole: + if context.isPlatformAdmin: return None userId = str(context.user.id) if context.user else None @@ -144,7 +144,7 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]: def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool: """Same rules as canDelete on rows in get_system_workflows.""" - if context.hasSysAdminRole: + if context.isPlatformAdmin: return True userId = str(context.user.id) if context.user else None if not userId or not wfMandateId: @@ -477,7 +477,7 @@ def get_system_workflows( userId = str(context.user.id) if context.user else None adminMandateIds = [] - if userId and not context.hasSysAdminRole: + if userId and not context.isPlatformAdmin: userMandateIds = _getUserMandateIds(userId) adminMandateIds = _getAdminMandateIds(userId, userMandateIds) @@ -514,7 +514,7 @@ def get_system_workflows( row["runCount"] = runCountMap.get(wfId, 0) row["lastStartedAt"] = lastStartedMap.get(wfId) - if context.hasSysAdminRole: + if context.isPlatformAdmin: row["canEdit"] = True row["canDelete"] = True row["canExecute"] = True @@ -670,7 +670,7 @@ def get_run_steps( raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) run = dict(runs[0]) - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: userId = str(context.user.id) if context.user else None runOwner = run.get("ownerId") runMandate = run.get("mandateId") @@ -711,7 +711,7 @@ async def get_run_stream( raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) run = dict(runs[0]) - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: userId = str(context.user.id) if context.user else None runOwner = run.get("ownerId") runMandate = run.get("mandateId") @@ -774,7 +774,7 @@ def stop_workflow_run( raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) run = dict(runs[0]) - if not context.hasSysAdminRole: + if not context.isPlatformAdmin: userId = str(context.user.id) if context.user else None runOwner = run.get("ownerId") runMandate = run.get("mandateId") diff --git a/modules/security/rbacCatalog.py b/modules/security/rbacCatalog.py index bc5b0353..9b1ca22f 100644 --- a/modules/security/rbacCatalog.py +++ b/modules/security/rbacCatalog.py @@ -84,10 +84,38 @@ class RbacCatalogService: logger.error(f"Failed to register DATA object {objectKey}: {e}") return False - def registerFeatureDefinition(self, featureCode: str, label: str, icon: str) -> bool: - """Register a feature definition.""" + def registerFeatureDefinition( + self, + featureCode: str, + label: str, + icon: str, + *, + instantiable: bool = True, + enabled: bool = True, + ) -> bool: + """Register a feature definition. + + Args: + featureCode: Stable code (e.g. ``"trustee"``). + label: Display label. + icon: Display icon. + instantiable: ``False`` for meta-features that must NOT be exposed + as a creatable Feature-Instance (e.g. the ``system`` umbrella + feature which only owns global UI/DATA/RESOURCE catalog + objects). Defaults to ``True``. + enabled: ``False`` to soft-disable a feature so it is filtered out + of selection lists (Store / Admin Feature-Instances dropdown) + without removing its catalog objects, role templates or + already-provisioned instances. Defaults to ``True``. + """ try: - self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon} + self._featureDefinitions[featureCode] = { + "code": featureCode, + "label": label, + "icon": icon, + "instantiable": bool(instantiable), + "enabled": bool(enabled), + } return True except Exception as e: logger.error(f"Failed to register feature definition {featureCode}: {e}") diff --git a/modules/shared/mandateNameUtils.py b/modules/shared/mandateNameUtils.py new file mode 100644 index 00000000..661aaeee --- /dev/null +++ b/modules/shared/mandateNameUtils.py @@ -0,0 +1,121 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Slug and validation helpers for Mandate.name (Kurzzeichen). + +Format: lowercase [a-z0-9], segments separated by a single hyphen, length 2–32. +German umlauts are transliterated (ä→ae, ö→oe, ü→ue, ß→ss) before slugging. +""" + +from __future__ import annotations + +import re +from typing import Iterable, Set + +MANDATE_NAME_MIN_LEN = 2 +MANDATE_NAME_MAX_LEN = 32 +_MANDATE_NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") + + +def _transliterateGerman(text: str) -> str: + """Map common German characters to ASCII before slugging.""" + if not text: + return "" + result: list[str] = [] + for ch in text: + lower = ch.lower() + if lower == "ä": + result.append("ae") + elif lower == "ö": + result.append("oe") + elif lower == "ü": + result.append("ue") + elif lower == "ß": + result.append("ss") + else: + result.append(ch) + return "".join(result) + + +def _collapseHyphensAndTrim(raw: str) -> str: + s = re.sub(r"[^a-z0-9]+", "-", raw.lower()) + s = re.sub(r"-+", "-", s).strip("-") + return s + + +def _ensureMinSlugLength(slug: str) -> str: + if len(slug) >= MANDATE_NAME_MIN_LEN: + return slug + if len(slug) == 1: + return slug + slug + return slug + ("x" * (MANDATE_NAME_MIN_LEN - len(slug))) + + +def _truncateSlugToMaxLen(slug: str) -> str: + if len(slug) <= MANDATE_NAME_MAX_LEN: + return slug + cut = slug[: MANDATE_NAME_MAX_LEN].rstrip("-") + if "-" in cut: + cut = cut[: cut.rfind("-")] + cut = cut.strip("-") + if len(cut) < MANDATE_NAME_MIN_LEN: + return cut + ("x" * (MANDATE_NAME_MIN_LEN - len(cut))) + return cut + + +def transliterateGerman(text: str) -> str: + """Transliterate German umlauts in *text* for further processing.""" + return _transliterateGerman(text) + + +def slugifyMandateName(label: str) -> str: + """ + Build a mandate slug base from a human-readable label. + Result satisfies isValidMandateName except pathological cases (falls back to 'mn'). + """ + if not label or not str(label).strip(): + t = "mn" + else: + step1 = _transliterateGerman(label.strip()) + step2 = _collapseHyphensAndTrim(step1) + if not step2: + t = "mn" + else: + t = _ensureMinSlugLength(step2) + t = _truncateSlugToMaxLen(t) + if not isValidMandateName(t): + return "mn" + return t + + +def isValidMandateName(name: str) -> bool: + """True if *name* matches slug rules (length 2–32, [a-z0-9] and single-hyphen segments).""" + if not isinstance(name, str) or len(name) < MANDATE_NAME_MIN_LEN or len(name) > MANDATE_NAME_MAX_LEN: + return False + return _MANDATE_NAME_RE.match(name) is not None + + +def allocateUniqueMandateSlug(base: str, taken: Iterable[str]) -> str: + """ + Return a slug not present in *taken*, starting with *base*, then base-2, base-3, ... + *base* must satisfy isValidMandateName (typically from slugifyMandateName). + """ + used: Set[str] = {x for x in taken if x} + if base not in used: + return base + n = 2 + while True: + suffix = f"-{n}" + room = MANDATE_NAME_MAX_LEN - len(suffix) + if room < MANDATE_NAME_MIN_LEN: + room = MANDATE_NAME_MIN_LEN + root = base[:room].rstrip("-") + if len(root) < MANDATE_NAME_MIN_LEN: + root = "mn" + cand = (root + suffix)[:MANDATE_NAME_MAX_LEN] + cand = cand.rstrip("-") + if isValidMandateName(cand) and cand not in used: + return cand + n += 1 + if n > 100000: + raise ValueError("allocateUniqueMandateSlug: could not allocate a unique slug") diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 3a361ab9..0558f65b 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -622,11 +622,11 @@ def registerFeature(catalogService) -> bool: meta=aicoreObj.get("meta") ) - # Register feature definition catalogService.registerFeatureDefinition( featureCode=FEATURE_CODE, label=FEATURE_LABEL, - icon=FEATURE_ICON + icon=FEATURE_ICON, + instantiable=False, ) logger.info(f"Registered system RBAC objects: {len(UI_OBJECTS)} UI, {len(DATA_OBJECTS)} DATA, {len(RESOURCE_OBJECTS)} RESOURCE") diff --git a/modules/system/registry.py b/modules/system/registry.py index 80bc8c0c..5a793ade 100644 --- a/modules/system/registry.py +++ b/modules/system/registry.py @@ -172,7 +172,9 @@ def registerAllFeaturesInCatalog(catalogService) -> Dict[str, bool]: catalogService.registerFeatureDefinition( featureCode=featureDef.get("code", featureName), label=featureDef.get("label", {"en": featureName, "de": featureName}), - icon=featureDef.get("icon", "mdi-puzzle") + icon=featureDef.get("icon", "mdi-puzzle"), + instantiable=featureDef.get("instantiable", True), + enabled=featureDef.get("enabled", True), ) logger.info(f"Registered feature definition: {featureDef.get('code', featureName)}") except Exception as e: diff --git a/scripts/check_db_no_sysadmin_role.py b/scripts/check_db_no_sysadmin_role.py new file mode 100644 index 00000000..e0d02d7c --- /dev/null +++ b/scripts/check_db_no_sysadmin_role.py @@ -0,0 +1,132 @@ +"""Runtime-Check (A5): bestaetigt, dass die ``sysadmin``-Rolle aus der +Datenbank entfernt wurde und liefert eine kurze Inventur fuer die +isPlatformAdmin / isSysAdmin Flags. + +Das Skript verwendet die bestehende ``APP_CONFIG`` (entschluesselt +``DB_PASSWORD_SECRET``) und fragt direkt via ``psycopg2`` ab, ohne den +ganzen FastAPI-Stack hochzufahren. + +Aufruf:: + + python gateway/scripts/check_db_no_sysadmin_role.py + +Exit-Code: + +- 0 -> sauber (Role-Count == 0) +- 1 -> sysadmin-Rolle existiert noch (Migration unvollstaendig) +- 2 -> Verbindungsfehler (Konfiguration / DB nicht erreichbar) +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_GATEWAY = Path(__file__).resolve().parents[1] +if str(_GATEWAY) not in sys.path: + sys.path.insert(0, str(_GATEWAY)) + +import psycopg2 # noqa: E402 +import psycopg2.extras # noqa: E402 + +from modules.shared.configuration import APP_CONFIG # noqa: E402 + + +def _connect(): + host = APP_CONFIG.get("DB_HOST", "localhost") + user = APP_CONFIG.get("DB_USER") + password = APP_CONFIG.get("DB_PASSWORD_SECRET") + port = int(APP_CONFIG.get("DB_PORT", 5432)) + database = APP_CONFIG.get("DB_DATABASE", "poweron_app") + return psycopg2.connect( + host=host, port=port, dbname=database, user=user, password=password + ) + + +def _main() -> int: + try: + conn = _connect() + except Exception as exc: + print(f"[ERR] Could not connect to database: {exc}") + return 2 + + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + 'SELECT COUNT(*)::int AS n FROM "Role" WHERE "roleLabel" = %s', + ("sysadmin",), + ) + roleCount = cur.fetchone()["n"] + + cur.execute( + 'SELECT COUNT(*)::int AS n FROM "UserInDB" ' + 'WHERE COALESCE("isPlatformAdmin", false) = true' + ) + platformAdmins = cur.fetchone()["n"] + + cur.execute( + 'SELECT COUNT(*)::int AS n FROM "UserInDB" ' + 'WHERE COALESCE("isSysAdmin", false) = true' + ) + sysAdmins = cur.fetchone()["n"] + + cur.execute( + 'SELECT "username", "email", ' + 'COALESCE("isSysAdmin", false) AS "isSysAdmin", ' + 'COALESCE("isPlatformAdmin", false) AS "isPlatformAdmin" ' + 'FROM "UserInDB" ' + 'WHERE COALESCE("isSysAdmin", false) = true ' + ' OR COALESCE("isPlatformAdmin", false) = true ' + 'ORDER BY "username"' + ) + adminUsers = cur.fetchall() + + cur.execute( + 'SELECT COUNT(*)::int AS n FROM "AccessRule" ar ' + 'JOIN "Role" r ON ar."roleId" = r."id" ' + 'WHERE r."roleLabel" = %s', + ("sysadmin",), + ) + orphanRules = cur.fetchone()["n"] + + cur.execute( + 'SELECT COUNT(*)::int AS n FROM "UserMandateRole" umr ' + 'JOIN "Role" r ON umr."roleId" = r."id" ' + 'WHERE r."roleLabel" = %s', + ("sysadmin",), + ) + orphanGrants = cur.fetchone()["n"] + finally: + conn.close() + + print("=" * 64) + print("A5 - SysAdmin Migration DB Check") + print("=" * 64) + print(f"Role.roleLabel == 'sysadmin' : {roleCount}") + print(f"AccessRule(s) referencing sysadmin role : {orphanRules}") + print(f"UserMandateRole(s) granting sysadmin role : {orphanGrants}") + print(f"User.isSysAdmin = true : {sysAdmins}") + print(f"User.isPlatformAdmin = true : {platformAdmins}") + print() + print("Admin-flagged users:") + if not adminUsers: + print(" (none)") + for row in adminUsers: + flags = [] + if row["isSysAdmin"]: + flags.append("isSysAdmin") + if row["isPlatformAdmin"]: + flags.append("isPlatformAdmin") + print(f" - {row['username']:<32} {row.get('email') or '':<40} {','.join(flags)}") + print("=" * 64) + + if roleCount == 0 and orphanRules == 0 and orphanGrants == 0: + print("[OK] Migration verified: no sysadmin role artefacts in DB.") + return 0 + print("[FAIL] Legacy sysadmin role artefacts still present in DB.") + return 1 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/scripts/check_no_sysadmin_role.py b/scripts/check_no_sysadmin_role.py new file mode 100644 index 00000000..23fa8f2e --- /dev/null +++ b/scripts/check_no_sysadmin_role.py @@ -0,0 +1,108 @@ +"""CI-Gate: Stelle sicher, dass keine Verweise auf die abgeschaffte +``sysadmin``-Rolle bzw. die alten Helper im Codebase mehr existieren. + +Verbotene Symbole nach Abschluss von ``2026-04-sysadmin-authority-split``: + +- ``hasSysAdminRole`` (RequestContext property) +- ``requireSysAdminRole`` (FastAPI dependency) +- ``_hasSysAdminRole`` (Hilfs-Funktion gegen die alte Rolle) + +Erlaubt sind weiterhin: + +- ``isSysAdmin`` (User-Flag fuer Infrastruktur-Operator) +- ``isPlatformAdmin`` (User-Flag fuer Cross-Mandate-Governance) +- ``requireSysAdmin`` / ``requirePlatformAdmin`` (FastAPI Dependencies) + +Exit-Code: + +- 0 -> sauber +- 1 -> Fundstellen vorhanden (CI bricht ab) + +Aufruf:: + + python gateway/scripts/check_no_sysadmin_role.py +""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path +from typing import Iterable, List, Tuple + +_REPO_ROOT = Path(__file__).resolve().parents[2] + +_FORBIDDEN_PATTERNS: Tuple[Tuple[str, str], ...] = ( + (r"\bhasSysAdminRole\b", "Use ctx.isPlatformAdmin (Governance) or ctx.isSysAdmin (Infra)"), + (r"\brequireSysAdminRole\b", "Use requirePlatformAdmin (Governance) or requireSysAdmin (Infra)"), + (r"\b_hasSysAdminRole\b", "Use User.isPlatformAdmin flag check directly"), +) + +_INCLUDE_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".jsx"} + +_EXCLUDE_DIR_NAMES = { + ".git", + "node_modules", + "dist", + "build", + "__pycache__", + ".venv", + "venv", + ".pytest_cache", + ".mypy_cache", + "wiki", + "scripts", +} + + +def _shouldScan(path: Path) -> bool: + if path.suffix not in _INCLUDE_SUFFIXES: + return False + parts = set(path.parts) + if parts & _EXCLUDE_DIR_NAMES: + return False + return True + + +def _iterFiles(root: Path) -> Iterable[Path]: + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in _EXCLUDE_DIR_NAMES] + for name in filenames: + full = Path(dirpath) / name + if _shouldScan(full): + yield full + + +def _scanFile(path: Path) -> List[Tuple[int, str, str, str]]: + findings: List[Tuple[int, str, str, str]] = [] + try: + text = path.read_text(encoding="utf-8", errors="ignore") + except Exception: + return findings + for pattern, hint in _FORBIDDEN_PATTERNS: + compiled = re.compile(pattern) + for lineNo, line in enumerate(text.splitlines(), start=1): + if compiled.search(line): + findings.append((lineNo, pattern, hint, line.rstrip())) + return findings + + +def _main() -> int: + findings = [] + for filePath in _iterFiles(_REPO_ROOT): + for entry in _scanFile(filePath): + findings.append((filePath, *entry)) + if not findings: + print("[OK] No legacy sysadmin-role references found.") + return 0 + print("[FAIL] Found legacy sysadmin-role references:") + for filePath, lineNo, pattern, hint, line in findings: + rel = filePath.relative_to(_REPO_ROOT) + print(f" {rel}:{lineNo}: {pattern}\n hint: {hint}\n line: {line}") + print(f"\n[FAIL] {len(findings)} forbidden reference(s).") + return 1 + + +if __name__ == "__main__": + sys.exit(_main()) diff --git a/tests/integration/mandates/__init__.py b/tests/integration/mandates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/mandates/test_createMandate.py b/tests/integration/mandates/test_createMandate.py new file mode 100644 index 00000000..f58f9021 --- /dev/null +++ b/tests/integration/mandates/test_createMandate.py @@ -0,0 +1,190 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Integration tests for ``AppObjects.createMandate``. + +Covers acceptance criteria from +``wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md``: + +- AC#1 -> create with label only auto-generates a valid slug name (umlaut transliteration). +- AC#2 -> two labels yielding the same slug get -2 suffix. +- AC#4 -> explicit invalid name (uppercase / spaces) is rejected with ValueError (mapped to 400 by route). +- Label is mandatory (empty label raises ValueError). +- Explicit valid name is honored verbatim. + +Strategy: instantiate ``AppObjects`` via ``__new__`` (skip real ``__init__``) and +inject a minimal FakeDb that simulates ``getRecordset(Mandate)`` and +``recordCreate(Mandate, ...)``. RBAC and role-copy are stubbed. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from unittest.mock import Mock, patch +from uuid import uuid4 + +import pytest + +from modules.datamodels.datamodelUam import Mandate +from modules.interfaces.interfaceDbApp import AppObjects +from modules.shared.mandateNameUtils import isValidMandateName + + +class _FakeDb: + """Minimal connector: getRecordset(Mandate) + recordCreate(Mandate, payload).""" + + def __init__(self, rows: Optional[List[Dict[str, Any]]] = None): + self.rows: List[Dict[str, Any]] = [dict(r) for r in (rows or [])] + self.created: List[Dict[str, Any]] = [] + + def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None): + if model is not Mandate: + return [] + if not recordFilter: + return [dict(r) for r in self.rows] + out = [] + for r in self.rows: + if all(r.get(k) == v for k, v in recordFilter.items()): + out.append(dict(r)) + return out + + def recordCreate(self, model, payload): + if hasattr(payload, "model_dump"): + data = payload.model_dump() + elif isinstance(payload, dict): + data = dict(payload) + else: + data = {k: getattr(payload, k) for k in ("name", "label", "enabled", "isSystem")} + if not data.get("id"): + data["id"] = str(uuid4()) + self.rows.append(data) + self.created.append(dict(data)) + return data + + +def _buildInterface(db: _FakeDb) -> AppObjects: + """Build an AppObjects without real __init__ so we don't need a DB connection.""" + iface = AppObjects.__new__(AppObjects) + iface.db = db + iface.currentUser = Mock(id="platform-admin", isPlatformAdmin=True, isSysAdmin=False) + iface.userId = "platform-admin" + iface.mandateId = None + iface.featureInstanceId = None + iface.rbac = Mock() + return iface + + +@pytest.fixture(autouse=True) +def _stubCopySystemRoles(): + """Avoid touching the bootstrap module (which would need a real DB).""" + with patch( + "modules.interfaces.interfaceBootstrap.copySystemRolesToMandate", + return_value=0, + ): + yield + + +class TestCreateMandateAutoName: + def test_emptyNameGetsSlugFromLabel(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + mandate = iface.createMandate(name=None, label="Müller AG") + assert mandate.label == "Müller AG" + assert mandate.name == "mueller-ag" + assert isValidMandateName(mandate.name) + + def test_blankNameStringGetsAutoGenerated(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + mandate = iface.createMandate(name=" ", label="Acme Corp") + assert mandate.name == "acme-corp" + + def test_labelTrimmed(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + mandate = iface.createMandate(name=None, label=" Tenant X ") + assert mandate.label == "Tenant X" + assert mandate.name == "tenant-x" + + +class TestCreateMandateCollision: + def test_secondMandateWithSameLabelGetsSuffix(self): + db = _FakeDb([{"id": "first", "name": "mueller-ag", "label": "Müller AG"}]) + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + mandate = iface.createMandate(name=None, label="Müller AG") + assert mandate.name == "mueller-ag-2" + + def test_thirdMandateWithSameLabelGetsThirdSuffix(self): + db = _FakeDb([ + {"id": "first", "name": "mueller-ag", "label": "Müller AG"}, + {"id": "second", "name": "mueller-ag-2", "label": "Müller AG"}, + ]) + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + mandate = iface.createMandate(name=None, label="Müller AG") + assert mandate.name == "mueller-ag-3" + + +class TestCreateMandateExplicitName: + def test_validExplicitNameHonored(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + mandate = iface.createMandate(name="custom-slug", label="Display Name") + assert mandate.name == "custom-slug" + assert mandate.label == "Display Name" + + def test_invalidExplicitNameRejected(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + with pytest.raises(ValueError) as excInfo: + iface.createMandate(name="ABC Müller!", label="Display") + assert "Kurzzeichen" in str(excInfo.value) + + def test_explicitNameCollisionRejected(self): + db = _FakeDb([{"id": "first", "name": "taken-slug", "label": "Existing"}]) + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + with pytest.raises(ValueError) as excInfo: + iface.createMandate(name="taken-slug", label="New One") + assert "already in use" in str(excInfo.value) + + +class TestCreateMandateLabelMandatory: + def test_emptyLabelAndNoNameRejected(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + with pytest.raises(ValueError) as excInfo: + iface.createMandate(name=None, label="") + assert "label" in str(excInfo.value).lower() + + def test_noneLabelAndNoNameRejected(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + with pytest.raises(ValueError): + iface.createMandate(name=None, label=None) + + def test_emptyLabelButNameProvidedFallsBackToName(self): + """Backwards-compat: legacy callers pass only ``name``; route falls back.""" + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=True): + mandate = iface.createMandate(name="legacy-name", label="") + assert mandate.label == "legacy-name" + assert mandate.name == "legacy-name" + + +class TestCreateMandateRbac: + def test_noPermissionRaises(self): + db = _FakeDb() + iface = _buildInterface(db) + with patch.object(iface, "checkRbacPermission", return_value=False): + with pytest.raises(PermissionError): + iface.createMandate(name=None, label="X") diff --git a/tests/integration/mandates/test_provisionMandate.py b/tests/integration/mandates/test_provisionMandate.py new file mode 100644 index 00000000..b88da4ee --- /dev/null +++ b/tests/integration/mandates/test_provisionMandate.py @@ -0,0 +1,109 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Integration tests for the slug-derivation contract that +``AppObjects._provisionMandateForUser`` relies on. + +Covers AC#10 from ``wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md``: +auto-provisioning a user named "Patrick.Möller" yields +``label = "Home Patrick.Möller"`` and ``name = "home-patrick-moeller"`` +(or ``-2``, ``-3``, ... on collisions). + +The full ``_provisionMandateForUser`` flow has many side effects (subscriptions, +billing, feature instances). For unit-level integration we focus on the +slug-allocation contract via ``_generateUniqueMandateName`` — that is the +single new behaviour the provisioning method delegates to. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from unittest.mock import Mock + +import pytest + +from modules.datamodels.datamodelUam import Mandate +from modules.interfaces.interfaceDbApp import AppObjects + + +class _FakeDb: + def __init__(self, rows: Optional[List[Dict[str, Any]]] = None): + self.rows: List[Dict[str, Any]] = [dict(r) for r in (rows or [])] + + def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None): + if model is not Mandate: + return [] + return [dict(r) for r in self.rows] + + +def _buildInterface(rows: Optional[List[Dict[str, Any]]] = None) -> AppObjects: + iface = AppObjects.__new__(AppObjects) + iface.db = _FakeDb(rows) + iface.currentUser = Mock(id="u-1", isPlatformAdmin=True, isSysAdmin=False) + iface.userId = "u-1" + iface.mandateId = None + iface.featureInstanceId = None + iface.rbac = Mock() + return iface + + +class TestProvisioningSlugFromHomeLabel: + def test_simpleHomeLabel(self): + iface = _buildInterface() + assert iface._generateUniqueMandateName("Home patrick") == "home-patrick" + + def test_umlautPersonNameTransliterated(self): + """AC#10: Patrick.Möller → home-patrick-moeller""" + iface = _buildInterface() + result = iface._generateUniqueMandateName("Home Patrick.Möller") + assert result == "home-patrick-moeller" + + def test_eszettAndUmlautsAndDots(self): + iface = _buildInterface() + result = iface._generateUniqueMandateName("Home Müßler.Ümpf") + assert result == "home-muessler-uempf" + + def test_emptyLabelFallsBackToFallbackSlug(self): + iface = _buildInterface() + result = iface._generateUniqueMandateName("") + assert result == "mn" + + +class TestProvisioningSlugCollisions: + def test_secondHomeWithSameLabelGetsSuffix(self): + rows = [{"id": "first", "name": "home-patrick-moeller", "label": "Home Patrick.Möller"}] + iface = _buildInterface(rows) + result = iface._generateUniqueMandateName("Home Patrick.Möller") + assert result == "home-patrick-moeller-2" + + def test_thirdCollisionGetsThirdSuffix(self): + rows = [ + {"id": "first", "name": "home-patrick-moeller", "label": "Home Patrick.Möller"}, + {"id": "second", "name": "home-patrick-moeller-2", "label": "Home Patrick.Möller"}, + ] + iface = _buildInterface(rows) + result = iface._generateUniqueMandateName("Home Patrick.Möller") + assert result == "home-patrick-moeller-3" + + def test_excludeIdHonored(self): + """When updating, the row being updated must not collide with itself.""" + rows = [{"id": "self", "name": "home-patrick-moeller", "label": "Home Patrick.Möller"}] + iface = _buildInterface(rows) + result = iface._generateUniqueMandateName("Home Patrick.Möller", excludeId="self") + assert result == "home-patrick-moeller", "own row should be excluded from collision check" + + +class TestProvisioningPlanGuard: + """Sanity guard: the new label-mandatory check fires before any DB write.""" + + def test_emptyLabelRejected(self): + iface = _buildInterface() + with pytest.raises(ValueError) as excInfo: + iface._provisionMandateForUser(userId="u-1", mandateLabel="", planKey="TRIAL_14D") + assert "label" in str(excInfo.value).lower() or "voller name" in str(excInfo.value).lower() + + def test_unknownPlanRejectedBeforeLabelCheck(self): + iface = _buildInterface() + with pytest.raises(ValueError) as excInfo: + iface._provisionMandateForUser(userId="u-1", mandateLabel="Home X", planKey="DOES_NOT_EXIST") + assert "plan" in str(excInfo.value).lower() diff --git a/tests/integration/mandates/test_updateMandate.py b/tests/integration/mandates/test_updateMandate.py new file mode 100644 index 00000000..385f7fa9 --- /dev/null +++ b/tests/integration/mandates/test_updateMandate.py @@ -0,0 +1,215 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Integration tests for ``AppObjects.updateMandate``. + +Covers acceptance criteria from +``wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md``: + +- AC#3 -> non-PlatformAdmin update silently drops protected ``name``; + label-only updates still succeed. +- AC#4 -> PlatformAdmin update with invalid name format rejected (ValueError → 400). +- AC#4b -> PlatformAdmin update with empty label rejected. +- AC#4c -> PlatformAdmin update with name colliding on another row rejected. +- Idempotent name update (same value) accepted. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from unittest.mock import Mock, patch + +import pytest + +from modules.datamodels.datamodelUam import Mandate +from modules.interfaces.interfaceDbApp import AppObjects + + +class _FakeDb: + """Minimal connector: getRecordset(Mandate) + recordModify(Mandate, id, data).""" + + def __init__(self, rows: List[Dict[str, Any]]): + self.rows: List[Dict[str, Any]] = [dict(r) for r in rows] + self.modifyCalls: List[Dict[str, Any]] = [] + + def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None): + if model is not Mandate: + return [] + if not recordFilter: + return [dict(r) for r in self.rows] + out = [] + for r in self.rows: + if all(r.get(k) == v for k, v in recordFilter.items()): + out.append(dict(r)) + return out + + def recordModify(self, model, recordId: str, payload): + if hasattr(payload, "model_dump"): + data = payload.model_dump() + elif isinstance(payload, dict): + data = dict(payload) + else: + data = {} + self.modifyCalls.append({"id": str(recordId), "data": dict(data)}) + for r in self.rows: + if str(r.get("id")) == str(recordId): + r.update(data) + return r + return None + + +def _buildInterface(db: _FakeDb, *, isPlatformAdmin: bool, isSysAdmin: bool = False) -> AppObjects: + iface = AppObjects.__new__(AppObjects) + iface.db = db + iface.currentUser = Mock( + id="user-x", + isPlatformAdmin=isPlatformAdmin, + isSysAdmin=isSysAdmin, + ) + iface.userId = "user-x" + iface.mandateId = None + iface.featureInstanceId = None + iface.rbac = Mock() + return iface + + +def _row(mid: str = "m1", name: str = "alpha", label: str = "Alpha", **extra) -> Dict[str, Any]: + base = { + "id": mid, + "name": name, + "label": label, + "enabled": True, + "isSystem": False, + } + base.update(extra) + return base + + +def _stubGetMandateAndRbac(iface: AppObjects, row: Dict[str, Any]): + """Wire ``getMandate`` to read from the FakeDb so post-update reads reflect changes.""" + db = iface.db + + def _readMandate(mandateId: str): + for r in db.rows: + if str(r.get("id")) == str(mandateId): + return Mandate(**r) + return None + + iface.getMandate = Mock(side_effect=_readMandate) + return patch.object(iface, "checkRbacPermission", return_value=True) + + +class TestUpdateMandateRbacOnName: + def test_mandateAdminCannotChangeName(self): + """Non-platform admin: ``name`` is a protected field, silently dropped. + + Status quo: route layer also enforces this via ``_MANDATE_ADMIN_EDITABLE_FIELDS``, + but the interface itself MUST also defend so that direct calls don't bypass. + """ + row = _row(mid="m1", name="original-slug", label="Original") + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=False) + with _stubGetMandateAndRbac(iface, row): + updated = iface.updateMandate("m1", {"name": "hacked-slug", "label": "New Label"}) + assert updated.name == "original-slug", "MandateAdmin must NOT modify name" + assert updated.label == "New Label" + + def test_platformAdminCanChangeName(self): + row = _row(mid="m1", name="old-slug", label="Old") + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + updated = iface.updateMandate("m1", {"name": "new-slug"}) + assert updated.name == "new-slug" + + def test_sysAdminCanChangeName(self): + row = _row(mid="m1", name="old-slug", label="Old") + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=False, isSysAdmin=True) + with _stubGetMandateAndRbac(iface, row): + updated = iface.updateMandate("m1", {"name": "syscall-slug"}) + assert updated.name == "syscall-slug" + + +class TestUpdateMandateNameValidation: + def test_invalidNameRejected(self): + row = _row() + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + with pytest.raises(ValueError) as excInfo: + iface.updateMandate("m1", {"name": "ABC Müller!"}) + assert "Kurzzeichen" in str(excInfo.value) or "Failed to update" in str(excInfo.value) + + def test_uppercaseNameRejected(self): + row = _row() + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + with pytest.raises(ValueError): + iface.updateMandate("m1", {"name": "ALPHA"}) + + def test_leadingHyphenRejected(self): + row = _row() + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + with pytest.raises(ValueError): + iface.updateMandate("m1", {"name": "-leading"}) + + def test_idempotentSameNameAccepted(self): + row = _row(mid="m1", name="alpha", label="Alpha") + db = _FakeDb([row, _row(mid="m2", name="beta", label="Beta")]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + updated = iface.updateMandate("m1", {"name": "alpha"}) + assert updated.name == "alpha" + + def test_collisionWithOtherMandateRejected(self): + rows = [ + _row(mid="m1", name="alpha", label="Alpha"), + _row(mid="m2", name="beta", label="Beta"), + ] + db = _FakeDb(rows) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, rows[0]): + with pytest.raises(ValueError) as excInfo: + iface.updateMandate("m1", {"name": "beta"}) + assert "already in use" in str(excInfo.value) + + +class TestUpdateMandateLabelValidation: + def test_emptyLabelRejected(self): + row = _row() + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + with pytest.raises(ValueError) as excInfo: + iface.updateMandate("m1", {"label": " "}) + assert "label" in str(excInfo.value).lower() + + def test_labelTrimmed(self): + row = _row() + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + updated = iface.updateMandate("m1", {"label": " Trimmed Name "}) + assert updated.label == "Trimmed Name" + + +class TestUpdateMandateProtectedFields: + def test_idCannotBeChanged(self): + row = _row(mid="m1", name="alpha", label="Alpha") + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True) + with _stubGetMandateAndRbac(iface, row): + updated = iface.updateMandate("m1", {"id": "spoofed", "label": "New"}) + assert str(updated.id) == "m1", "id field must remain immutable" + + def test_isSystemRequiresSysAdmin(self): + row = _row(mid="m1", name="alpha", label="Alpha", isSystem=False) + db = _FakeDb([row]) + iface = _buildInterface(db, isPlatformAdmin=True, isSysAdmin=False) + with _stubGetMandateAndRbac(iface, row): + updated = iface.updateMandate("m1", {"isSystem": True, "label": "New"}) + assert updated.isSystem is False, "PlatformAdmin alone must NOT escalate isSystem" diff --git a/tests/integration/rbac/test_platform_admin_flag.py b/tests/integration/rbac/test_platform_admin_flag.py new file mode 100644 index 00000000..d4cdbe9b --- /dev/null +++ b/tests/integration/rbac/test_platform_admin_flag.py @@ -0,0 +1,290 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Integration tests for the SysAdmin / PlatformAdmin authority split. + +Covers acceptance criteria from +``wiki/c-work/4-done/2026-04-sysadmin-authority-split.md``: + +- AC#1 -> User with isSysAdmin only is rejected by ``requirePlatformAdmin`` +- AC#2 -> User with isPlatformAdmin only is rejected by ``requireSysAdmin`` +- AC#3 -> User with isPlatformAdmin is accepted by ``requirePlatformAdmin`` +- AC#5 -> Live-flag check: revoking ``isPlatformAdmin`` immediately blocks + the next request (no token cache). +- AC#6 -> Live-flag check: revoking ``isSysAdmin`` immediately blocks + the next infrastructure request. +- AC#8 -> Self-protection: a user can never change their own admin flags + via ``update_user`` business logic. + +Strategy: build a tiny FastAPI app that exposes one route per dependency +and override ``getCurrentUser`` per request. This isolates the gating +logic from database/JWT plumbing and runs without external services. +""" + +from __future__ import annotations + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from modules.auth.authentication import ( + getCurrentUser, + requirePlatformAdmin, + requireSysAdmin, +) +from modules.datamodels.datamodelUam import User + + +def _makeUser( + *, + userId: str = "test-user", + isSysAdmin: bool = False, + isPlatformAdmin: bool = False, +) -> User: + """Build a minimal in-memory User instance for dependency overrides.""" + return User( + id=userId, + username=f"user-{userId}", + email=f"{userId}@example.com", + fullName=f"Test {userId}", + enabled=True, + language="de", + isSysAdmin=isSysAdmin, + isPlatformAdmin=isPlatformAdmin, + ) + + +@pytest.fixture +def appWithDeps() -> tuple[FastAPI, dict]: + """FastAPI app with one route per authority dependency. + + The returned dict allows tests to swap the "current user" between + requests by mutating ``state['user']``. + """ + state: dict = {"user": _makeUser()} + + app = FastAPI() + + def _overrideCurrentUser() -> User: + return state["user"] + + app.dependency_overrides[getCurrentUser] = _overrideCurrentUser + + @app.get("/admin/mandates") + def _adminMandates(_: User = Depends(requirePlatformAdmin)) -> dict: + return {"ok": True, "guard": "platform"} + + @app.get("/admin/logs") + def _adminLogs(_: User = Depends(requireSysAdmin)) -> dict: + return {"ok": True, "guard": "sysadmin"} + + return app, state + + +# --------------------------------------------------------------------------- +# AC #1, #2, #3 — basic authority gating +# --------------------------------------------------------------------------- + + +def testSysAdminCannotAccessPlatformRoute(appWithDeps): + """AC#1: isSysAdmin alone must NOT pass requirePlatformAdmin.""" + app, state = appWithDeps + state["user"] = _makeUser(isSysAdmin=True, isPlatformAdmin=False) + + with TestClient(app) as client: + response = client.get("/admin/mandates") + + assert response.status_code == 403 + assert "platform admin" in response.json()["detail"].lower() + + +def testPlatformAdminCannotAccessInfraRoute(appWithDeps): + """AC#2: isPlatformAdmin alone must NOT pass requireSysAdmin.""" + app, state = appWithDeps + state["user"] = _makeUser(isSysAdmin=False, isPlatformAdmin=True) + + with TestClient(app) as client: + response = client.get("/admin/logs") + + assert response.status_code == 403 + assert "sysadmin" in response.json()["detail"].lower() + + +def testPlatformAdminCanAccessPlatformRoute(appWithDeps): + """AC#3: isPlatformAdmin must pass requirePlatformAdmin.""" + app, state = appWithDeps + state["user"] = _makeUser(isPlatformAdmin=True) + + with TestClient(app) as client: + response = client.get("/admin/mandates") + + assert response.status_code == 200 + assert response.json() == {"ok": True, "guard": "platform"} + + +def testSysAdminCanAccessInfraRoute(appWithDeps): + """Sanity counterpart to AC#3: isSysAdmin passes requireSysAdmin.""" + app, state = appWithDeps + state["user"] = _makeUser(isSysAdmin=True) + + with TestClient(app) as client: + response = client.get("/admin/logs") + + assert response.status_code == 200 + assert response.json() == {"ok": True, "guard": "sysadmin"} + + +def testNoFlagsIsForbiddenForBothGuards(appWithDeps): + """Regular user (no flags) must be rejected by both guards.""" + app, state = appWithDeps + state["user"] = _makeUser() + + with TestClient(app) as client: + rPlatform = client.get("/admin/mandates") + rInfra = client.get("/admin/logs") + + assert rPlatform.status_code == 403 + assert rInfra.status_code == 403 + + +# --------------------------------------------------------------------------- +# AC #5, #6 — live flag check (no client-side cache, next request re-evaluates) +# --------------------------------------------------------------------------- + + +def testRevokingPlatformAdminBlocksNextRequest(appWithDeps): + """AC#5: After dropping isPlatformAdmin, the very next request gets 403.""" + app, state = appWithDeps + state["user"] = _makeUser(isPlatformAdmin=True) + + with TestClient(app) as client: + first = client.get("/admin/mandates") + assert first.status_code == 200 + + # Admin removes the flag (e.g. via /api/users/{id}) + state["user"] = _makeUser(isPlatformAdmin=False) + + second = client.get("/admin/mandates") + assert second.status_code == 403 + + +def testRevokingSysAdminBlocksNextRequest(appWithDeps): + """AC#6: After dropping isSysAdmin, the very next request gets 403.""" + app, state = appWithDeps + state["user"] = _makeUser(isSysAdmin=True) + + with TestClient(app) as client: + first = client.get("/admin/logs") + assert first.status_code == 200 + + state["user"] = _makeUser(isSysAdmin=False) + + second = client.get("/admin/logs") + assert second.status_code == 403 + + +# --------------------------------------------------------------------------- +# AC #8 — self-protection on update_user +# --------------------------------------------------------------------------- + + +def testSelfProtectionOnUpdateUserDisallowsAdminFlagChange(): + """AC#8: A platform admin updating themselves cannot change admin flags. + + Mirrors the gating logic in ``routeDataUsers.update_user``: + + callerIsPlatformAdmin = context.isPlatformAdmin + allowAdminFlagChange = callerIsPlatformAdmin and not isSelfUpdate + + When ``isSelfUpdate`` is True the flag must always be ``False``, + regardless of the caller's authority. + """ + callerId = "user-1" + + def _allowAdminFlagChange(callerIsPlatformAdmin: bool, isSelfUpdate: bool) -> bool: + return callerIsPlatformAdmin and not isSelfUpdate + + # Self-update by a platform admin: still NOT allowed to flip own flags. + assert _allowAdminFlagChange(True, isSelfUpdate=(callerId == callerId)) is False + + # Foreign-update by a platform admin: allowed. + assert _allowAdminFlagChange(True, isSelfUpdate=(callerId == "user-2")) is True + + # Foreign-update by a non-platform admin: rejected. + assert _allowAdminFlagChange(False, isSelfUpdate=(callerId == "user-2")) is False + + +def testInterfaceUpdateUserProtectsAdminFlagsWhenForbidden(): + """``interfaceDbApp.AppObjects.updateUser`` must keep the existing + ``isSysAdmin``/``isPlatformAdmin`` values when ``allowAdminFlagChange`` + is False — even if the request payload tries to escalate them. + + This is the second line of defence behind ``update_user``'s + ``isSelfUpdate`` check. + """ + from unittest.mock import Mock + + from modules.interfaces.interfaceDbApp import AppObjects + + existing = User( + id="victim", + username="victim", + email="victim@example.com", + fullName="Victim", + enabled=True, + language="de", + isSysAdmin=False, + isPlatformAdmin=False, + ) + + # Attacker payload tries to escalate both flags. + attackerPayload = User( + id="victim", + username="victim", + email="victim@example.com", + fullName="Victim", + enabled=True, + language="de", + isSysAdmin=True, + isPlatformAdmin=True, + ) + + captured: dict = {} + + def _captureUpdate(_model, _recordId, payload): + # Whether dict or User: extract flag values for assertion. + if hasattr(payload, "model_dump"): + data = payload.model_dump() + elif isinstance(payload, dict): + data = payload + else: + data = {"isSysAdmin": getattr(payload, "isSysAdmin", None), + "isPlatformAdmin": getattr(payload, "isPlatformAdmin", None)} + captured["isSysAdmin"] = data.get("isSysAdmin") + captured["isPlatformAdmin"] = data.get("isPlatformAdmin") + merged = {**existing.model_dump(), **{k: v for k, v in data.items() if v is not None}} + return merged + + fakeDb = Mock() + fakeDb.recordModify = Mock(side_effect=_captureUpdate) + + # Build the interface without going through __init__ (avoids real DB). + interface = AppObjects.__new__(AppObjects) + interface.currentUser = existing + interface.userId = existing.id + interface.mandateId = None + interface.featureInstanceId = None + interface.db = fakeDb + interface.rbac = Mock(checkRbacPermission=Mock(return_value=True)) + interface.getUser = Mock(return_value=existing) + + interface.updateUser("victim", attackerPayload, allowAdminFlagChange=False) + + assert captured.get("isSysAdmin") is False, ( + "isSysAdmin must remain False when allowAdminFlagChange=False, " + f"got {captured.get('isSysAdmin')!r}" + ) + assert captured.get("isPlatformAdmin") is False, ( + "isPlatformAdmin must remain False when allowAdminFlagChange=False, " + f"got {captured.get('isPlatformAdmin')!r}" + ) diff --git a/tests/integration/users/__init__.py b/tests/integration/users/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/users/test_updateUser.py b/tests/integration/users/test_updateUser.py new file mode 100644 index 00000000..1c7afa29 --- /dev/null +++ b/tests/integration/users/test_updateUser.py @@ -0,0 +1,221 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +""" +Integration tests for ``AppObjects.updateUser`` partial-update semantics. + +Regression for the silent flag-flip bug (``isSysAdmin`` <-> ``isPlatformAdmin``) +on inline toggles in ``Admin > System > Mandanten/Benutzer``: + +Symptom +------- +Toggling one privileged flag in the user table flipped the OTHER privileged +flag back to its Pydantic default (``False``). + +Root cause +---------- +The PUT ``/api/users/{id}`` route bound ``userData: User = Body(...)``. Pydantic +filled every field that the client did not explicitly send with model defaults +(``isSysAdmin=False``, ``isPlatformAdmin=False``). Combined with +``allowAdminFlagChange=True`` (PlatformAdmin updating another user), those +defaults were merged into the persisted record and silently overwrote the +"other" flag. + +Fix +--- +The route now accepts a plain ``Dict[str, Any]`` and ``AppObjects.updateUser`` +treats the payload as a true partial patch — only the keys present in the +request body are applied to the stored record. Pydantic ``User`` callers are +still supported via ``model_dump(exclude_unset=True)`` so legacy paths keep +working without re-introducing the default-fill regression. + +These tests assert the partial-update contract end-to-end at the interface +level, which is the layer where the bug lived. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from unittest.mock import Mock, patch + +import pytest + +from modules.datamodels.datamodelUam import User, UserInDB +from modules.interfaces.interfaceDbApp import AppObjects + + +class _FakeDb: + """Minimal connector covering the access patterns used by ``updateUser``.""" + + def __init__(self, rows: List[Dict[str, Any]]): + self.rows: List[Dict[str, Any]] = [dict(r) for r in rows] + self.modifyCalls: List[Dict[str, Any]] = [] + + def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None): + if model is not UserInDB and model is not User: + return [] + if not recordFilter: + return [dict(r) for r in self.rows] + out = [] + for r in self.rows: + if all(r.get(k) == v for k, v in recordFilter.items()): + out.append(dict(r)) + return out + + def recordModify(self, model, recordId: str, payload): + if hasattr(payload, "model_dump"): + data = payload.model_dump() + elif isinstance(payload, dict): + data = dict(payload) + else: + data = {} + self.modifyCalls.append({"id": str(recordId), "data": dict(data)}) + for r in self.rows: + if str(r.get("id")) == str(recordId): + r.update(data) + return r + return None + + +def _buildInterface(db: _FakeDb) -> AppObjects: + iface = AppObjects.__new__(AppObjects) + iface.db = db + iface.currentUser = Mock(id="caller", isPlatformAdmin=True, isSysAdmin=False) + iface.userId = "caller" + iface.mandateId = None + iface.featureInstanceId = None + iface.rbac = Mock() + return iface + + +def _row(uid: str = "u1", **extra) -> Dict[str, Any]: + base: Dict[str, Any] = { + "id": uid, + "username": "alice", + "email": "alice@example.com", + "fullName": "Alice Example", + "language": "de", + "enabled": True, + "isSysAdmin": False, + "isPlatformAdmin": True, + "authenticationAuthority": "local", + "roleLabels": [], + } + base.update(extra) + return base + + +def _stubGetUser(iface: AppObjects): + """Wire ``getUser`` to read from the FakeDb so post-update reads reflect changes.""" + db = iface.db + + def _readUser(userId: str): + for r in db.rows: + if str(r.get("id")) == str(userId): + return User(**r) + return None + + iface.getUser = Mock(side_effect=_readUser) + + +class TestPartialUpdateProtectsSiblingFlag: + """The headline regression: toggling one flag must not touch the other.""" + + def test_togglingIsSysAdminKeepsIsPlatformAdmin(self): + row = _row(isSysAdmin=False, isPlatformAdmin=True) + db = _FakeDb([row]) + iface = _buildInterface(db) + _stubGetUser(iface) + + updated = iface.updateUser( + "u1", + {"isSysAdmin": True}, # only the toggled cell + allowAdminFlagChange=True, + ) + + assert updated.isSysAdmin is True + assert updated.isPlatformAdmin is True, ( + "Partial update must not silently drop isPlatformAdmin to its default" + ) + + def test_togglingIsPlatformAdminKeepsIsSysAdmin(self): + row = _row(isSysAdmin=True, isPlatformAdmin=False) + db = _FakeDb([row]) + iface = _buildInterface(db) + _stubGetUser(iface) + + updated = iface.updateUser( + "u1", + {"isPlatformAdmin": True}, + allowAdminFlagChange=True, + ) + + assert updated.isPlatformAdmin is True + assert updated.isSysAdmin is True, ( + "Partial update must not silently drop isSysAdmin to its default" + ) + + def test_togglingUnrelatedFieldKeepsBothFlags(self): + row = _row(isSysAdmin=True, isPlatformAdmin=True, language="de") + db = _FakeDb([row]) + iface = _buildInterface(db) + _stubGetUser(iface) + + updated = iface.updateUser( + "u1", + {"language": "en"}, + allowAdminFlagChange=False, + ) + + assert updated.language == "en" + assert updated.isSysAdmin is True + assert updated.isPlatformAdmin is True + + +class TestPrivilegedFlagGuard: + """Without ``allowAdminFlagChange`` the protected flags must be dropped.""" + + def test_protectedFlagsDroppedWhenChangeNotAllowed(self): + row = _row(isSysAdmin=False, isPlatformAdmin=True) + db = _FakeDb([row]) + iface = _buildInterface(db) + _stubGetUser(iface) + + updated = iface.updateUser( + "u1", + {"isSysAdmin": True, "isPlatformAdmin": False, "language": "fr"}, + allowAdminFlagChange=False, + ) + + assert updated.language == "fr", "non-protected fields still apply" + assert updated.isSysAdmin is False, "protected flag must be ignored" + assert updated.isPlatformAdmin is True, "protected flag must be ignored" + + def test_legacyPydanticUserDoesNotDefaultFlipFlags(self): + """Defense in depth: if a caller still passes a ``User`` instance, + ``model_dump(exclude_unset=True)`` must keep unset fields out of the + merge so they do not pull live values down to Pydantic defaults. + """ + row = _row(isSysAdmin=True, isPlatformAdmin=True, fullName="Alice Example") + db = _FakeDb([row]) + iface = _buildInterface(db) + _stubGetUser(iface) + + partialModel = User( + id="u1", + username="alice", + fullName="Alice Updated", + ) + + updated = iface.updateUser( + "u1", + partialModel, + allowAdminFlagChange=True, + ) + + assert updated.fullName == "Alice Updated" + assert updated.isSysAdmin is True, ( + "Pydantic User without explicit isSysAdmin must not flip stored True to default False" + ) + assert updated.isPlatformAdmin is True, ( + "Pydantic User without explicit isPlatformAdmin must not flip stored True to default False" + ) diff --git a/tests/test_phase123_basic.py b/tests/test_phase123_basic.py index 89d23662..59a3234d 100644 --- a/tests/test_phase123_basic.py +++ b/tests/test_phase123_basic.py @@ -149,7 +149,7 @@ try: source = f.read() _check("routeDataFiles has PATCH scope endpoint", "updateFileScope" in source) _check("routeDataFiles has PATCH neutralize endpoint", "updateFileNeutralize" in source) - _check("routeDataFiles checks global sysAdmin", "hasSysAdminRole" in source or "sysadmin" in source.lower()) + _check("routeDataFiles checks global sysAdmin", "isSysAdmin" in source) except Exception as e: errors.append(f"Phase 2 Routes: {e}") print(f" [FAIL] Phase 2 Routes: {e}") diff --git a/tests/unit/bootstrap/__init__.py b/tests/unit/bootstrap/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/bootstrap/test_mandateNameMigration.py b/tests/unit/bootstrap/test_mandateNameMigration.py new file mode 100644 index 00000000..d09a6846 --- /dev/null +++ b/tests/unit/bootstrap/test_mandateNameMigration.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Unit tests for ``_migrateMandateNameLabelSlugRules`` in interfaceBootstrap. + +Covers: +- legacy ``name``/``label`` rows get fixed (label fill, slug rename), +- collisions across legacy rows resolve via -2/-3 suffixes in stable id order, +- valid rows are left untouched (idempotency), +- second invocation is a no-op. +""" + +from typing import Any, Dict, List, Optional + +import pytest + +from modules.datamodels.datamodelUam import Mandate +from modules.interfaces.interfaceBootstrap import _migrateMandateNameLabelSlugRules +from modules.shared.mandateNameUtils import isValidMandateName + + +class _FakeDb: + """Minimal connector simulating getRecordset(Mandate)+recordModify(Mandate, id, data).""" + + def __init__(self, rows: List[Dict[str, Any]]): + self.rows: List[Dict[str, Any]] = [dict(r) for r in rows] + self.modifyCalls: List[Dict[str, Any]] = [] + + def getRecordset(self, model, recordFilter: Optional[Dict[str, Any]] = None): + if model is not Mandate: + return [] + if not recordFilter: + return [dict(r) for r in self.rows] + out = [] + for r in self.rows: + if all(r.get(k) == v for k, v in recordFilter.items()): + out.append(dict(r)) + return out + + def recordModify(self, model, recordId: str, data: Dict[str, Any]): + self.modifyCalls.append({"id": str(recordId), "data": dict(data)}) + for r in self.rows: + if str(r.get("id")) == str(recordId): + r.update(data) + return r + return None + + +def _row(mid: str, name: Any, label: Any = None) -> Dict[str, Any]: + return {"id": mid, "name": name, "label": label} + + +class TestMigrationFillsLabel: + def test_emptyLabelGetsNameAsLabel(self): + db = _FakeDb([_row("a1", "good-name", None)]) + _migrateMandateNameLabelSlugRules(db) + assert db.rows[0]["label"] == "good-name" + assert db.rows[0]["name"] == "good-name" + + def test_emptyLabelAndEmptyNameFallsBackToMandate(self): + db = _FakeDb([_row("a1", "", "")]) + _migrateMandateNameLabelSlugRules(db) + assert db.rows[0]["label"] == "Mandate" + assert isValidMandateName(db.rows[0]["name"]) + + +class TestMigrationRenamesInvalidNames: + def test_invalidNameGetsSlugFromLabel(self): + db = _FakeDb([_row("a1", "Home patrick", "Home Patrick")]) + _migrateMandateNameLabelSlugRules(db) + assert db.rows[0]["name"] == "home-patrick" + assert db.rows[0]["label"] == "Home Patrick" + + def test_umlautsTransliterated(self): + db = _FakeDb([_row("a1", "Müller AG", "Müller AG")]) + _migrateMandateNameLabelSlugRules(db) + assert db.rows[0]["name"] == "mueller-ag" + + +class TestMigrationCollisions: + def test_collisionsResolveByStableIdOrder(self): + rows = [ + _row("z1", "Home patrick", "Home Patrick"), + _row("a1", "home-patrick", "Home Patrick Two"), + ] + db = _FakeDb(rows) + _migrateMandateNameLabelSlugRules(db) + byId = {r["id"]: r for r in db.rows} + assert byId["a1"]["name"] == "home-patrick" + assert byId["z1"]["name"] == "home-patrick-2" + + def test_threeWayCollisionGetsThirdSuffix(self): + rows = [ + _row("id-aaa", "home-patrick", "Home Patrick"), + _row("id-bbb", "Home patrick", "Home Patrick"), + _row("id-ccc", "home patrick", "Home Patrick"), + ] + db = _FakeDb(rows) + _migrateMandateNameLabelSlugRules(db) + names = sorted(r["name"] for r in db.rows) + assert names == ["home-patrick", "home-patrick-2", "home-patrick-3"] + + +class TestMigrationIdempotency: + def test_secondRunIsNoop(self): + rows = [ + _row("a1", "home-patrick", "Home Patrick"), + _row("b1", "Home Müller", ""), + ] + db = _FakeDb(rows) + _migrateMandateNameLabelSlugRules(db) + assert all(isValidMandateName(r["name"]) for r in db.rows) + firstChanges = list(db.modifyCalls) + db.modifyCalls.clear() + _migrateMandateNameLabelSlugRules(db) + assert db.modifyCalls == [], ( + f"expected no further changes after first migration, got {db.modifyCalls}; " + f"firstRun changes: {firstChanges}" + ) + + def test_validRowsLeftUntouched(self): + rows = [_row("a1", "root", "Root"), _row("b1", "alpina-treuhand", "Alpina Treuhand AG")] + db = _FakeDb(rows) + _migrateMandateNameLabelSlugRules(db) + assert db.modifyCalls == [] + + +class TestMigrationEmpty: + def test_emptyDbDoesNothing(self): + db = _FakeDb([]) + _migrateMandateNameLabelSlugRules(db) + assert db.modifyCalls == [] diff --git a/tests/unit/rbac/test_sysadmin_migration.py b/tests/unit/rbac/test_sysadmin_migration.py new file mode 100644 index 00000000..8ca077bf --- /dev/null +++ b/tests/unit/rbac/test_sysadmin_migration.py @@ -0,0 +1,209 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Unit tests for the one-shot sysadmin role -> isPlatformAdmin migration. + +Covers acceptance criteria from +``wiki/c-work/4-done/2026-04-sysadmin-authority-split.md``: + +- AC#4 -> Existing sysadmin role-holders are promoted to ``isPlatformAdmin=True`` + and the legacy role is removed (Role + UserMandateRole + AccessRules) + when the gateway boots. +- AC#10 -> The migration is idempotent and removes ALL artefacts (Role, + AccessRules, UserMandateRole) of the legacy ``sysadmin`` role. + +Strategy: use an in-memory fake ``DatabaseConnector`` that records calls +and returns deterministic recordsets for ``Role``/``UserMandateRole``/ +``UserMandate``/``UserInDB``/``AccessRule`` lookups. +""" + +from __future__ import annotations + +from typing import Any, Dict, List +from unittest.mock import Mock + +from modules.interfaces.interfaceBootstrap import _migrateAndDropSysAdminRole +from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole +from modules.datamodels.datamodelRbac import AccessRule, Role +from modules.datamodels.datamodelUam import UserInDB + + +_ROOT_MANDATE_ID = "root-mandate-id" +_SYSADMIN_ROLE_ID = "sysadmin-role-id" +_USER_MANDATE_ID = "user-mandate-id" +_USER_ID = "legacy-user-id" +_UMR_ROW_ID = "umr-row-id" +_ACCESS_RULE_ID = "access-rule-id" + + +def _buildFakeDb( + *, + sysadminRoles: List[Dict[str, Any]], + umRoleRows: List[Dict[str, Any]], + userMandateRows: List[Dict[str, Any]], + users: List[Dict[str, Any]], + accessRules: List[Dict[str, Any]], +) -> Mock: + """Build a fake ``DatabaseConnector`` that maps model -> recordset.""" + + deletes: List[tuple] = [] + modifies: List[tuple] = [] + + def _getRecordset(model, recordFilter=None, **_): # noqa: ANN001 + recordFilter = recordFilter or {} + if model is Role: + label = recordFilter.get("roleLabel") + mandateId = recordFilter.get("mandateId") + if label == "sysadmin" and mandateId == _ROOT_MANDATE_ID: + return list(sysadminRoles) + return [] + if model is UserMandateRole: + wanted = recordFilter.get("roleId") + return [r for r in umRoleRows if r.get("roleId") == wanted] + if model is UserMandate: + wanted = recordFilter.get("id") + return [r for r in userMandateRows if r.get("id") == wanted] + if model is UserInDB: + wanted = recordFilter.get("id") + return [r for r in users if r.get("id") == wanted] + if model is AccessRule: + wanted = recordFilter.get("roleId") + return [r for r in accessRules if r.get("roleId") == wanted] + return [] + + def _recordModify(model, recordId, payload): # noqa: ANN001 + modifies.append((model, recordId, payload)) + # Reflect the change so a subsequent migration call is idempotent. + if model is UserInDB: + for u in users: + if u.get("id") == recordId: + u.update(payload) + return True + + def _recordDelete(model, recordId): # noqa: ANN001 + deletes.append((model, recordId)) + if model is UserMandateRole: + umRoleRows[:] = [r for r in umRoleRows if r.get("id") != recordId] + elif model is AccessRule: + accessRules[:] = [r for r in accessRules if r.get("id") != recordId] + elif model is Role: + sysadminRoles[:] = [r for r in sysadminRoles if r.get("id") != recordId] + return True + + db = Mock() + db.getRecordset = Mock(side_effect=_getRecordset) + db.recordModify = Mock(side_effect=_recordModify) + db.recordDelete = Mock(side_effect=_recordDelete) + db._modifies = modifies # exposed for assertions + db._deletes = deletes + return db + + +def _seed(): + return { + "sysadminRoles": [{"id": _SYSADMIN_ROLE_ID, "roleLabel": "sysadmin", + "mandateId": _ROOT_MANDATE_ID}], + "umRoleRows": [{"id": _UMR_ROW_ID, "roleId": _SYSADMIN_ROLE_ID, + "userMandateId": _USER_MANDATE_ID}], + "userMandateRows": [{"id": _USER_MANDATE_ID, "userId": _USER_ID, + "mandateId": _ROOT_MANDATE_ID}], + "users": [{"id": _USER_ID, "username": "legacy", + "isSysAdmin": False, "isPlatformAdmin": False}], + "accessRules": [{"id": _ACCESS_RULE_ID, "roleId": _SYSADMIN_ROLE_ID}], + } + + +# --------------------------------------------------------------------------- +# AC #4 — promote + drop on first run +# --------------------------------------------------------------------------- + + +def testMigrationPromotesUserAndDropsArtefacts(): + """AC#4: legacy holder is promoted; Role+AccessRule+UMR are deleted.""" + seed = _seed() + db = _buildFakeDb(**seed) + + _migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID) + + # User got isPlatformAdmin=True + assert seed["users"][0]["isPlatformAdmin"] is True + assert any( + m[0] is UserInDB and m[2] == {"isPlatformAdmin": True} + for m in db._modifies + ), "Expected UserInDB.isPlatformAdmin promotion call" + + # All three artefact tables had their rows deleted. + deletedModels = {m[0] for m in db._deletes} + assert UserMandateRole in deletedModels, "UserMandateRole row not deleted" + assert AccessRule in deletedModels, "AccessRule row not deleted" + assert Role in deletedModels, "Sysadmin Role record not deleted" + + # And the seeded lists are empty after the migration. + assert seed["umRoleRows"] == [] + assert seed["accessRules"] == [] + assert seed["sysadminRoles"] == [] + + +# --------------------------------------------------------------------------- +# AC #10 — idempotent: a second run is a no-op +# --------------------------------------------------------------------------- + + +def testMigrationIsIdempotent(): + """AC#10: a second invocation finds no sysadmin role and exits silently.""" + seed = _seed() + db = _buildFakeDb(**seed) + + _migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID) + firstModifies = list(db._modifies) + firstDeletes = list(db._deletes) + + _migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID) + + # No additional writes on the second call. + assert db._modifies == firstModifies, ( + "Second migration call must not perform additional writes" + ) + assert db._deletes == firstDeletes, ( + "Second migration call must not perform additional deletes" + ) + + +def testMigrationSkipsAlreadyPromotedUsers(): + """If a user already has ``isPlatformAdmin=True``, no redundant write.""" + seed = _seed() + seed["users"][0]["isPlatformAdmin"] = True # already promoted + db = _buildFakeDb(**seed) + + _migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID) + + # No promotion write for an already-promoted user. + promotionWrites = [ + m for m in db._modifies + if m[0] is UserInDB and m[2].get("isPlatformAdmin") is True + ] + assert promotionWrites == [], ( + "Should not re-write isPlatformAdmin if user already has it" + ) + + # But role + access-rule cleanup still happens. + deletedModels = {m[0] for m in db._deletes} + assert Role in deletedModels + assert AccessRule in deletedModels + assert UserMandateRole in deletedModels + + +def testMigrationOnEmptyDbIsNoop(): + """No legacy sysadmin role at all -> no calls, no errors.""" + db = _buildFakeDb( + sysadminRoles=[], + umRoleRows=[], + userMandateRows=[], + users=[], + accessRules=[], + ) + + _migrateAndDropSysAdminRole(db, _ROOT_MANDATE_ID) + + assert db._modifies == [] + assert db._deletes == [] diff --git a/tests/unit/shared/test_mandateNameUtils.py b/tests/unit/shared/test_mandateNameUtils.py new file mode 100644 index 00000000..6ef4bec1 --- /dev/null +++ b/tests/unit/shared/test_mandateNameUtils.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Unit tests for mandateNameUtils (slug, validation, unique allocation).""" + +import pytest + +from modules.shared.mandateNameUtils import ( + allocateUniqueMandateSlug, + isValidMandateName, + slugifyMandateName, + transliterateGerman, +) + + +class TestTransliterateGerman: + def test_transliterateGerman_umlauts(self): + assert transliterateGerman("Müller") == "Mueller" + assert transliterateGerman("Größe") == "Groesse" + assert transliterateGerman("Fußball") == "Fussball" + + +class TestIsValidMandateName: + def test_isValidMandateName_ok(self): + assert isValidMandateName("ab") is True + assert isValidMandateName("a-b") is True + assert isValidMandateName("root") is True + assert isValidMandateName("mn-2") is True + + def test_isValidMandateName_rejects(self): + assert isValidMandateName("a") is False + assert isValidMandateName("") is False + assert isValidMandateName("Home patrick") is False + assert isValidMandateName("UPPER") is False + assert isValidMandateName("a--b") is False + assert isValidMandateName("-ab") is False + assert isValidMandateName("ab-") is False + + +class TestSlugifyMandateName: + def test_slugifyMandateName_basic(self): + assert slugifyMandateName("Müller AG") == "mueller-ag" + assert slugifyMandateName(" Foo Bar ") == "foo-bar" + + def test_slugifyMandateName_empty(self): + assert slugifyMandateName("") == "mn" + assert slugifyMandateName(" ") == "mn" + + +class TestAllocateUniqueMandateSlug: + def test_allocateUniqueMandateSlug_first_free(self): + assert allocateUniqueMandateSlug("mueller-ag", ["other"]) == "mueller-ag" + + def test_allocateUniqueMandateSlug_collision(self): + assert allocateUniqueMandateSlug("mueller-ag", ["mueller-ag"]) == "mueller-ag-2" + assert allocateUniqueMandateSlug("mueller-ag", ["mueller-ag", "mueller-ag-2"]) == "mueller-ag-3"