saas mandates core done
This commit is contained in:
parent
77e1414744
commit
82c01b5cb0
17 changed files with 438 additions and 255 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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!",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
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:
|
||||
logger.error(f"Failed to send reset email for user {username}: {str(userErr)}")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -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,22 +227,34 @@ def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguag
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
attributes.append(
|
||||
{
|
||||
"name": name,
|
||||
"type": field_type,
|
||||
"required": frontend_required,
|
||||
"description": description,
|
||||
"label": labels.get(name, name),
|
||||
"placeholder": f"Please enter {labels.get(name, name)}",
|
||||
"editable": not frontend_readonly,
|
||||
"visible": True,
|
||||
"order": len(attributes),
|
||||
"readonly": frontend_readonly,
|
||||
"options": frontend_options,
|
||||
"default": field_default,
|
||||
}
|
||||
)
|
||||
# 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,
|
||||
"description": description,
|
||||
"label": labels.get(name, name),
|
||||
"placeholder": f"Please enter {labels.get(name, name)}",
|
||||
"editable": not frontend_readonly,
|
||||
"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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,8 +373,22 @@ def _applyForeignKeys(cursor, tables: Optional[List[str]]) -> int:
|
|||
continue
|
||||
if not _tableExists(cursor, refTable):
|
||||
continue
|
||||
|
||||
# Check if constraint exists
|
||||
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:
|
||||
cursor.execute(f"""
|
||||
|
|
|
|||
Loading…
Reference in a new issue