saas mandates core done

This commit is contained in:
ValueOn AG 2026-01-21 00:32:47 +01:00
parent 77e1414744
commit 82c01b5cb0
17 changed files with 438 additions and 255 deletions

View file

@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2Z
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG

View file

@ -6,6 +6,7 @@ import uuid
from typing import Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual
class Feature(BaseModel):
@ -17,8 +18,7 @@ class Feature(BaseModel):
description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
label: dict = Field(
default_factory=dict,
label: TextMultilingual = Field(
description="Feature label in multiple languages (I18n)",
json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True}
)

View file

@ -20,15 +20,15 @@ class UserMandate(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user-mandate membership",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"}
)
enabled: bool = Field(
default=True,
@ -58,15 +58,15 @@ class FeatureAccess(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature access",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userId: str = Field(
description="FK → User.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
)
featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
)
enabled: bool = Field(
default=True,
@ -96,15 +96,15 @@ class UserMandateRole(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
@ -127,15 +127,15 @@ class FeatureAccessRole(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)

View file

@ -40,7 +40,7 @@ class Role(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleLabel: str = Field(
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')",
@ -55,17 +55,17 @@ class Role(BaseModel):
mandateId: Optional[str] = Field(
default=None,
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "name"}
)
featureInstanceId: Optional[str] = Field(
default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"}
)
featureCode: Optional[str] = Field(
default=None,
description="Feature code (z.B. 'trustee') - für Template-Rollen",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
isSystemRole: bool = Field(
@ -100,11 +100,11 @@ class AccessRule(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
)
context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",

View file

@ -67,12 +67,17 @@ class Mandate(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
name: str = Field(
description="Name of the mandate",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
)
description: Optional[str] = Field(
default=None,
description="Description of the mandate",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}
)
enabled: bool = Field(
default=True,
description="Indicates whether the mandate is enabled",
@ -86,6 +91,7 @@ registerModelLabels(
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"name": {"en": "Name", "de": "Name", "fr": "Nom"},
"description": {"en": "Description", "de": "Beschreibung", "fr": "Description"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
},
)
@ -143,11 +149,11 @@ class User(BaseModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}
)
username: str = Field(
description="Username for login",
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}
description="Username for login (immutable after creation)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
email: Optional[EmailStr] = Field(
default=None,

View file

@ -30,6 +30,7 @@ from modules.datamodels.datamodelMembership import (
UserMandate,
UserMandateRole,
)
from modules.datamodels.datamodelFeatures import Feature
logger = logging.getLogger(__name__)
@ -55,6 +56,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Initialize roles FIRST (needed for AccessRules)
initRoles(db)
# Initialize features (trustee, chatbot, etc.)
initFeatures(db)
# Initialize RBAC rules (uses roleIds from roles)
initRbacRules(db)
@ -114,6 +118,13 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
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})
logger.info(f"Admin user already exists with ID {userId}")
return userId
@ -175,6 +186,10 @@ def initRoles(db: DatabaseConnector) -> None:
Initialize standard roles if they don't exist.
Roles are created as GLOBAL (mandateId=None) template roles.
NOTE: SysAdmin is NOT a role - it's a flag (User.isSysAdmin).
SysAdmin users bypass RBAC entirely and have full system access.
These template roles are for mandate/feature-level access control.
Args:
db: Database connector instance
"""
@ -182,15 +197,9 @@ def initRoles(db: DatabaseConnector) -> None:
global _roleIdCache
_roleIdCache = {}
# Standard template roles for mandate/feature-level access
# NOTE: No "sysadmin" role - SysAdmin is a flag (User.isSysAdmin), not a role!
standardRoles = [
Role(
roleLabel="sysadmin",
description={"en": "System Administrator - Full access to all system resources", "de": "System-Administrator - Vollzugriff auf alle System-Ressourcen", "fr": "Administrateur système - Accès complet à toutes les ressources"},
mandateId=None, # Global template role
featureInstanceId=None,
featureCode=None,
isSystemRole=True
),
Role(
roleLabel="admin",
description={"en": "Administrator - Manage users and resources within mandate scope", "de": "Administrator - Benutzer und Ressourcen im Mandanten verwalten", "fr": "Administrateur - Gérer les utilisateurs et ressources dans le périmètre du mandat"},
@ -235,6 +244,63 @@ def initRoles(db: DatabaseConnector) -> None:
logger.info("Roles initialization completed")
def initFeatures(db: DatabaseConnector) -> None:
"""
Initialize standard features if they don't exist.
Features are global definitions that can be instantiated within mandates.
Each feature has a unique code (e.g., 'trustee', 'chatbot').
Args:
db: Database connector instance
"""
logger.info("Initializing features")
# Standard features available in the system
standardFeatures = [
Feature(
code="trustee",
label={"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"},
icon="mdi-briefcase"
),
Feature(
code="chatbot",
label={"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"},
icon="mdi-robot"
),
Feature(
code="chatworkflow",
label={"en": "Chat Workflow", "de": "Chat-Workflow", "fr": "Workflow de Chat"},
icon="mdi-message-cog"
),
Feature(
code="neutralization",
label={"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"},
icon="mdi-shield-check"
),
Feature(
code="realestate",
label={"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"},
icon="mdi-home-city"
),
]
existingFeatures = db.getRecordset(Feature)
existingCodes = {f.get("code") for f in existingFeatures}
for feature in standardFeatures:
if feature.code not in existingCodes:
try:
db.recordCreate(Feature, feature.model_dump())
logger.info(f"Created feature: {feature.code}")
except Exception as e:
logger.warning(f"Error creating feature {feature.code}: {e}")
else:
logger.debug(f"Feature {feature.code} already exists")
logger.info("Features initialization completed")
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
"""
Get role ID by label, using cache or database lookup.
@ -297,26 +363,15 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
Create default role rules for generic access (item = null).
Uses roleId instead of roleLabel.
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
Args:
db: Database connector instance
"""
defaultRules = []
# SysAdmin Role - Full access to all
sysadminId = _getRoleId(db, "sysadmin")
if sysadminId:
defaultRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item=None,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
# Admin Role - Group-level access
# Admin Role - Group-level access (highest role-based permission)
adminId = _getRoleId(db, "admin")
if adminId:
defaultRules.append(AccessRule(
@ -370,29 +425,21 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
These rules override generic rules for specific tables.
Uses roleId instead of roleLabel.
NOTE: No rules for "sysadmin" - SysAdmin is a flag (User.isSysAdmin), not a role!
SysAdmin users bypass RBAC entirely via the isSysAdmin check in getRecordsetWithRBAC().
Args:
db: Database connector instance
"""
tableRules = []
# Get role IDs
sysadminId = _getRoleId(db, "sysadmin")
# Get role IDs (no sysadmin - that's a flag, not a role!)
adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer")
# Mandate table - Only sysadmin can access
if sysadminId:
tableRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item="Mandate",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
# Mandate table - Only SysAdmin (flag) can access, not roles
# Regular roles have no access to Mandate table
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
@ -427,18 +474,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
# UserInDB table
if sysadminId:
tableRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item="UserInDB",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
# UserInDB table - Admin can manage users within group scope
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
@ -474,6 +510,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
))
# Standard tables with typical access patterns
# NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag
standardTables = [
"UserConnection", "DataNeutraliserConfig", "DataNeutralizerAttributes",
"ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument",
@ -483,17 +520,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
]
for table in standardTables:
if sysadminId:
tableRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item=table,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
# Admin gets full group-level access (highest role-based permission)
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
@ -528,28 +555,18 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE,
))
# AuthEvent table - special handling
if sysadminId:
tableRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item="AuthEvent",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.ALL,
))
# AuthEvent table - Audit logs (no delete allowed for audit integrity!)
# SysAdmin can delete via isSysAdmin bypass, but regular admins cannot
if adminId:
tableRules.append(AccessRule(
roleId=adminId,
context=AccessRuleContext.DATA,
item="AuthEvent",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.ALL,
read=AccessLevel.ALL, # Admin can see all auth events for security monitoring
create=AccessLevel.NONE, # Events are system-generated
update=AccessLevel.NONE, # Audit logs are immutable
delete=AccessLevel.NONE, # NO delete - audit integrity!
))
if userId:
tableRules.append(AccessRule(
@ -557,7 +574,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
context=AccessRuleContext.DATA,
item="AuthEvent",
view=True,
read=AccessLevel.MY,
read=AccessLevel.MY, # Users can see their own auth events
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
@ -586,13 +603,15 @@ def _createUiContextRules(db: DatabaseConnector) -> None:
Create UI context rules for controlling UI element visibility.
Uses roleId instead of roleLabel.
NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag.
Args:
db: Database connector instance
"""
uiRules = []
# All roles get full UI access by default
for roleLabel in ["sysadmin", "admin", "user", "viewer"]:
# All roles get full UI access by default (no sysadmin - that's a flag)
for roleLabel in ["admin", "user", "viewer"]:
roleId = _getRoleId(db, roleLabel)
if roleId:
uiRules.append(AccessRule(
@ -617,13 +636,15 @@ def _createResourceContextRules(db: DatabaseConnector) -> None:
Create RESOURCE context rules for controlling resource access.
Uses roleId instead of roleLabel.
NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag.
Args:
db: Database connector instance
"""
resourceRules = []
# All roles get full resource access by default
for roleLabel in ["sysadmin", "admin", "user", "viewer"]:
# All roles get full resource access by default (no sysadmin - that's a flag)
for roleLabel in ["admin", "user", "viewer"]:
roleId = _getRoleId(db, roleLabel)
if roleId:
resourceRules.append(AccessRule(
@ -653,15 +674,19 @@ def assignInitialUserMemberships(
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
This is the NEW multi-tenant way of assigning roles.
NOTE: SysAdmin is a flag (User.isSysAdmin), not a role. Initial users get the "admin" role
within the root mandate, plus they have isSysAdmin=True for system-level access.
Args:
db: Database connector instance
mandateId: Root mandate ID
adminUserId: Admin user ID
eventUserId: Event user ID
"""
sysadminRoleId = _getRoleId(db, "sysadmin")
if not sysadminRoleId:
logger.warning("Sysadmin role not found, skipping membership assignment")
# Use "admin" role for mandate membership (SysAdmin is a flag, not a role!)
adminRoleId = _getRoleId(db, "admin")
if not adminRoleId:
logger.warning("Admin role not found, skipping membership assignment")
return
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
@ -688,17 +713,17 @@ def assignInitialUserMemberships(
# Check if UserMandateRole already exists
existingRoles = db.getRecordset(
UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId}
recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
)
if not existingRoles:
# Create UserMandateRole
# Create UserMandateRole with "admin" role
userMandateRole = UserMandateRole(
userMandateId=userMandateId,
roleId=sysadminRoleId
roleId=adminRoleId
)
db.recordCreate(UserMandateRole, userMandateRole)
logger.info(f"Assigned sysadmin role to {userName} user in mandate")
logger.info(f"Assigned admin role to {userName} user in mandate")
def _getPasswordHash(password: Optional[str]) -> Optional[str]:

View file

@ -1313,13 +1313,13 @@ class AppObjects:
return Mandate(**filteredMandates[0])
def createMandate(self, name: str, language: str = "en") -> Mandate:
def createMandate(self, name: str, description: str = None, enabled: bool = True) -> Mandate:
"""Creates a new mandate if user has permission."""
if not self.checkRbacPermission(Mandate, "create"):
raise PermissionError("No permission to create mandates")
# Create mandate data using model
mandateData = Mandate(name=name, language=language)
mandateData = Mandate(name=name, description=description, enabled=enabled)
# Create mandate record
createdRecord = self.db.recordCreate(Mandate, mandateData)

View file

@ -59,6 +59,14 @@ def getRecordsetWithRBAC(
if not connector._ensureTableExists(modelClass):
return []
# SysAdmin bypass: SysAdmin users have full access to all tables
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
if isSysAdmin:
logger.debug(f"SysAdmin user {currentUser.id} bypassing RBAC for table {table}")
# Direct access without RBAC filtering
# Note: getRecordset doesn't support orderBy/limit - these are only used in RBAC path
return connector.getRecordset(modelClass, recordFilter=recordFilter)
# Get RBAC permissions for this table
# AccessRule table is always in DbApp database
dbApp = getRootDbAppConnector()

View file

@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Query, Body, Path, Reques
from typing import List, Dict, Any, Optional, Set
import logging
from modules.auth import limiter, requireSysAdmin, RequestContext
from modules.auth import limiter, requireSysAdmin
from modules.datamodels.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
@ -78,7 +78,7 @@ router = APIRouter(
@limiter.limit("60/minute")
async def listRoles(
request: Request,
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get list of all available roles with metadata.
@ -123,7 +123,7 @@ async def listRoles(
@limiter.limit("60/minute")
async def getRoleOptions(
request: Request,
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get role options for select dropdowns.
@ -165,7 +165,7 @@ async def getRoleOptions(
async def createRole(
request: Request,
role: Role = Body(...),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Create a new role.
@ -209,7 +209,7 @@ async def createRole(
async def getRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get a role by ID.
@ -254,7 +254,7 @@ async def updateRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update an existing role.
@ -301,7 +301,7 @@ async def updateRole(
async def deleteRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, str]:
"""
Delete a role.
@ -346,7 +346,7 @@ async def listUsersWithRoles(
request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get list of users with their role assignments.
@ -417,7 +417,7 @@ async def listUsersWithRoles(
async def getUserRoles(
request: Request,
userId: str = Path(..., description="User ID"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get role assignments for a specific user.
@ -468,7 +468,7 @@ async def updateUserRoles(
request: Request,
userId: str = Path(..., description="User ID"),
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update role assignments for a specific user.
@ -535,7 +535,7 @@ async def updateUserRoles(
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {context.user.id}")
logger.info(f"Updated roles for user {userId}: {newRoleLabels} by SysAdmin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
@ -565,7 +565,7 @@ async def addUserRole(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Add a role to a user (if not already assigned).
@ -617,7 +617,7 @@ async def addUserRole(
# Add the role
newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id))
interface.db.recordCreate(UserMandateRole, newRole.model_dump())
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {context.user.id}")
logger.info(f"Added role {roleLabel} to user {userId} by SysAdmin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
@ -647,7 +647,7 @@ async def removeUserRole(
request: Request,
userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Remove a role from a user.
@ -697,7 +697,7 @@ async def removeUserRole(
roleRemoved = True
if roleRemoved:
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {context.user.id}")
logger.info(f"Removed role {roleLabel} from user {userId} by SysAdmin {currentUser.id}")
userRoleLabels = _getUserRoleLabels(interface, userId)
return {
@ -727,7 +727,7 @@ async def getUsersWithRole(
request: Request,
roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get all users with a specific role.

View file

@ -55,10 +55,10 @@ class MandateUserInfo(BaseModel):
userId: str
username: str
email: Optional[str]
firstname: Optional[str]
lastname: Optional[str]
fullName: Optional[str]
userMandateId: str
roleIds: List[str]
roleLabels: List[str] # Resolved role labels for display
enabled: bool
# Configure logger
@ -79,7 +79,7 @@ router = APIRouter(
async def get_mandates(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> PaginatedResponse[Mandate]:
"""
Get mandates with optional pagination, sorting, and filtering.
@ -143,7 +143,7 @@ async def get_mandates(
async def get_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Mandate:
"""
Get a specific mandate by ID.
@ -174,7 +174,7 @@ async def get_mandate(
async def create_mandate(
request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Mandate:
"""
Create a new mandate.
@ -192,14 +192,16 @@ async def create_mandate(
)
# Get optional fields with defaults
language = mandateData.get('language', 'en')
description = mandateData.get('description')
enabled = mandateData.get('enabled', True)
appInterface = interfaceDbAppObjects.getRootInterface()
# Create mandate
newMandate = appInterface.createMandate(
name=name,
language=language
description=description,
enabled=enabled
)
if not newMandate:
@ -208,7 +210,7 @@ async def create_mandate(
detail="Failed to create mandate"
)
logger.info(f"Mandate {newMandate.id} created by SysAdmin {context.user.id}")
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
return newMandate
except HTTPException:
@ -226,7 +228,7 @@ async def update_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"),
mandateData: dict = Body(..., description="Mandate update data"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Mandate:
"""
Update an existing mandate.
@ -254,7 +256,7 @@ async def update_mandate(
detail="Failed to update mandate"
)
logger.info(f"Mandate {mandateId} updated by SysAdmin {context.user.id}")
logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}")
return updatedMandate
except HTTPException:
@ -271,7 +273,7 @@ async def update_mandate(
async def delete_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Delete a mandate.
@ -304,7 +306,7 @@ async def delete_mandate(
detail=str(e)
)
logger.info(f"Mandate {mandateId} deleted by SysAdmin {context.user.id}")
logger.info(f"Mandate {mandateId} deleted by SysAdmin {currentUser.id}")
return {"message": f"Mandate {mandateId} deleted successfully"}
except HTTPException:
@ -360,21 +362,30 @@ async def listMandateUsers(
result = []
for um in userMandates:
# Get user info
user = rootInterface.getUserById(um.get("userId"))
user = rootInterface.getUser(um.get("userId"))
if not user:
continue
# Get roles for this membership
roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id"))
# Resolve role labels for display
roleLabels = []
for roleId in roleIds:
role = rootInterface.getRole(roleId)
if role:
roleLabels.append(role.roleLabel)
else:
roleLabels.append(roleId) # Fallback to ID if not found
result.append(MandateUserInfo(
userId=str(user.id),
username=user.username,
email=user.email,
firstname=user.firstname,
lastname=user.lastname,
fullName=user.fullName,
userMandateId=um.get("id"),
roleIds=roleIds,
roleLabels=roleLabels,
enabled=um.get("enabled", True)
))
@ -434,7 +445,7 @@ async def addUserToMandate(
)
# 4. Verify target user exists
targetUser = rootInterface.getUserById(data.targetUserId)
targetUser = rootInterface.getUser(data.targetUserId)
if not targetUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,

View file

@ -12,6 +12,7 @@ MULTI-TENANT: User management requires RequestContext.
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
from typing import List, Dict, Any, Optional
from fastapi import status
from pydantic import BaseModel
import logging
import json
@ -192,12 +193,22 @@ async def get_user(
detail=f"Failed to get user: {str(e)}"
)
class CreateUserRequest(BaseModel):
"""Request body for creating a new user"""
username: str
email: Optional[str] = None
fullName: Optional[str] = None
language: str = "en"
enabled: bool = True
isSysAdmin: bool = False
password: Optional[str] = None
@router.post("", response_model=User)
@limiter.limit("10/minute")
async def create_user(
request: Request,
user_data: User = Body(...),
password: Optional[str] = Body(None, embed=True),
userData: CreateUserRequest = Body(...),
context: RequestContext = Depends(getRequestContext)
) -> User:
"""
@ -206,16 +217,17 @@ async def create_user(
"""
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Extract fields from User model and call createUser with individual parameters
# Extract fields from request model and call createUser with individual parameters
from modules.datamodels.datamodelUam import AuthAuthority
newUser = appInterface.createUser(
username=user_data.username,
password=password,
email=user_data.email,
fullName=user_data.fullName,
language=user_data.language,
enabled=user_data.enabled,
authenticationAuthority=user_data.authenticationAuthority
username=userData.username,
password=userData.password,
email=userData.email,
fullName=userData.fullName,
language=userData.language,
enabled=userData.enabled,
authenticationAuthority=AuthAuthority.LOCAL,
isSysAdmin=userData.isSysAdmin
)
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
@ -303,7 +315,7 @@ async def reset_user_password(
appInterface = interfaceDbAppObjects.getInterface(context.user)
# Get target user
target_user = appInterface.getUserById(userId)
target_user = appInterface.getUser(userId)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,

View file

@ -13,6 +13,7 @@ import logging
# Import interfaces and models
import modules.interfaces.interfaceDbChatbot as interfaceDbChatbot
from modules.auth import limiter, getRequestContext, requireSysAdmin, RequestContext
from modules.datamodels.datamodelUam import User
# Configure logger
logger = logging.getLogger(__name__)
@ -34,7 +35,7 @@ router = APIRouter(
@limiter.limit("30/minute")
async def get_all_automation_events(
request: Request,
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get all automation events across all mandates (sysadmin only).
@ -68,7 +69,7 @@ async def get_all_automation_events(
@limiter.limit("5/minute")
async def sync_all_automation_events(
request: Request,
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Manually trigger sync for all automations (sysadmin only).
@ -79,7 +80,7 @@ async def sync_all_automation_events(
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.features.workflow import syncAutomationEvents
chatInterface = getChatInterface(context.user)
chatInterface = getChatInterface(currentUser)
# Get event user for sync operation (routes can import from interfaces)
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
@ -90,7 +91,7 @@ async def sync_all_automation_events(
)
from modules.services import getInterface as getServices
services = getServices(context.user, None)
services = getServices(currentUser, None)
result = await syncAutomationEvents(services, eventUser)
return {
"success": True,
@ -111,7 +112,7 @@ async def sync_all_automation_events(
async def remove_event(
request: Request,
eventId: str = Path(..., description="Event ID to remove"),
context: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Manually remove a specific event from scheduler (sysadmin only).
@ -126,7 +127,7 @@ async def remove_event(
# Update automation's eventId if it exists
if eventId.startswith("automation."):
automation_id = eventId.replace("automation.", "")
chatInterface = interfaceDbChatbot.getInterface(context.user)
chatInterface = interfaceDbChatbot.getInterface(currentUser)
automation = chatInterface.getAutomationDefinition(automation_id)
if automation and getattr(automation, "eventId", None) == eventId:
chatInterface.updateAutomationDefinition(automation_id, {"eventId": None})

View file

@ -295,42 +295,6 @@ def _mergeAccessLevel(current: str, new: str) -> str:
return current
@router.get("/{featureCode}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getFeature(
request: Request,
featureCode: str,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get a specific feature by code.
Args:
featureCode: Feature code (e.g., 'trustee', 'chatbot')
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
feature = featureInterface.getFeature(featureCode)
if not feature:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{featureCode}' not found"
)
return feature.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting feature {featureCode}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get feature: {str(e)}"
)
@router.post("/", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def createFeature(
@ -745,6 +709,49 @@ async def createTemplateRole(
)
# =============================================================================
# Dynamic Feature Route (MUST be last to avoid catching /instances, /my, etc.)
# =============================================================================
@router.get("/{featureCode}", response_model=Dict[str, Any])
@limiter.limit("60/minute")
async def getFeature(
request: Request,
featureCode: str,
context: RequestContext = Depends(getRequestContext)
) -> Dict[str, Any]:
"""
Get a specific feature by code.
IMPORTANT: This route must be defined LAST to avoid catching paths like
/instances, /my, /templates, etc.
Args:
featureCode: Feature code (e.g., 'trustee', 'chatbot')
"""
try:
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
feature = featureInterface.getFeature(featureCode)
if not feature:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Feature '{featureCode}' not found"
)
return feature.model_dump()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting feature {featureCode}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get feature: {str(e)}"
)
# =============================================================================
# Helper Functions
# =============================================================================

View file

@ -230,7 +230,7 @@ async def getAccessRules(
context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"),
item: Optional[str] = Query(None, description="Filter by item identifier"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> PaginatedResponse:
"""
Get access rules with optional filters.
@ -316,7 +316,7 @@ async def getAccessRules(
async def getAccessRule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> dict:
"""
Get a specific access rule by ID.
@ -358,7 +358,7 @@ async def getAccessRule(
async def createAccessRule(
request: Request,
accessRuleData: dict = Body(..., description="Access rule data"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> dict:
"""
Create a new access rule.
@ -404,7 +404,7 @@ async def createAccessRule(
# Create rule
createdRule = interface.createAccessRule(accessRule)
logger.info(f"Created access rule {createdRule.id} by SysAdmin {reqContext.user.id}")
logger.info(f"Created access rule {createdRule.id} by SysAdmin {currentUser.id}")
# Convert to dict for JSON serialization
return createdRule.model_dump()
@ -425,7 +425,7 @@ async def updateAccessRule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
accessRuleData: dict = Body(..., description="Updated access rule data"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> dict:
"""
Update an existing access rule.
@ -487,7 +487,7 @@ async def updateAccessRule(
# Update rule
updatedRule = interface.updateAccessRule(ruleId, accessRule)
logger.info(f"Updated access rule {ruleId} by SysAdmin {reqContext.user.id}")
logger.info(f"Updated access rule {ruleId} by SysAdmin {currentUser.id}")
# Convert to dict for JSON serialization
return updatedRule.model_dump()
@ -507,7 +507,7 @@ async def updateAccessRule(
async def deleteAccessRule(
request: Request,
ruleId: str = Path(..., description="Access rule ID"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> dict:
"""
Delete an access rule.
@ -540,7 +540,7 @@ async def deleteAccessRule(
detail=f"Failed to delete access rule {ruleId}"
)
logger.info(f"Deleted access rule {ruleId} by SysAdmin {reqContext.user.id}")
logger.info(f"Deleted access rule {ruleId} by SysAdmin {currentUser.id}")
return {"success": True, "message": f"Access rule {ruleId} deleted successfully"}
@ -565,7 +565,7 @@ async def deleteAccessRule(
async def listRoles(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> PaginatedResponse:
"""
Get list of all available roles with metadata.
@ -604,6 +604,9 @@ async def listRoles(
"id": role.id,
"roleLabel": role.roleLabel,
"description": role.description,
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
"userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole
})
@ -662,7 +665,7 @@ async def listRoles(
@limiter.limit("60/minute")
async def getRoleOptions(
request: Request,
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]:
"""
Get role options for select dropdowns.
@ -704,7 +707,7 @@ async def getRoleOptions(
async def createRole(
request: Request,
role: Role = Body(...),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Create a new role.
@ -721,12 +724,15 @@ async def createRole(
createdRole = interface.createRole(role)
logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {reqContext.user.id}")
logger.info(f"Created role {createdRole.roleLabel} by SysAdmin {currentUser.id}")
return {
"id": createdRole.id,
"roleLabel": createdRole.roleLabel,
"description": createdRole.description,
"mandateId": createdRole.mandateId,
"featureInstanceId": createdRole.featureInstanceId,
"featureCode": createdRole.featureCode,
"isSystemRole": createdRole.isSystemRole
}
@ -750,7 +756,7 @@ async def createRole(
async def getRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Get a role by ID.
@ -776,6 +782,9 @@ async def getRole(
"id": role.id,
"roleLabel": role.roleLabel,
"description": role.description,
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
"isSystemRole": role.isSystemRole
}
@ -795,7 +804,7 @@ async def updateRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
role: Role = Body(...),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]:
"""
Update an existing role.
@ -815,12 +824,15 @@ async def updateRole(
updatedRole = interface.updateRole(roleId, role)
logger.info(f"Updated role {roleId} by SysAdmin {reqContext.user.id}")
logger.info(f"Updated role {roleId} by SysAdmin {currentUser.id}")
return {
"id": updatedRole.id,
"roleLabel": updatedRole.roleLabel,
"description": updatedRole.description,
"mandateId": updatedRole.mandateId,
"featureInstanceId": updatedRole.featureInstanceId,
"featureCode": updatedRole.featureCode,
"isSystemRole": updatedRole.isSystemRole
}
@ -844,7 +856,7 @@ async def updateRole(
async def deleteRole(
request: Request,
roleId: str = Path(..., description="Role ID"),
reqContext: RequestContext = Depends(requireSysAdmin)
currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, str]:
"""
Delete a role.
@ -866,7 +878,7 @@ async def deleteRole(
detail=f"Role {roleId} not found"
)
logger.info(f"Deleted role {roleId} by SysAdmin {reqContext.user.id}")
logger.info(f"Deleted role {roleId} by SysAdmin {currentUser.id}")
return {"message": f"Role {roleId} deleted successfully"}

View file

@ -24,6 +24,55 @@ from modules.shared.configuration import APP_CONFIG
# Configure logger
logger = logging.getLogger(__name__)
def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool:
"""
Send authentication-related email directly without requiring full Services initialization.
Used for registration, password reset, and other auth flows.
Args:
recipient: Email address
subject: Email subject
message: Plain text message (will be converted to HTML)
userId: Optional user ID for logging
Returns:
bool: True if email was sent successfully
"""
try:
import html
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.datamodels.datamodelMessaging import MessagingChannel
# Convert plain text to simple HTML
escaped = html.escape(message)
escaped = escaped.replace('\n', '<br>\n')
htmlMessage = f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
{escaped}
</body>
</html>"""
messagingInterface = getMessagingInterface()
success = messagingInterface.send(
channel=MessagingChannel.EMAIL,
recipient=recipient,
subject=subject,
message=htmlMessage
)
if success:
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
else:
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
return success
except Exception as e:
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
return False
# Create router for Local Security endpoints
router = APIRouter(
prefix="/api/local",
@ -261,15 +310,11 @@ async def register_user(
# Send registration email with magic link
try:
from modules.services import Services
services = Services(user)
magicLink = f"{baseUrl}/reset?token={token}"
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
emailSubject = "PowerOn Registrierung - Passwort setzen"
emailBody = f"""
Hallo {user.fullName or user.username},
emailBody = f"""Hallo {user.fullName or user.username},
Vielen Dank für Ihre Registrierung bei PowerOn.
@ -280,10 +325,9 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
Dieser Link ist {expiryHours} Stunden gültig.
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren.
"""
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
emailSent = services.messaging.sendEmailDirect(
emailSent = _sendAuthEmail(
recipient=user.email,
subject=emailSubject,
message=emailBody,
@ -529,7 +573,6 @@ async def passwordResetRequest(
user = rootInterface.findUserByUsernameLocalAuth(username)
if user and user.email:
from modules.services import Services
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try:
@ -539,16 +582,12 @@ async def passwordResetRequest(
# Set reset token (clears password)
rootInterface.setResetToken(user.id, token, expires)
# Get services for email sending
services = Services(user)
# Generate magic link using provided frontend URL
magicLink = f"{baseUrl}/reset?token={token}"
# Send email
# Send email using dedicated auth email function
emailSubject = "PowerOn - Passwort zurücksetzen"
emailBody = f"""
Hallo {user.fullName or user.username},
emailBody = f"""Hallo {user.fullName or user.username},
Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.
@ -559,17 +598,19 @@ Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
Dieser Link ist {expiryHours} Stunden gültig.
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.
"""
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren."""
services.messaging.sendEmailDirect(
emailSent = _sendAuthEmail(
recipient=user.email,
subject=emailSubject,
message=emailBody,
userId=str(user.id)
)
if emailSent:
logger.info(f"Password reset email sent to {user.email} for user {user.username}")
else:
logger.warning(f"Failed to send password reset email to {user.email}")
except Exception as userErr:
logger.error(f"Failed to send reset email for user {username}: {str(userErr)}")
else:

View file

@ -120,6 +120,9 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
frontend_readonly = False
frontend_required = field.is_required()
frontend_options = None
frontend_visible = True # Default visible
frontend_fk_source = None # FK dropdown source (e.g., "/api/users/")
frontend_fk_display_field = None # Which field of the FK target to display (e.g., "username", "name")
if field_info:
# Try direct attributes first (though these won't exist for custom kwargs)
@ -167,6 +170,15 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
frontend_required = json_extra.get("frontend_required", frontend_required)
if frontend_options is None and "frontend_options" in json_extra:
frontend_options = json_extra.get("frontend_options")
# Extract frontend_visible (default True, can be set to False to hide field)
if "frontend_visible" in json_extra:
frontend_visible = json_extra.get("frontend_visible", True)
# Extract frontend_fk_source for FK dropdown references
if "frontend_fk_source" in json_extra:
frontend_fk_source = json_extra.get("frontend_fk_source")
# Extract frontend_fk_display_field - which field of FK target to display
if "frontend_fk_display_field" in json_extra:
frontend_fk_display_field = json_extra.get("frontend_fk_display_field")
# Use frontend type if available, otherwise detect from Python type
if frontend_type:
@ -215,8 +227,12 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
except Exception:
pass
attributes.append(
{
# Hide "id" fields by default unless explicitly set to visible
# Also hide fields ending with "Id" that are FK references (unless they have fkSource)
if name == "id":
frontend_visible = False # Never show primary key in forms/tables
attr_def = {
"name": name,
"type": field_type,
"required": frontend_required,
@ -224,13 +240,21 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
"label": labels.get(name, name),
"placeholder": f"Please enter {labels.get(name, name)}",
"editable": not frontend_readonly,
"visible": True,
"visible": frontend_visible,
"order": len(attributes),
"readonly": frontend_readonly,
"options": frontend_options,
"default": field_default,
}
)
# Add FK source for dropdown rendering if specified
if frontend_fk_source:
attr_def["fkSource"] = frontend_fk_source
# Also add display field if specified (which field of FK target to show)
if frontend_fk_display_field:
attr_def["fkDisplayField"] = frontend_fk_display_field
attributes.append(attr_def)
return {"model": model_label, "attributes": attributes}

View file

@ -96,7 +96,7 @@ _PARTIAL_INDEXES = [
_FOREIGN_KEYS = [
# UserMandate FKs
("UserMandate", "fk_usermandate_mandate", "mandateId", "Mandate", "id"),
("UserMandate", "fk_usermandate_user", "userId", "User", "id"),
("UserMandate", "fk_usermandate_user", "userId", "UserInDB", "id"),
# FeatureInstance FKs
("FeatureInstance", "fk_featureinstance_mandate", "mandateId", "Mandate", "id"),
@ -107,7 +107,7 @@ _FOREIGN_KEYS = [
# FeatureAccess FKs
("FeatureAccess", "fk_featureaccess_instance", "featureInstanceId", "FeatureInstance", "id"),
("FeatureAccess", "fk_featureaccess_user", "userId", "User", "id"),
("FeatureAccess", "fk_featureaccess_user", "userId", "UserInDB", "id"),
# AccessRule FKs
("AccessRule", "fk_accessrule_role", "roleId", "Role", "id"),
@ -133,6 +133,9 @@ _IMMUTABLE_TRIGGERS = [
# AccessRule: context, roleId are immutable
("AccessRule", "tr_accessrule_immutable", ["context", "roleId"]),
# User: username is immutable (login name cannot be changed)
("UserInDB", "tr_user_immutable", ["username"]),
]
@ -340,6 +343,25 @@ def _applyIndexes(cursor, tables: Optional[List[str]]) -> int:
return created
def _getForeignKeyTarget(cursor, constraintName: str) -> Optional[str]:
"""Get the target table of an existing FK constraint."""
cursor.execute("""
SELECT ccu.table_name AS foreign_table_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.constraint_name = %s
LIMIT 1
""", (constraintName,))
row = cursor.fetchone()
if row:
if isinstance(row, dict):
return row.get('foreign_table_name')
return row[0]
return None
def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int:
"""Apply foreign key constraints with CASCADE DELETE. Returns count created."""
created = 0
@ -351,7 +373,21 @@ def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int:
continue
if not _tableExists(cursor, refTable):
continue
# Check if constraint exists
if _constraintExists(cursor, constraintName):
# Verify it points to the correct table
currentTarget = _getForeignKeyTarget(cursor, constraintName)
if currentTarget == refTable:
# FK exists and points to correct table - skip
continue
else:
# FK exists but points to wrong table - drop and recreate
logger.info(f"FK {constraintName} points to {currentTarget}, expected {refTable} - recreating")
try:
cursor.execute(f'ALTER TABLE "{tableName}" DROP CONSTRAINT "{constraintName}"')
except Exception as e:
logger.warning(f"Failed to drop FK {constraintName}: {e}")
continue
try: