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 APP_TOKEN_EXPIRY=300
# CORS Configuration # 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 # Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG APP_LOGGING_LOG_LEVEL = DEBUG

View file

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

View file

@ -20,15 +20,15 @@ class UserMandate(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the user-mandate membership", 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( userId: str = Field(
description="FK → User.id (CASCADE DELETE)", 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( mandateId: str = Field(
description="FK → Mandate.id (CASCADE DELETE)", 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( enabled: bool = Field(
default=True, default=True,
@ -58,15 +58,15 @@ class FeatureAccess(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the feature access", 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( userId: str = Field(
description="FK → User.id (CASCADE DELETE)", 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( featureInstanceId: str = Field(
description="FK → FeatureInstance.id (CASCADE DELETE)", 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( enabled: bool = Field(
default=True, default=True,
@ -96,15 +96,15 @@ class UserMandateRole(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record", 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( userMandateId: str = Field(
description="FK → UserMandate.id (CASCADE DELETE)", 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( roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)", 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( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the junction record", 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( featureAccessId: str = Field(
description="FK → FeatureAccess.id (CASCADE DELETE)", 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( roleId: str = Field(
description="FK → Role.id (CASCADE DELETE)", 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( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the role", 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( roleLabel: str = Field(
description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')", description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')",
@ -55,17 +55,17 @@ class Role(BaseModel):
mandateId: Optional[str] = Field( mandateId: Optional[str] = Field(
default=None, default=None,
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.", 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( featureInstanceId: Optional[str] = Field(
default=None, default=None,
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.", 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( featureCode: Optional[str] = Field(
default=None, default=None,
description="Feature code (z.B. 'trustee') - für Template-Rollen", 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( isSystemRole: bool = Field(
@ -100,11 +100,11 @@ class AccessRule(BaseModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the access rule", 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( roleId: str = Field(
description="FK → Role.id (CASCADE DELETE!)", 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( context: AccessRuleContext = Field(
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!", description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",

View file

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

View file

@ -30,6 +30,7 @@ from modules.datamodels.datamodelMembership import (
UserMandate, UserMandate,
UserMandateRole, UserMandateRole,
) )
from modules.datamodels.datamodelFeatures import Feature
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -55,6 +56,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Initialize roles FIRST (needed for AccessRules) # Initialize roles FIRST (needed for AccessRules)
initRoles(db) initRoles(db)
# Initialize features (trustee, chatbot, etc.)
initFeatures(db)
# Initialize RBAC rules (uses roleIds from roles) # Initialize RBAC rules (uses roleIds from roles)
initRbacRules(db) initRbacRules(db)
@ -114,6 +118,13 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"}) existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"})
if existingUsers: if existingUsers:
userId = existingUsers[0].get("id") 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}") logger.info(f"Admin user already exists with ID {userId}")
return userId return userId
@ -175,6 +186,10 @@ def initRoles(db: DatabaseConnector) -> None:
Initialize standard roles if they don't exist. Initialize standard roles if they don't exist.
Roles are created as GLOBAL (mandateId=None) template roles. 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: Args:
db: Database connector instance db: Database connector instance
""" """
@ -182,15 +197,9 @@ def initRoles(db: DatabaseConnector) -> None:
global _roleIdCache global _roleIdCache
_roleIdCache = {} _roleIdCache = {}
# Standard template roles for mandate/feature-level access
# NOTE: No "sysadmin" role - SysAdmin is a flag (User.isSysAdmin), not a role!
standardRoles = [ 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( Role(
roleLabel="admin", 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"}, 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") 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]: def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
""" """
Get role ID by label, using cache or database lookup. 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). Create default role rules for generic access (item = null).
Uses roleId instead of roleLabel. 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: Args:
db: Database connector instance db: Database connector instance
""" """
defaultRules = [] defaultRules = []
# SysAdmin Role - Full access to all # Admin Role - Group-level access (highest role-based permission)
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
adminId = _getRoleId(db, "admin") adminId = _getRoleId(db, "admin")
if adminId: if adminId:
defaultRules.append(AccessRule( defaultRules.append(AccessRule(
@ -370,29 +425,21 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
These rules override generic rules for specific tables. These rules override generic rules for specific tables.
Uses roleId instead of roleLabel. 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: Args:
db: Database connector instance db: Database connector instance
""" """
tableRules = [] tableRules = []
# Get role IDs # Get role IDs (no sysadmin - that's a flag, not a role!)
sysadminId = _getRoleId(db, "sysadmin")
adminId = _getRoleId(db, "admin") adminId = _getRoleId(db, "admin")
userId = _getRoleId(db, "user") userId = _getRoleId(db, "user")
viewerId = _getRoleId(db, "viewer") viewerId = _getRoleId(db, "viewer")
# Mandate table - Only sysadmin can access # Mandate table - Only SysAdmin (flag) can access, not roles
if sysadminId: # Regular roles have no access to Mandate table
tableRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item="Mandate",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
if adminId: if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
@ -427,18 +474,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# UserInDB table # UserInDB table - Admin can manage users within group scope
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,
))
if adminId: if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
@ -474,6 +510,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
)) ))
# Standard tables with typical access patterns # Standard tables with typical access patterns
# NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag
standardTables = [ standardTables = [
"UserConnection", "DataNeutraliserConfig", "DataNeutralizerAttributes", "UserConnection", "DataNeutraliserConfig", "DataNeutralizerAttributes",
"ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", "ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument",
@ -483,17 +520,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
] ]
for table in standardTables: for table in standardTables:
if sysadminId: # Admin gets full group-level access (highest role-based permission)
tableRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item=table,
view=True,
read=AccessLevel.ALL,
create=AccessLevel.ALL,
update=AccessLevel.ALL,
delete=AccessLevel.ALL,
))
if adminId: if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
@ -528,28 +555,18 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
)) ))
# AuthEvent table - special handling # AuthEvent table - Audit logs (no delete allowed for audit integrity!)
if sysadminId: # SysAdmin can delete via isSysAdmin bypass, but regular admins cannot
tableRules.append(AccessRule(
roleId=sysadminId,
context=AccessRuleContext.DATA,
item="AuthEvent",
view=True,
read=AccessLevel.ALL,
create=AccessLevel.NONE,
update=AccessLevel.NONE,
delete=AccessLevel.ALL,
))
if adminId: if adminId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
roleId=adminId, roleId=adminId,
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="AuthEvent", item="AuthEvent",
view=True, view=True,
read=AccessLevel.ALL, read=AccessLevel.ALL, # Admin can see all auth events for security monitoring
create=AccessLevel.NONE, create=AccessLevel.NONE, # Events are system-generated
update=AccessLevel.NONE, update=AccessLevel.NONE, # Audit logs are immutable
delete=AccessLevel.ALL, delete=AccessLevel.NONE, # NO delete - audit integrity!
)) ))
if userId: if userId:
tableRules.append(AccessRule( tableRules.append(AccessRule(
@ -557,7 +574,7 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
context=AccessRuleContext.DATA, context=AccessRuleContext.DATA,
item="AuthEvent", item="AuthEvent",
view=True, view=True,
read=AccessLevel.MY, read=AccessLevel.MY, # Users can see their own auth events
create=AccessLevel.NONE, create=AccessLevel.NONE,
update=AccessLevel.NONE, update=AccessLevel.NONE,
delete=AccessLevel.NONE, delete=AccessLevel.NONE,
@ -586,13 +603,15 @@ def _createUiContextRules(db: DatabaseConnector) -> None:
Create UI context rules for controlling UI element visibility. Create UI context rules for controlling UI element visibility.
Uses roleId instead of roleLabel. Uses roleId instead of roleLabel.
NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag.
Args: Args:
db: Database connector instance db: Database connector instance
""" """
uiRules = [] uiRules = []
# All roles get full UI access by default # All roles get full UI access by default (no sysadmin - that's a flag)
for roleLabel in ["sysadmin", "admin", "user", "viewer"]: for roleLabel in ["admin", "user", "viewer"]:
roleId = _getRoleId(db, roleLabel) roleId = _getRoleId(db, roleLabel)
if roleId: if roleId:
uiRules.append(AccessRule( uiRules.append(AccessRule(
@ -617,13 +636,15 @@ def _createResourceContextRules(db: DatabaseConnector) -> None:
Create RESOURCE context rules for controlling resource access. Create RESOURCE context rules for controlling resource access.
Uses roleId instead of roleLabel. Uses roleId instead of roleLabel.
NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag.
Args: Args:
db: Database connector instance db: Database connector instance
""" """
resourceRules = [] resourceRules = []
# All roles get full resource access by default # All roles get full resource access by default (no sysadmin - that's a flag)
for roleLabel in ["sysadmin", "admin", "user", "viewer"]: for roleLabel in ["admin", "user", "viewer"]:
roleId = _getRoleId(db, roleLabel) roleId = _getRoleId(db, roleLabel)
if roleId: if roleId:
resourceRules.append(AccessRule( resourceRules.append(AccessRule(
@ -653,15 +674,19 @@ def assignInitialUserMemberships(
Assign initial memberships to admin and event users via UserMandate + UserMandateRole. Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
This is the NEW multi-tenant way of assigning roles. 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: Args:
db: Database connector instance db: Database connector instance
mandateId: Root mandate ID mandateId: Root mandate ID
adminUserId: Admin user ID adminUserId: Admin user ID
eventUserId: Event user ID eventUserId: Event user ID
""" """
sysadminRoleId = _getRoleId(db, "sysadmin") # Use "admin" role for mandate membership (SysAdmin is a flag, not a role!)
if not sysadminRoleId: adminRoleId = _getRoleId(db, "admin")
logger.warning("Sysadmin role not found, skipping membership assignment") if not adminRoleId:
logger.warning("Admin role not found, skipping membership assignment")
return return
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]: for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
@ -688,17 +713,17 @@ def assignInitialUserMemberships(
# Check if UserMandateRole already exists # Check if UserMandateRole already exists
existingRoles = db.getRecordset( existingRoles = db.getRecordset(
UserMandateRole, UserMandateRole,
recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId} recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
) )
if not existingRoles: if not existingRoles:
# Create UserMandateRole # Create UserMandateRole with "admin" role
userMandateRole = UserMandateRole( userMandateRole = UserMandateRole(
userMandateId=userMandateId, userMandateId=userMandateId,
roleId=sysadminRoleId roleId=adminRoleId
) )
db.recordCreate(UserMandateRole, userMandateRole) 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]: def _getPasswordHash(password: Optional[str]) -> Optional[str]:

View file

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

View file

@ -59,6 +59,14 @@ def getRecordsetWithRBAC(
if not connector._ensureTableExists(modelClass): if not connector._ensureTableExists(modelClass):
return [] 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 # Get RBAC permissions for this table
# AccessRule table is always in DbApp database # AccessRule table is always in DbApp database
dbApp = getRootDbAppConnector() 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 from typing import List, Dict, Any, Optional, Set
import logging 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.datamodelUam import User, UserInDB
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
@ -78,7 +78,7 @@ router = APIRouter(
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def listRoles( async def listRoles(
request: Request, request: Request,
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of all available roles with metadata. Get list of all available roles with metadata.
@ -123,7 +123,7 @@ async def listRoles(
@limiter.limit("60/minute") @limiter.limit("60/minute")
async def getRoleOptions( async def getRoleOptions(
request: Request, request: Request,
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get role options for select dropdowns. Get role options for select dropdowns.
@ -165,7 +165,7 @@ async def getRoleOptions(
async def createRole( async def createRole(
request: Request, request: Request,
role: Role = Body(...), role: Role = Body(...),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Create a new role. Create a new role.
@ -209,7 +209,7 @@ async def createRole(
async def getRole( async def getRole(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get a role by ID. Get a role by ID.
@ -254,7 +254,7 @@ async def updateRole(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
role: Role = Body(...), role: Role = Body(...),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update an existing role. Update an existing role.
@ -301,7 +301,7 @@ async def updateRole(
async def deleteRole( async def deleteRole(
request: Request, request: Request,
roleId: str = Path(..., description="Role ID"), roleId: str = Path(..., description="Role ID"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, str]: ) -> Dict[str, str]:
""" """
Delete a role. Delete a role.
@ -346,7 +346,7 @@ async def listUsersWithRoles(
request: Request, request: Request,
roleLabel: Optional[str] = Query(None, description="Filter by role label"), roleLabel: Optional[str] = Query(None, description="Filter by role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get list of users with their role assignments. Get list of users with their role assignments.
@ -417,7 +417,7 @@ async def listUsersWithRoles(
async def getUserRoles( async def getUserRoles(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Get role assignments for a specific user. Get role assignments for a specific user.
@ -468,7 +468,7 @@ async def updateUserRoles(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
newRoleLabels: List[str] = Body(..., description="List of role labels to assign"), newRoleLabels: List[str] = Body(..., description="List of role labels to assign"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Update role assignments for a specific user. Update role assignments for a specific user.
@ -535,7 +535,7 @@ async def updateUserRoles(
newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId) newRole = UserMandateRole(userMandateId=userMandateId, roleId=roleId)
interface.db.recordCreate(UserMandateRole, newRole.model_dump()) 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) userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
@ -565,7 +565,7 @@ async def addUserRole(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to add"), roleLabel: str = Path(..., description="Role label to add"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Add a role to a user (if not already assigned). Add a role to a user (if not already assigned).
@ -617,7 +617,7 @@ async def addUserRole(
# Add the role # Add the role
newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id)) newRole = UserMandateRole(userMandateId=userMandateId, roleId=str(role.id))
interface.db.recordCreate(UserMandateRole, newRole.model_dump()) 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) userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
@ -647,7 +647,7 @@ async def removeUserRole(
request: Request, request: Request,
userId: str = Path(..., description="User ID"), userId: str = Path(..., description="User ID"),
roleLabel: str = Path(..., description="Role label to remove"), roleLabel: str = Path(..., description="Role label to remove"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Remove a role from a user. Remove a role from a user.
@ -697,7 +697,7 @@ async def removeUserRole(
roleRemoved = True roleRemoved = True
if roleRemoved: 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) userRoleLabels = _getUserRoleLabels(interface, userId)
return { return {
@ -727,7 +727,7 @@ async def getUsersWithRole(
request: Request, request: Request,
roleLabel: str = Path(..., description="Role label"), roleLabel: str = Path(..., description="Role label"),
mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
""" """
Get all users with a specific role. Get all users with a specific role.

View file

@ -55,10 +55,10 @@ class MandateUserInfo(BaseModel):
userId: str userId: str
username: str username: str
email: Optional[str] email: Optional[str]
firstname: Optional[str] fullName: Optional[str]
lastname: Optional[str]
userMandateId: str userMandateId: str
roleIds: List[str] roleIds: List[str]
roleLabels: List[str] # Resolved role labels for display
enabled: bool enabled: bool
# Configure logger # Configure logger
@ -79,7 +79,7 @@ router = APIRouter(
async def get_mandates( async def get_mandates(
request: Request, request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> PaginatedResponse[Mandate]: ) -> PaginatedResponse[Mandate]:
""" """
Get mandates with optional pagination, sorting, and filtering. Get mandates with optional pagination, sorting, and filtering.
@ -143,7 +143,7 @@ async def get_mandates(
async def get_mandate( async def get_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate"), mandateId: str = Path(..., description="ID of the mandate"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Mandate: ) -> Mandate:
""" """
Get a specific mandate by ID. Get a specific mandate by ID.
@ -174,7 +174,7 @@ async def get_mandate(
async def create_mandate( async def create_mandate(
request: Request, request: Request,
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"), mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Mandate: ) -> Mandate:
""" """
Create a new mandate. Create a new mandate.
@ -192,14 +192,16 @@ async def create_mandate(
) )
# Get optional fields with defaults # Get optional fields with defaults
language = mandateData.get('language', 'en') description = mandateData.get('description')
enabled = mandateData.get('enabled', True)
appInterface = interfaceDbAppObjects.getRootInterface() appInterface = interfaceDbAppObjects.getRootInterface()
# Create mandate # Create mandate
newMandate = appInterface.createMandate( newMandate = appInterface.createMandate(
name=name, name=name,
language=language description=description,
enabled=enabled
) )
if not newMandate: if not newMandate:
@ -208,7 +210,7 @@ async def create_mandate(
detail="Failed to 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 return newMandate
except HTTPException: except HTTPException:
@ -226,7 +228,7 @@ async def update_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"), mandateId: str = Path(..., description="ID of the mandate to update"),
mandateData: dict = Body(..., description="Mandate update data"), mandateData: dict = Body(..., description="Mandate update data"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Mandate: ) -> Mandate:
""" """
Update an existing mandate. Update an existing mandate.
@ -254,7 +256,7 @@ async def update_mandate(
detail="Failed to 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 return updatedMandate
except HTTPException: except HTTPException:
@ -271,7 +273,7 @@ async def update_mandate(
async def delete_mandate( async def delete_mandate(
request: Request, request: Request,
mandateId: str = Path(..., description="ID of the mandate to delete"), mandateId: str = Path(..., description="ID of the mandate to delete"),
context: RequestContext = Depends(requireSysAdmin) currentUser: User = Depends(requireSysAdmin)
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
Delete a mandate. Delete a mandate.
@ -304,7 +306,7 @@ async def delete_mandate(
detail=str(e) 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"} return {"message": f"Mandate {mandateId} deleted successfully"}
except HTTPException: except HTTPException:
@ -360,21 +362,30 @@ async def listMandateUsers(
result = [] result = []
for um in userMandates: for um in userMandates:
# Get user info # Get user info
user = rootInterface.getUserById(um.get("userId")) user = rootInterface.getUser(um.get("userId"))
if not user: if not user:
continue continue
# Get roles for this membership # Get roles for this membership
roleIds = rootInterface.getRoleIdsForUserMandate(um.get("id")) 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( result.append(MandateUserInfo(
userId=str(user.id), userId=str(user.id),
username=user.username, username=user.username,
email=user.email, email=user.email,
firstname=user.firstname, fullName=user.fullName,
lastname=user.lastname,
userMandateId=um.get("id"), userMandateId=um.get("id"),
roleIds=roleIds, roleIds=roleIds,
roleLabels=roleLabels,
enabled=um.get("enabled", True) enabled=um.get("enabled", True)
)) ))
@ -434,7 +445,7 @@ async def addUserToMandate(
) )
# 4. Verify target user exists # 4. Verify target user exists
targetUser = rootInterface.getUserById(data.targetUserId) targetUser = rootInterface.getUser(data.targetUserId)
if not targetUser: if not targetUser:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, 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 fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from fastapi import status from fastapi import status
from pydantic import BaseModel
import logging import logging
import json import json
@ -192,12 +193,22 @@ async def get_user(
detail=f"Failed to get user: {str(e)}" 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) @router.post("", response_model=User)
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def create_user( async def create_user(
request: Request, request: Request,
user_data: User = Body(...), userData: CreateUserRequest = Body(...),
password: Optional[str] = Body(None, embed=True),
context: RequestContext = Depends(getRequestContext) context: RequestContext = Depends(getRequestContext)
) -> User: ) -> User:
""" """
@ -206,16 +217,17 @@ async def create_user(
""" """
appInterface = interfaceDbAppObjects.getInterface(context.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 from modules.datamodels.datamodelUam import AuthAuthority
newUser = appInterface.createUser( newUser = appInterface.createUser(
username=user_data.username, username=userData.username,
password=password, password=userData.password,
email=user_data.email, email=userData.email,
fullName=user_data.fullName, fullName=userData.fullName,
language=user_data.language, language=userData.language,
enabled=user_data.enabled, enabled=userData.enabled,
authenticationAuthority=user_data.authenticationAuthority authenticationAuthority=AuthAuthority.LOCAL,
isSysAdmin=userData.isSysAdmin
) )
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role # 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) appInterface = interfaceDbAppObjects.getInterface(context.user)
# Get target user # Get target user
target_user = appInterface.getUserById(userId) target_user = appInterface.getUser(userId)
if not target_user: if not target_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,

View file

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

View file

@ -295,42 +295,6 @@ def _mergeAccessLevel(current: str, new: str) -> str:
return current 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]) @router.post("/", response_model=Dict[str, Any])
@limiter.limit("10/minute") @limiter.limit("10/minute")
async def createFeature( 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 # Helper Functions
# ============================================================================= # =============================================================================

View file

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

View file

@ -24,6 +24,55 @@ from modules.shared.configuration import APP_CONFIG
# Configure logger # Configure logger
logger = logging.getLogger(__name__) 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 # Create router for Local Security endpoints
router = APIRouter( router = APIRouter(
prefix="/api/local", prefix="/api/local",
@ -261,15 +310,11 @@ async def register_user(
# Send registration email with magic link # Send registration email with magic link
try: try:
from modules.services import Services
services = Services(user)
magicLink = f"{baseUrl}/reset?token={token}" magicLink = f"{baseUrl}/reset?token={token}"
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
emailSubject = "PowerOn Registrierung - Passwort setzen" emailSubject = "PowerOn Registrierung - Passwort setzen"
emailBody = f""" emailBody = f"""Hallo {user.fullName or user.username},
Hallo {user.fullName or user.username},
Vielen Dank für Ihre Registrierung bei PowerOn. 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. 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, recipient=user.email,
subject=emailSubject, subject=emailSubject,
message=emailBody, message=emailBody,
@ -529,7 +573,6 @@ async def passwordResetRequest(
user = rootInterface.findUserByUsernameLocalAuth(username) user = rootInterface.findUserByUsernameLocalAuth(username)
if user and user.email: if user and user.email:
from modules.services import Services
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24")) expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try: try:
@ -539,16 +582,12 @@ async def passwordResetRequest(
# Set reset token (clears password) # Set reset token (clears password)
rootInterface.setResetToken(user.id, token, expires) rootInterface.setResetToken(user.id, token, expires)
# Get services for email sending
services = Services(user)
# Generate magic link using provided frontend URL # Generate magic link using provided frontend URL
magicLink = f"{baseUrl}/reset?token={token}" magicLink = f"{baseUrl}/reset?token={token}"
# Send email # Send email using dedicated auth email function
emailSubject = "PowerOn - Passwort zurücksetzen" emailSubject = "PowerOn - Passwort zurücksetzen"
emailBody = f""" emailBody = f"""Hallo {user.fullName or user.username},
Hallo {user.fullName or user.username},
Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert. 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. 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, recipient=user.email,
subject=emailSubject, subject=emailSubject,
message=emailBody, message=emailBody,
userId=str(user.id) userId=str(user.id)
) )
logger.info(f"Password reset email sent to {user.email} for user {user.username}") 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: except Exception as userErr:
logger.error(f"Failed to send reset email for user {username}: {str(userErr)}") logger.error(f"Failed to send reset email for user {username}: {str(userErr)}")
else: else:

View file

@ -120,6 +120,9 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
frontend_readonly = False frontend_readonly = False
frontend_required = field.is_required() frontend_required = field.is_required()
frontend_options = None 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: if field_info:
# Try direct attributes first (though these won't exist for custom kwargs) # 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) frontend_required = json_extra.get("frontend_required", frontend_required)
if frontend_options is None and "frontend_options" in json_extra: if frontend_options is None and "frontend_options" in json_extra:
frontend_options = json_extra.get("frontend_options") 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 # Use frontend type if available, otherwise detect from Python type
if frontend_type: if frontend_type:
@ -215,22 +227,34 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
except Exception: except Exception:
pass 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)
"name": name, if name == "id":
"type": field_type, frontend_visible = False # Never show primary key in forms/tables
"required": frontend_required,
"description": description, attr_def = {
"label": labels.get(name, name), "name": name,
"placeholder": f"Please enter {labels.get(name, name)}", "type": field_type,
"editable": not frontend_readonly, "required": frontend_required,
"visible": True, "description": description,
"order": len(attributes), "label": labels.get(name, name),
"readonly": frontend_readonly, "placeholder": f"Please enter {labels.get(name, name)}",
"options": frontend_options, "editable": not frontend_readonly,
"default": field_default, "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} return {"model": model_label, "attributes": attributes}

View file

@ -96,7 +96,7 @@ _PARTIAL_INDEXES = [
_FOREIGN_KEYS = [ _FOREIGN_KEYS = [
# UserMandate FKs # UserMandate FKs
("UserMandate", "fk_usermandate_mandate", "mandateId", "Mandate", "id"), ("UserMandate", "fk_usermandate_mandate", "mandateId", "Mandate", "id"),
("UserMandate", "fk_usermandate_user", "userId", "User", "id"), ("UserMandate", "fk_usermandate_user", "userId", "UserInDB", "id"),
# FeatureInstance FKs # FeatureInstance FKs
("FeatureInstance", "fk_featureinstance_mandate", "mandateId", "Mandate", "id"), ("FeatureInstance", "fk_featureinstance_mandate", "mandateId", "Mandate", "id"),
@ -107,7 +107,7 @@ _FOREIGN_KEYS = [
# FeatureAccess FKs # FeatureAccess FKs
("FeatureAccess", "fk_featureaccess_instance", "featureInstanceId", "FeatureInstance", "id"), ("FeatureAccess", "fk_featureaccess_instance", "featureInstanceId", "FeatureInstance", "id"),
("FeatureAccess", "fk_featureaccess_user", "userId", "User", "id"), ("FeatureAccess", "fk_featureaccess_user", "userId", "UserInDB", "id"),
# AccessRule FKs # AccessRule FKs
("AccessRule", "fk_accessrule_role", "roleId", "Role", "id"), ("AccessRule", "fk_accessrule_role", "roleId", "Role", "id"),
@ -133,6 +133,9 @@ _IMMUTABLE_TRIGGERS = [
# AccessRule: context, roleId are immutable # AccessRule: context, roleId are immutable
("AccessRule", "tr_accessrule_immutable", ["context", "roleId"]), ("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 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: def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int:
"""Apply foreign key constraints with CASCADE DELETE. Returns count created.""" """Apply foreign key constraints with CASCADE DELETE. Returns count created."""
created = 0 created = 0
@ -351,8 +373,22 @@ def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int:
continue continue
if not _tableExists(cursor, refTable): if not _tableExists(cursor, refTable):
continue continue
# Check if constraint exists
if _constraintExists(cursor, constraintName): if _constraintExists(cursor, constraintName):
continue # 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: try:
cursor.execute(f""" cursor.execute(f"""