From efc28879c39ca2e98107688343c4699b647d8157 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 24 Jan 2026 09:43:46 +0100 Subject: [PATCH] access rules editor enhanced --- modules/features/trustee/mainTrustee.py | 100 +++--- .../features/trustee/routeFeatureTrustee.py | 328 +++++++++++++++++- modules/routes/routeAdminRbacRules.py | 44 +++ 3 files changed, 415 insertions(+), 57 deletions(-) diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 8cda0cd0..5ee0ed08 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -16,76 +16,48 @@ FEATURE_LABEL = {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"} FEATURE_ICON = "mdi-briefcase" # UI Objects for RBAC catalog +# Note: organisations and contracts removed - feature instance = organisation UI_OBJECTS = [ { - "objectKey": "ui.feature.trustee.organisations", - "label": {"en": "Organisations", "de": "Organisationen", "fr": "Organisations"}, - "meta": {"area": "organisations"} + "objectKey": "ui.feature.trustee.dashboard", + "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, + "meta": {"area": "dashboard"} }, { - "objectKey": "ui.feature.trustee.contracts", - "label": {"en": "Contracts", "de": "Verträge", "fr": "Contrats"}, - "meta": {"area": "contracts"} + "objectKey": "ui.feature.trustee.positions", + "label": {"en": "Positions", "de": "Positionen", "fr": "Positions"}, + "meta": {"area": "positions"} }, { - "objectKey": "ui.feature.trustee.contracts.tab.documents", - "label": {"en": "Contract Documents", "de": "Vertragsdokumente", "fr": "Documents contractuels"}, - "meta": {"area": "contracts", "element": "tab.documents"} + "objectKey": "ui.feature.trustee.documents", + "label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"}, + "meta": {"area": "documents"} }, { - "objectKey": "ui.feature.trustee.contracts.tab.positions", - "label": {"en": "Contract Positions", "de": "Vertragspositionen", "fr": "Positions contractuelles"}, - "meta": {"area": "contracts", "element": "tab.positions"} + "objectKey": "ui.feature.trustee.position-documents", + "label": {"en": "Position Documents", "de": "Positions-Dokumente", "fr": "Documents de position"}, + "meta": {"area": "position-documents"} }, { - "objectKey": "ui.feature.trustee.access", - "label": {"en": "Access Management", "de": "Zugriffsverwaltung", "fr": "Gestion des accès"}, - "meta": {"area": "access"} - }, - { - "objectKey": "ui.feature.trustee.roles", - "label": {"en": "Roles", "de": "Rollen", "fr": "Rôles"}, - "meta": {"area": "roles"} + "objectKey": "ui.feature.trustee.instance-roles", + "label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"}, + "meta": {"area": "admin", "admin_only": True} }, ] # Resource Objects for RBAC catalog +# Note: organisations and contracts removed - feature instance = organisation RESOURCE_OBJECTS = [ - { - "objectKey": "resource.feature.trustee.organisations.create", - "label": {"en": "Create Organisation", "de": "Organisation erstellen", "fr": "Créer organisation"}, - "meta": {"endpoint": "/api/trustee/{instanceId}/organisations", "method": "POST"} - }, - { - "objectKey": "resource.feature.trustee.organisations.update", - "label": {"en": "Update Organisation", "de": "Organisation aktualisieren", "fr": "Modifier organisation"}, - "meta": {"endpoint": "/api/trustee/{instanceId}/organisations/{orgId}", "method": "PUT"} - }, - { - "objectKey": "resource.feature.trustee.organisations.delete", - "label": {"en": "Delete Organisation", "de": "Organisation löschen", "fr": "Supprimer organisation"}, - "meta": {"endpoint": "/api/trustee/{instanceId}/organisations/{orgId}", "method": "DELETE"} - }, - { - "objectKey": "resource.feature.trustee.contracts.create", - "label": {"en": "Create Contract", "de": "Vertrag erstellen", "fr": "Créer contrat"}, - "meta": {"endpoint": "/api/trustee/{instanceId}/contracts", "method": "POST"} - }, - { - "objectKey": "resource.feature.trustee.contracts.update", - "label": {"en": "Update Contract", "de": "Vertrag aktualisieren", "fr": "Modifier contrat"}, - "meta": {"endpoint": "/api/trustee/{instanceId}/contracts/{contractId}", "method": "PUT"} - }, - { - "objectKey": "resource.feature.trustee.contracts.delete", - "label": {"en": "Delete Contract", "de": "Vertrag löschen", "fr": "Supprimer contrat"}, - "meta": {"endpoint": "/api/trustee/{instanceId}/contracts/{contractId}", "method": "DELETE"} - }, { "objectKey": "resource.feature.trustee.documents.create", "label": {"en": "Upload Document", "de": "Dokument hochladen", "fr": "Télécharger document"}, "meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"} }, + { + "objectKey": "resource.feature.trustee.documents.update", + "label": {"en": "Update Document", "de": "Dokument aktualisieren", "fr": "Modifier document"}, + "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"} + }, { "objectKey": "resource.feature.trustee.documents.delete", "label": {"en": "Delete Document", "de": "Dokument löschen", "fr": "Supprimer document"}, @@ -96,15 +68,26 @@ RESOURCE_OBJECTS = [ "label": {"en": "Create Position", "de": "Position erstellen", "fr": "Créer position"}, "meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"} }, + { + "objectKey": "resource.feature.trustee.positions.update", + "label": {"en": "Update Position", "de": "Position aktualisieren", "fr": "Modifier position"}, + "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"} + }, { "objectKey": "resource.feature.trustee.positions.delete", "label": {"en": "Delete Position", "de": "Position löschen", "fr": "Supprimer position"}, "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"} }, + { + "objectKey": "resource.feature.trustee.instance-roles.manage", + "label": {"en": "Manage Instance Roles", "de": "Instanz-Rollen verwalten", "fr": "Gérer les rôles d'instance"}, + "meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True} + }, ] # Template roles for this feature with AccessRules # Each role defines default UI and DATA permissions +# Note: UI item=None means ALL views, specific items restrict to named views TEMPLATE_ROLES = [ { "roleLabel": "trustee-admin", @@ -114,10 +97,12 @@ TEMPLATE_ROLES = [ "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires" }, "accessRules": [ - # Full UI access + # Full UI access (all views including admin views) {"context": "UI", "item": None, "view": True}, # Full DATA access {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, + # Admin resource: manage instance roles + {"context": "RESOURCE", "item": "instance-roles.manage", "view": True}, ] }, { @@ -128,8 +113,11 @@ TEMPLATE_ROLES = [ "fr": "Comptable fiduciaire - Gérer les données comptables et financières" }, "accessRules": [ - # Full UI access - {"context": "UI", "item": None, "view": True}, + # UI access to main views (not admin views) + {"context": "UI", "item": "dashboard", "view": True}, + {"context": "UI", "item": "positions", "view": True}, + {"context": "UI", "item": "documents", "view": True}, + {"context": "UI", "item": "position-documents", "view": True}, # Group-level DATA access {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, ] @@ -142,8 +130,10 @@ TEMPLATE_ROLES = [ "fr": "Client fiduciaire - Consulter ses propres données comptables et documents" }, "accessRules": [ - # Full UI access - {"context": "UI", "item": None, "view": True}, + # UI access to main views only (read-only focus) + {"context": "UI", "item": "dashboard", "view": True}, + {"context": "UI", "item": "positions", "view": True}, + {"context": "UI", "item": "documents", "view": True}, # Own records only (MY level) {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, ] diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index e05f6dc0..9e30951a 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -115,6 +115,66 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s return str(instance.mandateId) +# ============================================================================ +# ATTRIBUTES ENDPOINT (for FormGeneratorTable) +# ============================================================================ + +# Mapping of entity names to Pydantic model classes +_TRUSTEE_ENTITY_MODELS = { + "TrusteeOrganisation": TrusteeOrganisation, + "TrusteeRole": TrusteeRole, + "TrusteeAccess": TrusteeAccess, + "TrusteeContract": TrusteeContract, + "TrusteeDocument": TrusteeDocument, + "TrusteePosition": TrusteePosition, + "TrusteePositionDocument": TrusteePositionDocument, +} + + +@router.get("/{instanceId}/attributes/{entityType}") +@limiter.limit("30/minute") +async def getEntityAttributes( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + entityType: str = Path(..., description="Entity type (e.g., TrusteeDocument)"), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Get attribute definitions for a Trustee entity. + Used by FormGeneratorTable for dynamic column generation. + """ + # Validate instance access + await _validateInstanceAccess(instanceId, context) + + # Check if entity type is valid + if entityType not in _TRUSTEE_ENTITY_MODELS: + raise HTTPException( + status_code=404, + detail=f"Unknown entity type: {entityType}. Valid types: {list(_TRUSTEE_ENTITY_MODELS.keys())}" + ) + + # Get the model class + modelClass = _TRUSTEE_ENTITY_MODELS[entityType] + + # Import the attribute utils + from modules.shared.attributeUtils import getModelAttributeDefinitions + + try: + attrDefs = getModelAttributeDefinitions(modelClass) + # Filter to only visible attributes + visibleAttrs = [ + attr for attr in attrDefs.get("attributes", []) + if isinstance(attr, dict) and attr.get("visible", True) + ] + return {"attributes": visibleAttrs} + except Exception as e: + logger.error(f"Error getting attributes for {entityType}: {e}") + raise HTTPException( + status_code=500, + detail=f"Error getting attributes for {entityType}: {str(e)}" + ) + + # ============================================================================ # OPTIONS ENDPOINTS (for dropdowns) # ============================================================================ @@ -131,7 +191,7 @@ async def getOrganisationOptions( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllOrganisations(None) items = result.items if hasattr(result, 'items') else result - return [{"value": org.id, "label": org.label or org.id} for org in items] + return [{"value": org["id"], "label": org.get("label") or org["id"]} for org in items] @router.get("/{instanceId}/roles/options", response_model=List[Dict[str, Any]]) @@ -146,7 +206,7 @@ async def getRoleOptions( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllRoles(None) items = result.items if hasattr(result, 'items') else result - return [{"value": role.id, "label": role.desc or role.id} for role in items] + return [{"value": role["id"], "label": role.get("desc") or role["id"]} for role in items] @router.get("/{instanceId}/contracts/options", response_model=List[Dict[str, Any]]) @@ -1136,3 +1196,267 @@ async def deletePositionDocument( if not success: raise HTTPException(status_code=400, detail="Failed to delete link") return {"message": f"Link {linkId} deleted"} + + +# ===== Instance Roles Management ===== +# These endpoints allow feature admins to manage instance-specific roles and their AccessRules + +from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + + +async def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str: + """ + Validate that the user has admin access to the feature instance. + Returns the mandateId if authorized. + + This checks for the RESOURCE permission 'instance-roles.manage'. + """ + mandateId = await _validateInstanceAccess(instanceId, context) + + # SysAdmin always has access + if context.user.isSysAdmin: + return mandateId + + # Check for instance-roles.manage resource permission + featureInterface = getFeatureInterface() + permissions = featureInterface.getUserPermissionsForInstance(context.user.id, instanceId) + + if not permissions: + raise HTTPException( + status_code=403, + detail="Keine Berechtigung zur Rollenverwaltung" + ) + + # Check for resource permission + resourcePermissions = permissions.get("resources", {}) + if not resourcePermissions.get("instance-roles.manage"): + raise HTTPException( + status_code=403, + detail="Keine Berechtigung zur Rollenverwaltung" + ) + + return mandateId + + +@router.get("/{instanceId}/instance-roles", response_model=PaginatedResponse) +@limiter.limit("30/minute") +async def getInstanceRoles( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + context: RequestContext = Depends(getRequestContext) +) -> PaginatedResponse: + """ + Get all roles for this feature instance. + Requires feature admin permission. + """ + mandateId = await _validateInstanceAdmin(instanceId, context) + + rootInterface = getRootInterface() + + # Get instance-specific roles (mandateId set, featureInstanceId matches) + roles = rootInterface.db.getRecordset( + Role, + recordFilter={ + "featureCode": "trustee", + "featureInstanceId": instanceId + } + ) + + return PaginatedResponse( + items=roles, + pagination=None + ) + + +@router.get("/{instanceId}/instance-roles/{roleId}", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def getInstanceRole( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + roleId: str = Path(..., description="Role ID"), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """Get a specific instance role.""" + mandateId = await _validateInstanceAdmin(instanceId, context) + + rootInterface = getRootInterface() + role = rootInterface.db.getRecord(Role, roleId) + + if not role: + raise HTTPException(status_code=404, detail=f"Role {roleId} not found") + + # Verify role belongs to this instance + if role.get("featureInstanceId") != instanceId: + raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance") + + return role + + +@router.get("/{instanceId}/instance-roles/{roleId}/rules", response_model=PaginatedResponse) +@limiter.limit("30/minute") +async def getInstanceRoleRules( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + roleId: str = Path(..., description="Role ID"), + context: RequestContext = Depends(getRequestContext) +) -> PaginatedResponse: + """ + Get all AccessRules for a specific instance role. + Requires feature admin permission. + """ + mandateId = await _validateInstanceAdmin(instanceId, context) + + rootInterface = getRootInterface() + + # Verify role belongs to this instance + role = rootInterface.db.getRecord(Role, roleId) + if not role or role.get("featureInstanceId") != instanceId: + raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance") + + # Get AccessRules for this role + rules = rootInterface.db.getRecordset( + AccessRule, + recordFilter={"roleId": roleId} + ) + + return PaginatedResponse( + items=rules, + pagination=None + ) + + +@router.post("/{instanceId}/instance-roles/{roleId}/rules", response_model=Dict[str, Any], status_code=201) +@limiter.limit("10/minute") +async def createInstanceRoleRule( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + roleId: str = Path(..., description="Role ID"), + ruleData: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Create a new AccessRule for an instance role. + Requires feature admin permission. + """ + mandateId = await _validateInstanceAdmin(instanceId, context) + + rootInterface = getRootInterface() + + # Verify role belongs to this instance + role = rootInterface.db.getRecord(Role, roleId) + if not role or role.get("featureInstanceId") != instanceId: + raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance") + + # Create the rule + try: + contextStr = ruleData.get("context", "UI") + if isinstance(contextStr, str): + contextEnum = AccessRuleContext(contextStr.upper()) + else: + contextEnum = contextStr + + newRule = AccessRule( + roleId=roleId, + context=contextEnum, + item=ruleData.get("item"), + view=ruleData.get("view", False), + read=ruleData.get("read"), + create=ruleData.get("create"), + update=ruleData.get("update"), + delete=ruleData.get("delete"), + ) + + created = rootInterface.db.recordCreate(AccessRule, newRule.model_dump()) + return created + + except Exception as e: + logger.error(f"Error creating AccessRule: {e}") + raise HTTPException(status_code=400, detail=f"Failed to create rule: {str(e)}") + + +@router.put("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}", response_model=Dict[str, Any]) +@limiter.limit("10/minute") +async def updateInstanceRoleRule( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + roleId: str = Path(..., description="Role ID"), + ruleId: str = Path(..., description="Rule ID"), + ruleData: Dict[str, Any] = Body(...), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Update an AccessRule for an instance role. + Only view, read, create, update, delete can be changed. + Requires feature admin permission. + """ + mandateId = await _validateInstanceAdmin(instanceId, context) + + rootInterface = getRootInterface() + + # Verify role belongs to this instance + role = rootInterface.db.getRecord(Role, roleId) + if not role or role.get("featureInstanceId") != instanceId: + raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance") + + # Verify rule belongs to role + existingRule = rootInterface.db.getRecord(AccessRule, ruleId) + if not existingRule or existingRule.get("roleId") != roleId: + raise HTTPException(status_code=404, detail=f"Rule {ruleId} not found for this role") + + # Update only allowed fields + updateData = {} + if "view" in ruleData: + updateData["view"] = ruleData["view"] + if "read" in ruleData: + updateData["read"] = ruleData["read"] + if "create" in ruleData: + updateData["create"] = ruleData["create"] + if "update" in ruleData: + updateData["update"] = ruleData["update"] + if "delete" in ruleData: + updateData["delete"] = ruleData["delete"] + + if not updateData: + return existingRule + + try: + updated = rootInterface.db.recordUpdate(AccessRule, ruleId, updateData) + return updated + except Exception as e: + logger.error(f"Error updating AccessRule: {e}") + raise HTTPException(status_code=400, detail=f"Failed to update rule: {str(e)}") + + +@router.delete("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}") +@limiter.limit("10/minute") +async def deleteInstanceRoleRule( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + roleId: str = Path(..., description="Role ID"), + ruleId: str = Path(..., description="Rule ID"), + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Delete an AccessRule for an instance role. + Requires feature admin permission. + """ + mandateId = await _validateInstanceAdmin(instanceId, context) + + rootInterface = getRootInterface() + + # Verify role belongs to this instance + role = rootInterface.db.getRecord(Role, roleId) + if not role or role.get("featureInstanceId") != instanceId: + raise HTTPException(status_code=404, detail=f"Role {roleId} not found in this instance") + + # Verify rule belongs to role + existingRule = rootInterface.db.getRecord(AccessRule, ruleId) + if not existingRule or existingRule.get("roleId") != roleId: + raise HTTPException(status_code=404, detail=f"Rule {ruleId} not found for this role") + + try: + rootInterface.db.recordDelete(AccessRule, ruleId) + return {"message": f"Rule {ruleId} deleted"} + except Exception as e: + logger.error(f"Error deleting AccessRule: {e}") + raise HTTPException(status_code=400, detail=f"Failed to delete rule: {str(e)}") diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index f16a9bc7..dcd32bbc 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -311,6 +311,50 @@ async def getAccessRules( ) +@router.get("/rules/by-role/{roleId}", response_model=PaginatedResponse) +@limiter.limit("30/minute") +async def getAccessRulesByRole( + request: Request, + roleId: str = Path(..., description="Role ID to get rules for"), + currentUser: User = Depends(requireSysAdmin) +) -> PaginatedResponse: + """ + Get all access rules for a specific role. + MULTI-TENANT: SysAdmin-only. + + Path Parameters: + - roleId: The role ID to get rules for + + Returns: + - List of AccessRule objects for the specified role + """ + try: + interface = getRootInterface() + + # Build filter for roleId + recordFilter = {"roleId": roleId} + + # Get rules from database + rules = interface.db.getRecordset(AccessRule, recordFilter=recordFilter) + + # Convert to AccessRule objects + ruleObjects = [AccessRule(**rule) for rule in rules] + + return PaginatedResponse( + items=[rule.model_dump() for rule in ruleObjects], + pagination=None + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting access rules for role {roleId}: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Failed to get access rules for role: {str(e)}" + ) + + @router.get("/rules/{ruleId}", response_model=dict) @limiter.limit("30/minute") async def getAccessRule(