2170 lines
81 KiB
Python
2170 lines
81 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Centralized bootstrap interface for system initialization.
|
|
Contains all bootstrap logic including mandate, users, and RBAC rules.
|
|
|
|
Multi-Tenant Design:
|
|
- Rollen werden mit Kontext erstellt (mandateId=None für globale Template-Rollen)
|
|
- AccessRules referenzieren roleId (FK), nicht roleLabel
|
|
- Admin-User bekommt isSysAdmin=True UND isPlatformAdmin=True (statt einer Rolle)
|
|
"""
|
|
|
|
import logging
|
|
from typing import Optional, Dict
|
|
from passlib.context import CryptContext
|
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
|
from modules.shared.configuration import APP_CONFIG
|
|
from modules.datamodels.datamodelUam import (
|
|
Mandate,
|
|
UserInDB,
|
|
AuthAuthority,
|
|
)
|
|
from modules.datamodels.datamodelRbac import (
|
|
AccessRule,
|
|
AccessRuleContext,
|
|
Role,
|
|
)
|
|
from modules.datamodels.datamodelUtils import coerce_text_multilingual
|
|
from modules.datamodels.datamodelUam import AccessLevel
|
|
from modules.datamodels.datamodelMembership import (
|
|
UserMandate,
|
|
UserMandateRole,
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Password-Hashing
|
|
pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
|
|
|
|
# Cache für Role-IDs (roleLabel -> roleId)
|
|
_roleIdCache: Dict[str, str] = {}
|
|
|
|
_bootstrapDone: bool = False
|
|
|
|
def initBootstrap(db: DatabaseConnector) -> None:
|
|
"""
|
|
Main bootstrap entry point - initializes all system components.
|
|
Idempotent: runs only once per process regardless of how many callers invoke it.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
global _bootstrapDone
|
|
if _bootstrapDone:
|
|
return
|
|
_bootstrapDone = True
|
|
|
|
logger.info("Starting system bootstrap")
|
|
|
|
# Initialize root mandate
|
|
mandateId = initRootMandate(db)
|
|
|
|
# Migrate existing mandate records: description -> label
|
|
_migrateMandateDescriptionToLabel(db)
|
|
_migrateMandateNameLabelSlugRules(db)
|
|
|
|
# Clean up duplicate roles and fix corrupted templates FIRST
|
|
_deduplicateRoles(db)
|
|
|
|
# Initialize system role TEMPLATES (mandateId=None, isSystemRole=True)
|
|
initRoles(db)
|
|
|
|
# Initialize RBAC rules for template roles
|
|
initRbacRules(db)
|
|
|
|
# Copy system template roles to ALL mandates as mandate-instance roles
|
|
# This also serves as migration for existing mandates that don't have instance roles yet
|
|
_ensureAllMandatesHaveSystemRoles(db)
|
|
|
|
# Migration: eliminate the legacy ``sysadmin`` role in root mandate
|
|
# (replaced by ``User.isPlatformAdmin`` flag — see
|
|
# wiki/c-work/4-done/2026-04-sysadmin-authority-split.md).
|
|
# Idempotent: noop after first successful run.
|
|
if mandateId:
|
|
_migrateAndDropSysAdminRole(db, mandateId)
|
|
|
|
# Ensure UI rules for navigation items (admin/user/viewer roles)
|
|
_ensureUiContextRules(db)
|
|
|
|
# Initialize admin user
|
|
adminUserId = initAdminUser(db, mandateId)
|
|
|
|
# Initialize event user
|
|
eventUserId = initEventUser(db, mandateId)
|
|
|
|
# Assign initial user memberships (via UserMandate + UserMandateRole)
|
|
# Uses mandate-instance roles (not template roles)
|
|
if adminUserId and eventUserId and mandateId:
|
|
assignInitialUserMemberships(db, mandateId, adminUserId, eventUserId)
|
|
|
|
# Apply multi-tenant database optimizations (indexes, triggers, FKs)
|
|
_applyDatabaseOptimizations(db)
|
|
|
|
# Initialize root mandate feature instances
|
|
if mandateId:
|
|
initRootMandateFeatures(db, mandateId)
|
|
|
|
# Remove feature instances for features that no longer exist in the codebase
|
|
_cleanupRemovedFeatureInstances(db)
|
|
|
|
# Initialize billing settings for root mandate
|
|
if mandateId:
|
|
initRootMandateBilling(mandateId)
|
|
|
|
# Initialize subscription for root mandate
|
|
if mandateId:
|
|
_initRootMandateSubscription(mandateId)
|
|
|
|
# Auto-provision Stripe Products/Prices for paid plans (idempotent)
|
|
_bootstrapStripePrices()
|
|
|
|
# Purge soft-deleted mandates past 30-day retention
|
|
try:
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
rootIf = getRootInterface()
|
|
rootIf.purgeExpiredMandates(retentionDays=30)
|
|
except Exception as e:
|
|
logger.warning(f"Mandate retention purge failed: {e}")
|
|
|
|
# Bootstrap system workflow templates for graphical editor
|
|
_bootstrapSystemTemplates(db)
|
|
|
|
# Ensure billing settings and accounts exist for all mandates
|
|
_bootstrapBilling()
|
|
|
|
|
|
def _bootstrapBilling() -> None:
|
|
"""
|
|
Ensure billing settings and accounts exist for all mandates.
|
|
Idempotent: only creates missing settings/accounts.
|
|
"""
|
|
try:
|
|
from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRootInterface
|
|
|
|
billingInterface = getBillingRootInterface()
|
|
|
|
settingsCreated = billingInterface.ensureAllMandateSettingsExist()
|
|
if settingsCreated > 0:
|
|
logger.info(f"Billing bootstrap: Created {settingsCreated} missing mandate billing settings")
|
|
|
|
accountsCreated = billingInterface.ensureAllUserAccountsExist()
|
|
if accountsCreated > 0:
|
|
logger.info(f"Billing bootstrap: Created {accountsCreated} missing user accounts")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Billing bootstrap failed (non-critical): {e}")
|
|
|
|
|
|
def _bootstrapSystemTemplates(db: DatabaseConnector) -> None:
|
|
"""
|
|
Seed platform-wide workflow templates (templateScope='system', mandateId=None).
|
|
Idempotent: skips if templates with the same label already exist.
|
|
"""
|
|
try:
|
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
|
|
import uuid
|
|
|
|
greenfieldDb = DatabaseConnector(
|
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
|
dbDatabase=graphicalEditorDatabase,
|
|
dbUser=APP_CONFIG.get("DB_USER"),
|
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
|
)
|
|
greenfieldDb._ensureTableExists(AutoWorkflow)
|
|
|
|
existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
|
|
"isTemplate": True,
|
|
"templateScope": "system",
|
|
})
|
|
existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])}
|
|
|
|
templates = _buildSystemTemplates()
|
|
created = 0
|
|
for tpl in templates:
|
|
if tpl["label"] in existingLabels:
|
|
continue
|
|
tpl["id"] = str(uuid.uuid4())
|
|
greenfieldDb.recordCreate(AutoWorkflow, tpl)
|
|
created += 1
|
|
|
|
if created:
|
|
logger.info(f"Bootstrapped {created} system workflow template(s)")
|
|
greenfieldDb.close()
|
|
except Exception as e:
|
|
logger.warning(f"System workflow template bootstrap failed: {e}")
|
|
|
|
|
|
def _buildSystemTemplates():
|
|
"""Build the graph definitions for platform system templates."""
|
|
return [
|
|
{
|
|
"label": "Personal Assistant: E-Mail-Antwort-Drafting",
|
|
"mandateId": None,
|
|
"featureInstanceId": None,
|
|
"isTemplate": True,
|
|
"templateScope": "system",
|
|
"sharedReadOnly": True,
|
|
"active": False,
|
|
"graph": {
|
|
"nodes": [
|
|
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}},
|
|
{"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}},
|
|
{
|
|
"id": "n3",
|
|
"type": "flow.loop",
|
|
"x": 550,
|
|
"y": 200,
|
|
"title": "Pro E-Mail",
|
|
"parameters": {
|
|
"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]},
|
|
"level": "auto",
|
|
"concurrency": 1,
|
|
},
|
|
},
|
|
{"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}},
|
|
{"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}},
|
|
{"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}},
|
|
{"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}},
|
|
],
|
|
"connections": [
|
|
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
|
|
],
|
|
},
|
|
"invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}],
|
|
},
|
|
{
|
|
"label": "Treuhand: PDF-Klassifizierung & Trustee-Import",
|
|
"mandateId": None,
|
|
"featureInstanceId": None,
|
|
"isTemplate": True,
|
|
"templateScope": "system",
|
|
"sharedReadOnly": True,
|
|
"active": False,
|
|
"graph": {
|
|
"nodes": [
|
|
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}},
|
|
{"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}},
|
|
{
|
|
"id": "n3",
|
|
"type": "flow.loop",
|
|
"x": 550,
|
|
"y": 200,
|
|
"title": "Pro Dokument",
|
|
"parameters": {
|
|
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
|
|
"level": "auto",
|
|
"concurrency": 1,
|
|
},
|
|
},
|
|
{"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}},
|
|
{"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}},
|
|
{"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}},
|
|
{"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}},
|
|
],
|
|
"connections": [
|
|
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
|
|
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
|
|
],
|
|
},
|
|
"invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}],
|
|
},
|
|
]
|
|
|
|
|
|
def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None:
|
|
"""
|
|
Create feature instances for root mandate.
|
|
Dynamically discovers all feature modules with autoCreateInstance=True.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
mandateId: Root mandate ID
|
|
"""
|
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
|
from modules.system.registry import loadFeatureMainModules
|
|
|
|
logger.info("Initializing root mandate features")
|
|
|
|
# Dynamically discover features with autoCreateInstance=True
|
|
featuresToCreate = []
|
|
mainModules = loadFeatureMainModules()
|
|
|
|
for featureName, module in mainModules.items():
|
|
if hasattr(module, "getFeatureDefinition"):
|
|
try:
|
|
from modules.shared.i18nRegistry import resolveText
|
|
featureDef = module.getFeatureDefinition()
|
|
if featureDef.get("autoCreateInstance", False):
|
|
featureCode = featureDef.get("code", featureName)
|
|
featureLabel = resolveText(featureDef.get("label", featureName))
|
|
featuresToCreate.append({"code": featureCode, "label": featureLabel})
|
|
logger.debug(f"Feature '{featureCode}' marked for auto-creation in root mandate")
|
|
except Exception as e:
|
|
logger.warning(f"Could not read feature definition for '{featureName}': {e}")
|
|
|
|
if not featuresToCreate:
|
|
logger.info("No features marked for auto-creation in root mandate")
|
|
return
|
|
|
|
featureInterface = getFeatureInterface(db)
|
|
|
|
for featureConfig in featuresToCreate:
|
|
featureCode = featureConfig["code"]
|
|
featureLabel = featureConfig["label"]
|
|
|
|
try:
|
|
# Check if instance already exists
|
|
existingInstances = db.getRecordset(
|
|
FeatureInstance,
|
|
recordFilter={
|
|
"mandateId": mandateId,
|
|
"featureCode": featureCode
|
|
}
|
|
)
|
|
|
|
if existingInstances:
|
|
logger.info(f"Feature instance for '{featureCode}' already exists in root mandate")
|
|
continue
|
|
|
|
# Create feature instance with template roles copied
|
|
instance = featureInterface.createFeatureInstance(
|
|
featureCode=featureCode,
|
|
mandateId=mandateId,
|
|
label=featureLabel,
|
|
enabled=True,
|
|
copyTemplateRoles=True
|
|
)
|
|
|
|
if instance:
|
|
instanceId = instance.get("id") if isinstance(instance, dict) else instance.id
|
|
logger.info(f"Created feature instance '{instanceId}' for '{featureCode}' in root mandate")
|
|
else:
|
|
logger.warning(f"Failed to create feature instance for '{featureCode}'")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error creating feature instance for '{featureCode}': {e}")
|
|
|
|
logger.info("Root mandate features initialization completed")
|
|
|
|
|
|
def _cleanupRemovedFeatureInstances(db: DatabaseConnector) -> None:
|
|
"""Remove feature instances whose featureCode no longer exists in the codebase."""
|
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
|
from modules.system.registry import loadFeatureMainModules
|
|
|
|
mainModules = loadFeatureMainModules()
|
|
activeCodes = set()
|
|
for featureName, module in mainModules.items():
|
|
if hasattr(module, "getFeatureDefinition"):
|
|
try:
|
|
featureDef = module.getFeatureDefinition()
|
|
activeCodes.add(featureDef.get("code", featureName))
|
|
except Exception:
|
|
pass
|
|
|
|
allInstances = db.getRecordset(FeatureInstance)
|
|
for inst in allInstances:
|
|
code = inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", None)
|
|
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
|
|
if code and code not in activeCodes:
|
|
try:
|
|
db.recordDelete(FeatureInstance, str(instId))
|
|
logger.info(f"Removed orphaned feature instance '{instId}' (featureCode='{code}')")
|
|
except Exception as e:
|
|
logger.warning(f"Could not remove orphaned feature instance '{instId}': {e}")
|
|
|
|
|
|
def initRootMandate(db: DatabaseConnector) -> Optional[str]:
|
|
"""
|
|
Creates the Root mandate if it doesn't exist.
|
|
Root mandate is identified by name='root' AND isSystem=True.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
|
|
Returns:
|
|
Mandate ID if created or found, None otherwise
|
|
"""
|
|
# Find existing root mandate by name AND isSystem flag
|
|
existingMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
|
if existingMandates:
|
|
mandateId = existingMandates[0].get("id")
|
|
logger.info(f"Root mandate already exists with ID {mandateId}")
|
|
return mandateId
|
|
|
|
# Check for legacy root mandates (name="Root" without isSystem flag) and migrate
|
|
legacyMandates = db.getRecordset(Mandate, recordFilter={"name": "Root"})
|
|
if legacyMandates:
|
|
mandateId = legacyMandates[0].get("id")
|
|
logger.info(f"Migrating legacy Root mandate {mandateId}: setting name='root', isSystem=True")
|
|
db.recordModify(Mandate, mandateId, {"name": "root", "isSystem": True})
|
|
return mandateId
|
|
|
|
logger.info("Creating Root mandate")
|
|
rootMandate = Mandate(name="root", label="Root", isSystem=True, enabled=True)
|
|
createdMandate = db.recordCreate(Mandate, rootMandate)
|
|
mandateId = createdMandate.get("id")
|
|
logger.info(f"Root mandate created with ID {mandateId}")
|
|
return mandateId
|
|
|
|
|
|
def _migrateMandateDescriptionToLabel(db: DatabaseConnector) -> None:
|
|
"""
|
|
Migration: Rename 'description' field to 'label' in all Mandate records.
|
|
Copies existing 'description' values to 'label' and removes the old field.
|
|
Safe to run multiple times (idempotent).
|
|
"""
|
|
allMandates = db.getRecordset(Mandate)
|
|
migratedCount = 0
|
|
for mandateRecord in allMandates:
|
|
mandateId = mandateRecord.get("id")
|
|
hasDescription = "description" in mandateRecord and mandateRecord.get("description") is not None
|
|
hasLabel = "label" in mandateRecord and mandateRecord.get("label") is not None
|
|
|
|
if hasDescription and not hasLabel:
|
|
# Copy description to label
|
|
updateData = {"label": mandateRecord["description"]}
|
|
db.recordModify(Mandate, mandateId, updateData)
|
|
migratedCount += 1
|
|
logger.info(f"Migrated mandate {mandateId}: description -> label")
|
|
|
|
if migratedCount > 0:
|
|
logger.info(f"Migrated {migratedCount} mandate(s) from description to label")
|
|
else:
|
|
logger.debug("No mandate description->label migration needed")
|
|
|
|
|
|
def _migrateMandateNameLabelSlugRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Migration: normalize Mandate.name to the slug rules ([a-z0-9-], length 2..32, single
|
|
hyphen segments) and ensure Mandate.label is non-empty.
|
|
|
|
Rules (see wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md):
|
|
1. If ``label`` is empty/None → set ``label := name`` (or "Mandate" when both empty).
|
|
2. If ``name`` is not a valid slug, or collides with an earlier mandate in stable id
|
|
order, allocate a unique slug from the (now non-empty) ``label`` using
|
|
``slugifyMandateName`` + ``allocateUniqueMandateSlug``.
|
|
|
|
Idempotent: a second run is a no-op because all valid names stay valid and stay unique.
|
|
Each rename and label fill-in is logged for audit.
|
|
"""
|
|
from modules.shared.mandateNameUtils import (
|
|
allocateUniqueMandateSlug,
|
|
isValidMandateName,
|
|
slugifyMandateName,
|
|
)
|
|
|
|
allRows = db.getRecordset(Mandate)
|
|
if not allRows:
|
|
return
|
|
sortedRows = sorted(allRows, key=lambda r: str(r.get("id", "")))
|
|
|
|
used: set[str] = set()
|
|
labelFills = 0
|
|
nameRenames: list[tuple[str, str, str]] = []
|
|
|
|
for rec in sortedRows:
|
|
mid = rec.get("id")
|
|
if not mid:
|
|
continue
|
|
name = (rec.get("name") or "").strip()
|
|
labelRaw = rec.get("label")
|
|
label = (labelRaw or "").strip() if labelRaw is not None else ""
|
|
|
|
if not label:
|
|
label = name if name else "Mandate"
|
|
db.recordModify(Mandate, mid, {"label": label})
|
|
labelFills += 1
|
|
logger.info(f"Mandate {mid}: filled empty label with '{label}'")
|
|
|
|
nameFits = isValidMandateName(name)
|
|
nameCollides = name in used
|
|
if nameFits and not nameCollides:
|
|
used.add(name)
|
|
continue
|
|
|
|
base = slugifyMandateName(label) or "mn"
|
|
newName = allocateUniqueMandateSlug(base, used)
|
|
used.add(newName)
|
|
if newName != name:
|
|
db.recordModify(Mandate, mid, {"name": newName})
|
|
nameRenames.append((str(mid), name, newName))
|
|
logger.info(f"Mandate {mid}: renamed name '{name}' -> '{newName}'")
|
|
|
|
if labelFills or nameRenames:
|
|
logger.info(
|
|
"Mandate name/label slug migration: %d label fill-in(s), %d name rename(s)",
|
|
labelFills, len(nameRenames),
|
|
)
|
|
else:
|
|
logger.debug("No mandate name/label slug migration needed")
|
|
|
|
|
|
def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
|
|
"""
|
|
Creates the Admin user if it doesn't exist.
|
|
Admin user gets BOTH platform flags:
|
|
- isSysAdmin=True (Infrastructure: logs/tokens/DB-health)
|
|
- isPlatformAdmin=True (Cross-Mandate-Governance: user/mandate/RBAC mgmt)
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
mandateId: Root mandate ID (for membership assignment, not on User)
|
|
|
|
Returns:
|
|
User ID if created or found, None otherwise
|
|
"""
|
|
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"})
|
|
if existingUsers:
|
|
userId = existingUsers[0].get("id")
|
|
updates: Dict[str, bool] = {}
|
|
if not existingUsers[0].get("isSysAdmin", False):
|
|
updates["isSysAdmin"] = True
|
|
if not existingUsers[0].get("isPlatformAdmin", False):
|
|
updates["isPlatformAdmin"] = True
|
|
if updates:
|
|
logger.info(f"Updating admin user {userId} platform flags: {updates}")
|
|
db.recordModify(UserInDB, userId, updates)
|
|
|
|
logger.info(f"Admin user already exists with ID {userId}")
|
|
return userId
|
|
|
|
logger.info("Creating Admin user")
|
|
adminUser = UserInDB(
|
|
username="admin",
|
|
email="admin@example.com",
|
|
fullName="Administrator",
|
|
enabled=True,
|
|
language="en",
|
|
isSysAdmin=True,
|
|
isPlatformAdmin=True,
|
|
authenticationAuthority=AuthAuthority.LOCAL,
|
|
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")),
|
|
)
|
|
createdUser = db.recordCreate(UserInDB, adminUser)
|
|
userId = createdUser.get("id")
|
|
logger.info(f"Admin user created with ID {userId}")
|
|
return userId
|
|
|
|
|
|
def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
|
|
"""
|
|
Creates the Event user if it doesn't exist.
|
|
Event user gets isSysAdmin=True for infrastructure-level operations
|
|
(system events, internal callbacks). It does NOT need cross-mandate
|
|
governance, so isPlatformAdmin is left False.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
mandateId: Root mandate ID (for membership assignment, not on User)
|
|
|
|
Returns:
|
|
User ID if created or found, None otherwise
|
|
"""
|
|
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "event"})
|
|
if existingUsers:
|
|
userId = existingUsers[0].get("id")
|
|
# Defensive: revoke any historic platform-admin grant on the event user
|
|
if existingUsers[0].get("isPlatformAdmin", False):
|
|
logger.warning(
|
|
f"Event user {userId} had isPlatformAdmin=True; "
|
|
f"revoking (event user is infrastructure-only)"
|
|
)
|
|
db.recordModify(UserInDB, userId, {"isPlatformAdmin": False})
|
|
logger.info(f"Event user already exists with ID {userId}")
|
|
return userId
|
|
|
|
logger.info("Creating Event user")
|
|
eventUser = UserInDB(
|
|
username="event",
|
|
email="event@example.com",
|
|
fullName="Event",
|
|
enabled=True,
|
|
language="en",
|
|
isSysAdmin=True,
|
|
isPlatformAdmin=False,
|
|
authenticationAuthority=AuthAuthority.LOCAL,
|
|
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")),
|
|
)
|
|
createdUser = db.recordCreate(UserInDB, eventUser)
|
|
userId = createdUser.get("id")
|
|
logger.info(f"Event user created with ID {userId}")
|
|
return userId
|
|
|
|
|
|
def initRoles(db: DatabaseConnector) -> None:
|
|
"""
|
|
Initialize standard roles if they don't exist.
|
|
Roles are created as GLOBAL (mandateId=None) template roles.
|
|
|
|
NOTE: There is no platform-level "sysadmin" role any more — platform
|
|
authority lives on the User record via ``isSysAdmin`` and
|
|
``isPlatformAdmin``. These template roles (admin/user/viewer) are
|
|
purely for mandate/feature-level access control.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
logger.info("Initializing roles")
|
|
global _roleIdCache
|
|
_roleIdCache = {}
|
|
|
|
standardRoles = [
|
|
Role(
|
|
roleLabel="admin",
|
|
description=coerce_text_multilingual("Administrator - Benutzer und Ressourcen im Mandanten verwalten"),
|
|
mandateId=None, # Global template role
|
|
featureInstanceId=None,
|
|
featureCode=None,
|
|
isSystemRole=True
|
|
),
|
|
Role(
|
|
roleLabel="user",
|
|
description=coerce_text_multilingual("Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze"),
|
|
mandateId=None, # Global template role
|
|
featureInstanceId=None,
|
|
featureCode=None,
|
|
isSystemRole=True
|
|
),
|
|
Role(
|
|
roleLabel="viewer",
|
|
description=coerce_text_multilingual("Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze"),
|
|
mandateId=None, # Global template role
|
|
featureInstanceId=None,
|
|
featureCode=None,
|
|
isSystemRole=True
|
|
),
|
|
]
|
|
|
|
# Check specifically for system template roles:
|
|
# mandateId=NULL, isSystemRole=True, featureCode=NULL
|
|
# Feature templates share the same labels but have featureCode set!
|
|
allTemplates = db.getRecordset(
|
|
Role,
|
|
recordFilter={"mandateId": None, "isSystemRole": True}
|
|
)
|
|
# Filter for SYSTEM templates only (featureCode=None), not feature templates
|
|
systemTemplates = [r for r in allTemplates if r.get("featureCode") is None]
|
|
existingTemplateLabels = {role.get("roleLabel"): role.get("id") for role in systemTemplates}
|
|
|
|
for role in standardRoles:
|
|
if role.roleLabel not in existingTemplateLabels:
|
|
try:
|
|
createdRole = db.recordCreate(Role, role)
|
|
_roleIdCache[role.roleLabel] = createdRole.get("id")
|
|
logger.info(f"Created template role: {role.roleLabel} with ID {createdRole.get('id')}")
|
|
except Exception as e:
|
|
logger.warning(f"Error creating role {role.roleLabel}: {e}")
|
|
else:
|
|
_roleIdCache[role.roleLabel] = existingTemplateLabels[role.roleLabel]
|
|
|
|
logger.info("Roles initialization completed")
|
|
|
|
|
|
def _deduplicateRoles(db: DatabaseConnector) -> None:
|
|
"""
|
|
Remove duplicate roles (same roleLabel + mandateId + featureInstanceId).
|
|
Keeps the oldest role (smallest ID) and deletes newer duplicates.
|
|
"""
|
|
allRoles = db.getRecordset(Role)
|
|
|
|
# Group by (roleLabel, mandateId, featureInstanceId, featureCode)
|
|
# featureCode is essential: system template ('admin', None, None, None)
|
|
# must NOT be grouped with feature template ('admin', None, None, '<featureCode>')
|
|
groups: dict = {}
|
|
for role in allRoles:
|
|
key = (role.get("roleLabel"), role.get("mandateId"), role.get("featureInstanceId"), role.get("featureCode"))
|
|
if key not in groups:
|
|
groups[key] = []
|
|
groups[key].append(role)
|
|
|
|
deletedCount = 0
|
|
for key, roles in groups.items():
|
|
if len(roles) > 1:
|
|
# Sort by id to keep the first (oldest), delete the rest
|
|
roles.sort(key=lambda r: r.get("id", ""))
|
|
for duplicate in roles[1:]:
|
|
try:
|
|
db.recordDelete(Role, duplicate.get("id"))
|
|
deletedCount += 1
|
|
logger.info(f"Deleted duplicate role: label='{key[0]}', mandateId={key[1]}, id={duplicate.get('id')}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to delete duplicate role {duplicate.get('id')}: {e}")
|
|
|
|
if deletedCount > 0:
|
|
logger.info(f"Deduplicated roles: removed {deletedCount} duplicates")
|
|
|
|
# Migration: Fix isSystemRole flags
|
|
fixedMandateCount = 0
|
|
fixedTemplateCount = 0
|
|
for role in allRoles:
|
|
# Mandate-level roles should NOT be isSystemRole=True
|
|
if role.get("mandateId") is not None and role.get("isSystemRole") is True:
|
|
try:
|
|
db.recordModify(Role, role.get("id"), {"isSystemRole": False})
|
|
fixedMandateCount += 1
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fix mandate role {role.get('id')}: {e}")
|
|
# Template roles (mandateId=None, no featureCode) MUST be isSystemRole=True
|
|
if role.get("mandateId") is None and role.get("featureCode") is None and role.get("isSystemRole") is not True:
|
|
try:
|
|
db.recordModify(Role, role.get("id"), {"isSystemRole": True})
|
|
fixedTemplateCount += 1
|
|
except Exception as e:
|
|
logger.warning(f"Failed to fix template role {role.get('id')}: {e}")
|
|
if fixedMandateCount > 0:
|
|
logger.info(f"Fixed {fixedMandateCount} mandate-level roles: isSystemRole → False")
|
|
if fixedTemplateCount > 0:
|
|
logger.info(f"Fixed {fixedTemplateCount} template roles: isSystemRole → True")
|
|
|
|
|
|
def _ensureAllMandatesHaveSystemRoles(db: DatabaseConnector) -> None:
|
|
"""
|
|
Ensure all existing mandates have system-instance roles.
|
|
Serves as both initial setup and migration for existing mandates.
|
|
"""
|
|
allMandates = db.getRecordset(Mandate)
|
|
if not allMandates:
|
|
logger.info("No mandates found, skipping system role copy")
|
|
return
|
|
|
|
logger.info(f"Ensuring system roles for {len(allMandates)} mandates...")
|
|
for mandate in allMandates:
|
|
mandateId = mandate.get("id")
|
|
copiedCount = copySystemRolesToMandate(db, mandateId)
|
|
if copiedCount > 0:
|
|
logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}")
|
|
|
|
|
|
def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
|
|
"""
|
|
Copy system template roles (mandateId=None, isSystemRole=True) to a mandate
|
|
as mandate-instance roles. Also copies all AccessRules for each role.
|
|
|
|
This is analogous to how feature template roles are copied to feature instances.
|
|
Each mandate gets its own instances of admin/user/viewer with their AccessRules.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
mandateId: Target mandate ID
|
|
|
|
Returns:
|
|
Number of roles copied
|
|
"""
|
|
import uuid as _uuid
|
|
|
|
# Find system template roles (global: mandateId=NULL, isSystemRole=True)
|
|
templateRoles = db.getRecordset(
|
|
Role,
|
|
recordFilter={"isSystemRole": True, "mandateId": None}
|
|
)
|
|
|
|
if not templateRoles:
|
|
logger.warning(f"No system template roles found (mandateId IS NULL, isSystemRole=True)")
|
|
return 0
|
|
|
|
# Check which mandate-level roles already exist for this mandate
|
|
existingMandateRoles = db.getRecordset(
|
|
Role,
|
|
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
|
|
)
|
|
existingLabels = {r.get("roleLabel") for r in existingMandateRoles}
|
|
logger.info(f"copySystemRolesToMandate: mandate={mandateId}, templates={len(templateRoles)}, existing={len(existingMandateRoles)}, labels={existingLabels}")
|
|
|
|
# Load all AccessRules for template roles
|
|
templateRoleIds = [r.get("id") for r in templateRoles]
|
|
rulesByRoleId = {}
|
|
for roleId in templateRoleIds:
|
|
rules = db.getRecordset(AccessRule, recordFilter={"roleId": roleId})
|
|
rulesByRoleId[roleId] = rules
|
|
|
|
copiedCount = 0
|
|
for templateRole in templateRoles:
|
|
roleLabel = templateRole.get("roleLabel")
|
|
|
|
# Skip if mandate already has this role
|
|
if roleLabel in existingLabels:
|
|
logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping")
|
|
continue
|
|
|
|
newRoleId = str(_uuid.uuid4())
|
|
|
|
# Create mandate-instance role
|
|
newRole = Role(
|
|
id=newRoleId,
|
|
roleLabel=roleLabel,
|
|
description=coerce_text_multilingual(templateRole.get("description", {})),
|
|
mandateId=mandateId,
|
|
featureInstanceId=None,
|
|
featureCode=None,
|
|
isSystemRole=False # Mandate-level role, not a system template
|
|
)
|
|
db.recordCreate(Role, newRole.model_dump())
|
|
|
|
# Copy AccessRules
|
|
templateRules = rulesByRoleId.get(templateRole.get("id"), [])
|
|
for rule in templateRules:
|
|
newRule = AccessRule(
|
|
id=str(_uuid.uuid4()),
|
|
roleId=newRoleId,
|
|
context=rule.get("context"),
|
|
item=rule.get("item"),
|
|
view=rule.get("view", False),
|
|
read=rule.get("read"),
|
|
create=rule.get("create"),
|
|
update=rule.get("update"),
|
|
delete=rule.get("delete")
|
|
)
|
|
db.recordCreate(AccessRule, newRule.model_dump())
|
|
|
|
copiedCount += 1
|
|
logger.info(f"Copied system role '{roleLabel}' to mandate {mandateId} with {len(templateRules)} AccessRules")
|
|
|
|
if copiedCount > 0:
|
|
logger.info(f"Copied {copiedCount} system roles to mandate {mandateId}")
|
|
|
|
return copiedCount
|
|
|
|
|
|
def _migrateAndDropSysAdminRole(db: DatabaseConnector, mandateId: str) -> None:
|
|
"""
|
|
One-shot migration: eliminate the legacy ``sysadmin`` role in the root mandate.
|
|
|
|
Authority semantics moved to two orthogonal flags on User:
|
|
- ``isSysAdmin`` → Infrastructure-Operator (RBAC bypass)
|
|
- ``isPlatformAdmin`` → Cross-Mandate-Governance (no bypass)
|
|
|
|
Migration steps (idempotent):
|
|
1. Find sysadmin role(s) in root mandate. If none exist → done.
|
|
2. For every UserMandateRole row referencing such a role: set
|
|
``user.isPlatformAdmin = True`` (preserves cross-mandate authority).
|
|
3. Delete those UserMandateRole rows.
|
|
4. Delete AccessRules attached to the sysadmin role.
|
|
5. Delete the sysadmin Role record.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
mandateId: Root mandate ID
|
|
"""
|
|
sysadminRoles = db.getRecordset(
|
|
Role,
|
|
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None},
|
|
)
|
|
if not sysadminRoles:
|
|
logger.debug("Sysadmin role migration: no legacy sysadmin role present, nothing to do")
|
|
return
|
|
|
|
sysadminRoleIds = [str(r.get("id")) for r in sysadminRoles if r.get("id")]
|
|
logger.warning(
|
|
f"Sysadmin role migration: found {len(sysadminRoleIds)} legacy sysadmin role(s) "
|
|
f"in root mandate, migrating to isPlatformAdmin flag"
|
|
)
|
|
|
|
# 1) Promote every holder to isPlatformAdmin=True
|
|
promoted = 0
|
|
for sysadminRoleId in sysadminRoleIds:
|
|
umRoleRows = db.getRecordset(
|
|
UserMandateRole, recordFilter={"roleId": sysadminRoleId}
|
|
)
|
|
userMandateIds = [str(r.get("userMandateId")) for r in umRoleRows if r.get("userMandateId")]
|
|
if not userMandateIds:
|
|
continue
|
|
|
|
# Resolve userIds via UserMandate
|
|
userIds = set()
|
|
for umId in userMandateIds:
|
|
ums = db.getRecordset(UserMandate, recordFilter={"id": umId})
|
|
for um in ums:
|
|
uid = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
|
if uid:
|
|
userIds.add(str(uid))
|
|
|
|
for userId in userIds:
|
|
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
|
|
if not users:
|
|
continue
|
|
current = users[0].get("isPlatformAdmin", False)
|
|
if not current:
|
|
db.recordModify(UserInDB, userId, {"isPlatformAdmin": True})
|
|
promoted += 1
|
|
logger.warning(
|
|
f"Sysadmin role migration: granted isPlatformAdmin=True to user {userId}"
|
|
)
|
|
|
|
# 2) Delete UserMandateRole rows
|
|
for umRow in umRoleRows:
|
|
rowId = umRow.get("id") if isinstance(umRow, dict) else getattr(umRow, "id", None)
|
|
if rowId:
|
|
try:
|
|
db.recordDelete(UserMandateRole, str(rowId))
|
|
except Exception as e:
|
|
logger.error(f"Sysadmin role migration: failed to drop UserMandateRole {rowId}: {e}")
|
|
|
|
# 3) Delete AccessRules
|
|
accessRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
|
|
for ar in accessRules:
|
|
arId = ar.get("id") if isinstance(ar, dict) else getattr(ar, "id", None)
|
|
if arId:
|
|
try:
|
|
db.recordDelete(AccessRule, str(arId))
|
|
except Exception as e:
|
|
logger.error(f"Sysadmin role migration: failed to drop AccessRule {arId}: {e}")
|
|
|
|
# 4) Delete the Role
|
|
try:
|
|
db.recordDelete(Role, sysadminRoleId)
|
|
except Exception as e:
|
|
logger.error(f"Sysadmin role migration: failed to drop Role {sysadminRoleId}: {e}")
|
|
|
|
logger.warning(
|
|
f"Sysadmin role migration: completed; promoted {promoted} user(s) to isPlatformAdmin"
|
|
)
|
|
|
|
|
|
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
|
|
"""
|
|
Get role ID by label, using cache or database lookup.
|
|
|
|
Args:
|
|
db: Database connector
|
|
roleLabel: Role label to look up
|
|
|
|
Returns:
|
|
Role ID or None if not found
|
|
"""
|
|
global _roleIdCache
|
|
|
|
if roleLabel in _roleIdCache:
|
|
return _roleIdCache[roleLabel]
|
|
|
|
# Lookup from database
|
|
roles = db.getRecordset(Role, recordFilter={"roleLabel": roleLabel})
|
|
if roles:
|
|
roleId = roles[0].get("id")
|
|
_roleIdCache[roleLabel] = roleId
|
|
return roleId
|
|
|
|
logger.warning(f"Role not found: {roleLabel}")
|
|
return None
|
|
|
|
|
|
def initRbacRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Initialize RBAC rules if they don't exist.
|
|
AccessRules now reference roleId (FK) instead of roleLabel.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
existingRules = db.getRecordset(AccessRule)
|
|
if existingRules:
|
|
logger.info(f"RBAC rules already exist ({len(existingRules)} rules)")
|
|
# Still ensure UI and DATA rules exist (may have been added later)
|
|
_ensureUiContextRules(db)
|
|
_ensureDataContextRules(db)
|
|
return
|
|
|
|
logger.info("Initializing RBAC rules")
|
|
|
|
# Create default role rules
|
|
_createDefaultRoleRules(db)
|
|
|
|
# Create table-specific rules (converted from UAM logic)
|
|
_createTableSpecificRules(db)
|
|
|
|
# Create UI context rules
|
|
_createUiContextRules(db)
|
|
|
|
# Create RESOURCE context rules
|
|
_createResourceContextRules(db)
|
|
|
|
logger.info("RBAC rules initialization completed")
|
|
|
|
|
|
def _createDefaultRoleRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Create default role rules for generic access (item = null).
|
|
Uses roleId instead of roleLabel.
|
|
|
|
NOTE: There is no sysadmin role any more — platform/infra authority is
|
|
governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User
|
|
record. These default rules cover admin/user/viewer template roles.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
defaultRules = []
|
|
|
|
# Admin Role - Group-level access (highest role-based permission)
|
|
adminId = _getRoleId(db, "admin")
|
|
if adminId:
|
|
defaultRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item=None,
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.GROUP,
|
|
update=AccessLevel.GROUP,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# User Role - No access rights (mandate membership marker only)
|
|
# Users get their actual permissions from feature-instance-level roles
|
|
|
|
# Viewer Role - Read-only group access
|
|
viewerId = _getRoleId(db, "viewer")
|
|
if viewerId:
|
|
defaultRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item=None,
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
for rule in defaultRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
|
|
logger.info(f"Created {len(defaultRules)} default role rules")
|
|
|
|
|
|
def _createTableSpecificRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Create table-specific rules converted from UAM logic.
|
|
These rules override generic rules for specific tables.
|
|
Uses roleId instead of roleLabel.
|
|
|
|
NOTE: There is no sysadmin role any more — platform/infra authority is
|
|
governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User
|
|
record. These table-specific rules cover admin/user/viewer template roles.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
tableRules = []
|
|
|
|
# Get role IDs for template roles (platform authority lives on User flags)
|
|
adminId = _getRoleId(db, "admin")
|
|
userId = _getRoleId(db, "user")
|
|
viewerId = _getRoleId(db, "viewer")
|
|
|
|
# ==========================================================================
|
|
# DATA TABLE RULES - Using semantic namespace structure
|
|
# ==========================================================================
|
|
# Namespace structure:
|
|
# - data.uam.* → User Access Management (mandantenübergreifend)
|
|
# - data.chat.* → Chat/AI-Daten (benutzer-eigen, kein Mandantenkontext)
|
|
# - data.files.* → Dateien (benutzer-eigen)
|
|
# - data.feature.* → Mandanten-/Feature-spezifische Daten (dynamisch)
|
|
#
|
|
# GROUP-Berechtigung:
|
|
# - data.uam.*: GROUP filtert nach Mandant (via UserMandate)
|
|
# - data.chat.*, data.files.*: GROUP = MY (benutzer-eigen)
|
|
# ==========================================================================
|
|
|
|
# -------------------------------------------------------------------------
|
|
# UAM Namespace - User Access Management
|
|
# -------------------------------------------------------------------------
|
|
|
|
# Mandate table - Only SysAdmin (flag) can access, not roles
|
|
# Regular roles have no access to Mandate table
|
|
if adminId:
|
|
tableRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.Mandate",
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if userId:
|
|
tableRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.Mandate",
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.Mandate",
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# UserInDB table - Admin can manage users within group scope
|
|
if adminId:
|
|
tableRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.UserInDB",
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.GROUP,
|
|
update=AccessLevel.GROUP,
|
|
delete=AccessLevel.GROUP,
|
|
))
|
|
if userId:
|
|
tableRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.UserInDB",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.UserInDB",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# UserConnection: All users only MY-level CRUD (UAM namespace)
|
|
for roleId in [adminId, userId]:
|
|
if roleId:
|
|
tableRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.UserConnection",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.MY,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.MY,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.UserConnection",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# Invitation: Standard group-level access (UAM namespace)
|
|
if adminId:
|
|
tableRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.Invitation",
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.GROUP,
|
|
update=AccessLevel.GROUP,
|
|
delete=AccessLevel.GROUP,
|
|
))
|
|
if userId:
|
|
tableRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.Invitation",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.MY,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.MY,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.Invitation",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# AuthEvent table - Audit logs (UAM namespace, no delete for audit integrity!)
|
|
if adminId:
|
|
tableRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.AuthEvent",
|
|
view=True,
|
|
read=AccessLevel.ALL,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if userId:
|
|
tableRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.AuthEvent",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.uam.AuthEvent",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Chat Namespace - User-owned, no mandate context
|
|
# -------------------------------------------------------------------------
|
|
|
|
# Prompt: Only MY-level access (user-owned, no mandate context)
|
|
# Each user manages only their own prompts
|
|
for roleId in [adminId, userId]:
|
|
if roleId:
|
|
tableRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.chat.Prompt",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.MY,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.MY,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.chat.Prompt",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# ChatWorkflow: Only MY-level access (user-owned, no mandate context)
|
|
for roleId in [adminId, userId]:
|
|
if roleId:
|
|
tableRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.chat.ChatWorkflow",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.MY,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.MY,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.chat.ChatWorkflow",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Files Namespace - User-owned, no mandate context
|
|
# -------------------------------------------------------------------------
|
|
|
|
# FileItem: Only MY-level access (user-owned)
|
|
for roleId in [adminId, userId]:
|
|
if roleId:
|
|
tableRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.files.FileItem",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.MY,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.MY,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.files.FileItem",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Billing Namespace - Billing accounts and transactions
|
|
# -------------------------------------------------------------------------
|
|
|
|
# BillingAccount: User sees own accounts (MY), Admin sees all in mandate (GROUP)
|
|
# Each user must see all billing accounts assigned to them
|
|
if adminId:
|
|
tableRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingAccount",
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if userId:
|
|
tableRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingAccount",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingAccount",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# BillingTransaction: User sees own transactions (MY), Admin sees all in mandate (GROUP)
|
|
if adminId:
|
|
tableRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingTransaction",
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if userId:
|
|
tableRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingTransaction",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingTransaction",
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# BillingSettings: Only admin can view mandate settings (read-only)
|
|
# SysAdmin (flag) manages settings, roles only read
|
|
if adminId:
|
|
tableRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingSettings",
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if userId:
|
|
tableRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingSettings",
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if viewerId:
|
|
tableRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item="data.billing.BillingSettings",
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# Create all table-specific rules
|
|
for rule in tableRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
|
|
logger.info(f"Created {len(tableRules)} table-specific rules")
|
|
|
|
|
|
def _createUiContextRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Create UI context rules for controlling UI element visibility.
|
|
Uses roleId instead of roleLabel.
|
|
|
|
Creates rules for system pages based on NAVIGATION_SECTIONS.
|
|
Admin pages require admin role, public pages are available to all.
|
|
|
|
NOTE: No sysadmin rules - SysAdmin users bypass RBAC via isSysAdmin flag.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
from modules.system.mainSystem import NAVIGATION_SECTIONS
|
|
|
|
uiRules = []
|
|
adminId = _getRoleId(db, "admin")
|
|
userId = _getRoleId(db, "user")
|
|
viewerId = _getRoleId(db, "viewer")
|
|
|
|
# Create rules based on navigation sections
|
|
for section in NAVIGATION_SECTIONS:
|
|
isAdminSection = section.get("adminOnly", False)
|
|
|
|
for item in section.get("items", []):
|
|
objectKey = item.get("objectKey")
|
|
isPublic = item.get("public", False)
|
|
isAdminOnly = item.get("adminOnly", False) or isAdminSection
|
|
|
|
if isAdminOnly:
|
|
# Admin-only pages: only admin role
|
|
if adminId:
|
|
uiRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.UI,
|
|
item=objectKey,
|
|
view=True,
|
|
read=None, create=None, update=None, delete=None,
|
|
))
|
|
else:
|
|
# Public/normal pages: all roles
|
|
for roleId in [adminId, userId, viewerId]:
|
|
if roleId:
|
|
uiRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.UI,
|
|
item=objectKey,
|
|
view=True,
|
|
read=None, create=None, update=None, delete=None,
|
|
))
|
|
|
|
for rule in uiRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
|
|
logger.info(f"Created {len(uiRules)} UI context rules")
|
|
|
|
|
|
def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Ensure UI context rules exist for all navigation items.
|
|
This is called during bootstrap to add missing UI rules for new navigation items.
|
|
|
|
Creates rules for BOTH template roles AND mandate-instance roles.
|
|
This ensures new navigation items are visible even for mandates created before
|
|
the navigation item was added.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
from modules.system.mainSystem import NAVIGATION_SECTIONS
|
|
|
|
# Template role IDs
|
|
adminId = _getRoleId(db, "admin")
|
|
userId = _getRoleId(db, "user")
|
|
viewerId = _getRoleId(db, "viewer")
|
|
|
|
# Mandate-instance role IDs (same roleLabel, but mandateId set, featureInstanceId=None)
|
|
mandateAdminRoleIds = []
|
|
mandateUserRoleIds = []
|
|
mandateViewerRoleIds = []
|
|
|
|
mandateRoles = db.getRecordset(
|
|
Role,
|
|
recordFilter={"isSystemRole": False, "featureInstanceId": None}
|
|
)
|
|
for role in mandateRoles:
|
|
roleId = role.get("id")
|
|
label = role.get("roleLabel")
|
|
if not roleId or not label or not role.get("mandateId"):
|
|
continue
|
|
if label == "admin":
|
|
mandateAdminRoleIds.append(roleId)
|
|
elif label == "user":
|
|
mandateUserRoleIds.append(roleId)
|
|
elif label == "viewer":
|
|
mandateViewerRoleIds.append(roleId)
|
|
|
|
# All role IDs per level (template + mandate-instance).
|
|
# Admin-only navigation items are governed by these admin roles plus the
|
|
# ``isPlatformAdmin`` flag (checked in routes via requirePlatformAdmin),
|
|
# NOT by a dedicated platform-level role.
|
|
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds
|
|
allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds
|
|
allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds
|
|
|
|
# Get existing UI rules
|
|
existingUiRules = db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={"context": AccessRuleContext.UI.value}
|
|
)
|
|
|
|
# Build set of existing (roleId, item) combinations
|
|
existingCombinations = set()
|
|
for rule in existingUiRules:
|
|
roleId = rule.get("roleId")
|
|
item = rule.get("item")
|
|
if roleId and item:
|
|
existingCombinations.add((roleId, item))
|
|
|
|
# Check each navigation item and add missing rules (including subgroup items)
|
|
missingRules = []
|
|
for section in NAVIGATION_SECTIONS:
|
|
isAdminSection = section.get("adminOnly", False)
|
|
|
|
allItems = list(section.get("items", []))
|
|
for subgroup in section.get("subgroups", []):
|
|
allItems.extend(subgroup.get("items", []))
|
|
|
|
for item in allItems:
|
|
objectKey = item.get("objectKey")
|
|
if not objectKey:
|
|
continue
|
|
|
|
isAdminOnly = item.get("adminOnly", False) or isAdminSection
|
|
|
|
if isAdminOnly:
|
|
# Admin-only: all admin roles (template + mandate-instance)
|
|
for roleId in allAdminRoleIds:
|
|
if (roleId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.UI,
|
|
item=objectKey,
|
|
view=True,
|
|
read=None, create=None, update=None, delete=None,
|
|
))
|
|
else:
|
|
# Public/normal: all roles (template + mandate-instance)
|
|
for roleId in allAdminRoleIds + allUserRoleIds + allViewerRoleIds:
|
|
if (roleId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.UI,
|
|
item=objectKey,
|
|
view=True,
|
|
read=None, create=None, update=None, delete=None,
|
|
))
|
|
|
|
# Create missing rules
|
|
if missingRules:
|
|
for rule in missingRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
logger.info(f"Created {len(missingRules)} missing UI context rules (incl. mandate-instance roles)")
|
|
# All UI context rules already exist (nothing to create)
|
|
|
|
|
|
def _ensureDataContextRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Ensure DATA context rules exist for key tables like ChatWorkflow and AutomationDefinition.
|
|
This is called during bootstrap to add missing DATA rules for new tables.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
adminId = _getRoleId(db, "admin")
|
|
userId = _getRoleId(db, "user")
|
|
viewerId = _getRoleId(db, "viewer")
|
|
|
|
# Get existing DATA rules
|
|
existingDataRules = db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={"context": AccessRuleContext.DATA.value}
|
|
)
|
|
|
|
# Build set of existing (roleId, item) combinations
|
|
existingCombinations = set()
|
|
for rule in existingDataRules:
|
|
roleId = rule.get("roleId")
|
|
item = rule.get("item")
|
|
if roleId and item:
|
|
existingCombinations.add((roleId, item))
|
|
|
|
# Define tables that need rules (user-owned, no mandate context)
|
|
# Users can only manage their own records (MY-level access)
|
|
tablesNeedingMyRules = [
|
|
"data.chat.ChatWorkflow",
|
|
]
|
|
|
|
# Billing tables: read-only for all roles, scoped by role level
|
|
# Users see their own accounts/transactions (MY), Admins see mandate-wide (GROUP)
|
|
billingReadOnlyTables = [
|
|
"data.billing.BillingAccount",
|
|
"data.billing.BillingTransaction",
|
|
]
|
|
|
|
missingRules = []
|
|
|
|
# MY-level rules for user-owned tables
|
|
for objectKey in tablesNeedingMyRules:
|
|
# Admin: MY-level access (user-owned, no mandate context)
|
|
if adminId and (adminId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item=objectKey,
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.MY,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.MY,
|
|
))
|
|
|
|
# User: MY-level access (user-owned, no mandate context)
|
|
if userId and (userId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item=objectKey,
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.MY,
|
|
update=AccessLevel.MY,
|
|
delete=AccessLevel.MY,
|
|
))
|
|
|
|
# Viewer: MY read-only (user-owned, no mandate context)
|
|
if viewerId and (viewerId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item=objectKey,
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# Billing read-only rules: Admin=GROUP, User/Viewer=MY (own accounts/transactions)
|
|
for objectKey in billingReadOnlyTables:
|
|
# Admin: GROUP-level read (sees all accounts in their mandates)
|
|
if adminId and (adminId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item=objectKey,
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# User: MY-level read (sees only own billing accounts/transactions)
|
|
if userId and (userId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item=objectKey,
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# Viewer: MY-level read-only (sees only own billing accounts/transactions)
|
|
if viewerId and (viewerId, objectKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item=objectKey,
|
|
view=True,
|
|
read=AccessLevel.MY,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# BillingSettings: Admin can view (GROUP), User/Viewer have no access
|
|
billingSettingsKey = "data.billing.BillingSettings"
|
|
if adminId and (adminId, billingSettingsKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.DATA,
|
|
item=billingSettingsKey,
|
|
view=True,
|
|
read=AccessLevel.GROUP,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if userId and (userId, billingSettingsKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.DATA,
|
|
item=billingSettingsKey,
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
if viewerId and (viewerId, billingSettingsKey) not in existingCombinations:
|
|
missingRules.append(AccessRule(
|
|
roleId=viewerId,
|
|
context=AccessRuleContext.DATA,
|
|
item=billingSettingsKey,
|
|
view=False,
|
|
read=AccessLevel.NONE,
|
|
create=AccessLevel.NONE,
|
|
update=AccessLevel.NONE,
|
|
delete=AccessLevel.NONE,
|
|
))
|
|
|
|
# Create missing rules
|
|
if missingRules:
|
|
for rule in missingRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
logger.info(f"Created {len(missingRules)} missing DATA context rules")
|
|
# All DATA context rules already exist (nothing to create)
|
|
|
|
|
|
|
|
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 = []
|
|
|
|
# Admin and User get default resource access; Viewer gets NO resource access
|
|
for roleLabel in ["admin", "user"]:
|
|
roleId = _getRoleId(db, roleLabel)
|
|
if roleId:
|
|
resourceRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.RESOURCE,
|
|
item=None,
|
|
view=True,
|
|
read=None,
|
|
create=None,
|
|
update=None,
|
|
delete=None,
|
|
))
|
|
|
|
# Viewer: no default RESOURCE access (viewer cannot use system resources)
|
|
|
|
for rule in resourceRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
|
|
logger.info(f"Created {len(resourceRules)} RESOURCE context rules")
|
|
|
|
# Create AICore provider RBAC rules
|
|
_createAicoreProviderRules(db)
|
|
|
|
# Create Store resource RBAC rules
|
|
_createStoreResourceRules(db)
|
|
|
|
|
|
def _createAicoreProviderRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Create RBAC rules for AICore providers (resource.aicore.{provider}).
|
|
|
|
Provider access per role:
|
|
- admin: all providers allowed
|
|
- user: all providers allowed
|
|
- viewer: NO provider access (viewer has no RESOURCE permissions)
|
|
|
|
NOTE: Provider list is dynamically discovered from AICore model registry.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
try:
|
|
from modules.aicore.aicoreModelRegistry import modelRegistry
|
|
|
|
# Discover available connectors dynamically
|
|
connectors = modelRegistry.discoverConnectors()
|
|
providers = [c.getConnectorType() for c in connectors]
|
|
|
|
if not providers:
|
|
logger.warning("No AICore providers discovered, skipping provider RBAC rules")
|
|
return
|
|
|
|
logger.info(f"Creating RBAC rules for AICore providers: {providers}")
|
|
|
|
providerRules = []
|
|
|
|
# Admin: access to ALL providers
|
|
adminId = _getRoleId(db, "admin")
|
|
if adminId:
|
|
for provider in providers:
|
|
resourceKey = f"resource.aicore.{provider}"
|
|
existingRules = db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={
|
|
"roleId": adminId,
|
|
"context": AccessRuleContext.RESOURCE.value,
|
|
"item": resourceKey
|
|
}
|
|
)
|
|
if not existingRules:
|
|
providerRules.append(AccessRule(
|
|
roleId=adminId,
|
|
context=AccessRuleContext.RESOURCE,
|
|
item=resourceKey,
|
|
view=True,
|
|
read=None, create=None, update=None, delete=None,
|
|
))
|
|
|
|
# User: access to all providers (same provider keys as admin)
|
|
userId = _getRoleId(db, "user")
|
|
if userId:
|
|
for provider in providers:
|
|
resourceKey = f"resource.aicore.{provider}"
|
|
existingRules = db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={
|
|
"roleId": userId,
|
|
"context": AccessRuleContext.RESOURCE.value,
|
|
"item": resourceKey
|
|
}
|
|
)
|
|
if not existingRules:
|
|
providerRules.append(AccessRule(
|
|
roleId=userId,
|
|
context=AccessRuleContext.RESOURCE,
|
|
item=resourceKey,
|
|
view=True,
|
|
read=None, create=None, update=None, delete=None,
|
|
))
|
|
|
|
# Viewer: NO provider access (viewer has no RESOURCE permissions at all)
|
|
|
|
for rule in providerRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
|
|
if providerRules:
|
|
logger.info(f"Created {len(providerRules)} AICore provider RBAC rules")
|
|
else:
|
|
logger.debug("All AICore provider RBAC rules already exist")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to create AICore provider RBAC rules: {e}")
|
|
|
|
|
|
def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
|
"""
|
|
Create RBAC rules for Store feature activation resources.
|
|
|
|
Store resources control which roles can activate features via the Store.
|
|
- admin/user: view=True (can see and activate store features)
|
|
- viewer: no store access
|
|
- isSysAdmin flag bypasses RBAC (rbac.py:getUserPermissions)
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
storeResources = [
|
|
"resource.store.teamsbot",
|
|
"resource.store.workspace",
|
|
"resource.store.commcoach",
|
|
"resource.store.trustee",
|
|
"resource.store.graphicalEditor",
|
|
]
|
|
|
|
storeRules = []
|
|
|
|
for roleLabel in ["admin", "user"]:
|
|
roleId = _getRoleId(db, roleLabel)
|
|
if not roleId:
|
|
continue
|
|
for resourceKey in storeResources:
|
|
existingRules = db.getRecordset(
|
|
AccessRule,
|
|
recordFilter={
|
|
"roleId": roleId,
|
|
"context": AccessRuleContext.RESOURCE.value,
|
|
"item": resourceKey
|
|
}
|
|
)
|
|
if not existingRules:
|
|
storeRules.append(AccessRule(
|
|
roleId=roleId,
|
|
context=AccessRuleContext.RESOURCE,
|
|
item=resourceKey,
|
|
view=True,
|
|
read=None, create=None, update=None, delete=None,
|
|
))
|
|
|
|
for rule in storeRules:
|
|
db.recordCreate(AccessRule, rule)
|
|
|
|
if storeRules:
|
|
logger.info(f"Created {len(storeRules)} Store resource RBAC rules")
|
|
|
|
|
|
def initRootMandateBilling(mandateId: str) -> None:
|
|
"""
|
|
Initialize billing settings for root mandate (PREPAY_MANDATE).
|
|
Creates mandate pool account and user audit accounts.
|
|
"""
|
|
try:
|
|
from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRootInterface
|
|
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
|
|
from modules.datamodels.datamodelBilling import BillingSettings
|
|
|
|
billingInterface = getBillingRootInterface()
|
|
appInterface = getAppRootInterface()
|
|
|
|
existingSettings = billingInterface.getSettings(mandateId)
|
|
if existingSettings:
|
|
logger.info("Billing settings for root mandate already exist")
|
|
else:
|
|
settings = BillingSettings(
|
|
mandateId=mandateId,
|
|
warningThresholdPercent=10.0,
|
|
notifyOnWarning=True
|
|
)
|
|
billingInterface.createSettings(settings)
|
|
logger.info("Created billing settings for root mandate: PREPAY_MANDATE")
|
|
existingSettings = billingInterface.getSettings(mandateId)
|
|
|
|
if existingSettings:
|
|
billingInterface.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
|
|
userMandates = appInterface.getUserMandatesByMandate(mandateId)
|
|
accountsCreated = 0
|
|
for um in userMandates:
|
|
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
|
if userId:
|
|
existingAccount = billingInterface.getUserAccount(mandateId, userId)
|
|
if not existingAccount:
|
|
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
|
|
accountsCreated += 1
|
|
if accountsCreated > 0:
|
|
logger.info(f"Created {accountsCreated} billing audit accounts for root mandate users")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}")
|
|
|
|
|
|
def _initRootMandateSubscription(mandateId: str) -> None:
|
|
"""
|
|
Ensure the root mandate has an active ROOT subscription.
|
|
Called during bootstrap after billing init.
|
|
"""
|
|
try:
|
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
|
|
from modules.datamodels.datamodelSubscription import (
|
|
MandateSubscription,
|
|
SubscriptionStatusEnum,
|
|
)
|
|
|
|
subInterface = getSubRootInterface()
|
|
existing = subInterface.getOperativeForMandate(mandateId)
|
|
if existing:
|
|
logger.info("Root mandate subscription already exists")
|
|
return
|
|
|
|
sub = MandateSubscription(
|
|
mandateId=mandateId,
|
|
planKey="ROOT",
|
|
status=SubscriptionStatusEnum.ACTIVE,
|
|
recurring=False,
|
|
)
|
|
subInterface.createSubscription(sub)
|
|
logger.info("Created ROOT subscription for root mandate")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Failed to initialize root mandate subscription (non-critical): {e}")
|
|
|
|
|
|
def _bootstrapStripePrices() -> None:
|
|
"""Auto-create Stripe Products and Prices for all paid plans.
|
|
Idempotent — safe on every startup. IDs are persisted in the StripePlanPrice table."""
|
|
try:
|
|
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices
|
|
bootstrapStripePrices()
|
|
except Exception as e:
|
|
logger.error(f"Stripe price bootstrap failed (subscriptions will not work for paid plans): {e}")
|
|
|
|
|
|
def assignInitialUserMemberships(
|
|
db: DatabaseConnector,
|
|
mandateId: str,
|
|
adminUserId: str,
|
|
eventUserId: str
|
|
) -> None:
|
|
"""
|
|
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
|
|
This is the NEW multi-tenant way of assigning roles.
|
|
|
|
Initial users get the "admin" role in the root mandate. Platform-level
|
|
authority (cross-mandate governance + infrastructure ops) is conveyed via
|
|
the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User record itself
|
|
(see ``initAdminUser`` / ``initEventUser``).
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
mandateId: Root mandate ID
|
|
adminUserId: Admin user ID
|
|
eventUserId: Event user ID
|
|
"""
|
|
# Find the highest-privilege mandate-level role (prefer "admin", fallback to first available)
|
|
mandateRoles = db.getRecordset(
|
|
Role,
|
|
recordFilter={"mandateId": mandateId, "featureInstanceId": None}
|
|
)
|
|
# Prefer "admin" role, fall back to first available mandate role
|
|
adminRole = next((r for r in mandateRoles if r.get("roleLabel") == "admin"), None)
|
|
adminRoleId = adminRole.get("id") if adminRole else (mandateRoles[0].get("id") if mandateRoles else None)
|
|
if not adminRoleId:
|
|
logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment")
|
|
return
|
|
|
|
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
|
|
# Check if UserMandate already exists
|
|
existingMemberships = db.getRecordset(
|
|
UserMandate,
|
|
recordFilter={"userId": userId, "mandateId": mandateId}
|
|
)
|
|
|
|
if existingMemberships:
|
|
userMandateId = existingMemberships[0].get("id")
|
|
else:
|
|
# Create UserMandate
|
|
userMandate = UserMandate(
|
|
userId=userId,
|
|
mandateId=mandateId,
|
|
enabled=True
|
|
)
|
|
createdMembership = db.recordCreate(UserMandate, userMandate)
|
|
userMandateId = createdMembership.get("id")
|
|
logger.info(f"Created UserMandate for {userName} user with ID {userMandateId}")
|
|
|
|
# Check if UserMandateRole already exists for admin role
|
|
existingRoles = db.getRecordset(
|
|
UserMandateRole,
|
|
recordFilter={"userMandateId": userMandateId, "roleId": adminRoleId}
|
|
)
|
|
|
|
if not existingRoles:
|
|
# Create UserMandateRole with "admin" role
|
|
userMandateRole = UserMandateRole(
|
|
userMandateId=userMandateId,
|
|
roleId=adminRoleId
|
|
)
|
|
db.recordCreate(UserMandateRole, userMandateRole)
|
|
logger.info(f"Assigned admin role to {userName} user in mandate")
|
|
|
|
|
|
def _getPasswordHash(password: Optional[str]) -> Optional[str]:
|
|
"""
|
|
Hash a password using Argon2.
|
|
|
|
Args:
|
|
password: Plain text password
|
|
|
|
Returns:
|
|
Hashed password or None if password is None
|
|
"""
|
|
if password is None:
|
|
return None
|
|
return pwdContext.hash(password)
|
|
|
|
|
|
def _applyDatabaseOptimizations(db: DatabaseConnector) -> None:
|
|
"""
|
|
Apply multi-tenant database optimizations after bootstrap.
|
|
|
|
Creates indexes, immutable triggers, and foreign key constraints
|
|
for the multi-tenant junction tables. All operations are idempotent.
|
|
|
|
Args:
|
|
db: Database connector instance
|
|
"""
|
|
try:
|
|
from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations
|
|
|
|
result = applyMultiTenantOptimizations(db)
|
|
|
|
if result.get("errors"):
|
|
for error in result["errors"]:
|
|
logger.warning(f"DB optimization error: {error}")
|
|
else:
|
|
totalCreated = (
|
|
result.get("indexesCreated", 0) +
|
|
result.get("triggersCreated", 0) +
|
|
result.get("foreignKeysCreated", 0)
|
|
)
|
|
if totalCreated > 0:
|
|
logger.info(
|
|
f"Applied DB optimizations: {result['indexesCreated']} indexes, "
|
|
f"{result['triggersCreated']} triggers, "
|
|
f"{result['foreignKeysCreated']} foreign keys"
|
|
)
|
|
# If nothing created, optimizations were already applied (idempotent)
|
|
|
|
except ImportError as e:
|
|
logger.warning(f"DB optimizations module not available: {e}")
|
|
except Exception as e:
|
|
# Don't fail bootstrap if optimizations fail
|
|
logger.warning(f"Failed to apply DB optimizations (non-critical): {e}")
|