import logging
from typing import Any
from assets_guardian.core.domain.models.access import Access
from assets_guardian.core.domain.models.asset import Asset
from assets_guardian.core.domain.models.identity import Identity
from assets_guardian.core.domain.ports.collector import Collector
from assets_guardian.core.domain.registry.collector_registry import CollectorRegistry
from assets_guardian.plugins.dolibarr.constants import (
CRITICAL_MODULES,
DEFAULT_INSTANCE_ID,
MODULE_LABELS,
SOURCE_NAME,
)
from assets_guardian.plugins.dolibarr.mapper import DolibarrMapper
from assets_guardian.plugins.dolibarr.repository import DolibarrRepository
logger = logging.getLogger(__name__)
[docs]
@CollectorRegistry.register(SOURCE_NAME)
class DolibarrCollector(Collector):
"""Dolibarr Collector.
ARCH-LIMIT: The Collector base class assumes that collect_identities(),
collect_assets() and collect_accesses() are independent. For Dolibarr,
accesses (groups and permissions) require N+1 calls per user.
Therefore, we completely override these three methods and share an assets cache
to avoid redundant calls.
"""
def __init__(self, client: Any, instance_config: dict[str, Any]) -> None:
self.__client = client
self.__config = instance_config
base_url = instance_config.get("url")
self.__mapper = DolibarrMapper(instance_id=self.instance_id, base_url=base_url)
self.__repository = DolibarrRepository(client)
self._assets_cache: dict[str, Asset] | None = None
self._raw_groups: list[dict[str, Any]] = []
self._group_accesses_cache: list[Access] | None = None
@property
def source_name(self) -> str:
return SOURCE_NAME
@property
def instance_id(self) -> str:
return str(self.__config.get("instance_id", DEFAULT_INSTANCE_ID))
[docs]
def collect_identities(self) -> list[Identity]:
"""Collects all Dolibarr users with their groups and critical permissions.
Each user requires two additional calls:
- /users/{id}?includepermissions=1 โ module-level permissions
- /users/{id}/groups โ group memberships
ARCH-LIMIT: This N+1 pattern is not supported by the base class
nor by the IRepository interface, which assumes a flat list of accesses.
Returns:
list[Identity]: List of collected identities with their accesses.
"""
raw_users = self.__repository.get_raw_users()
identities = []
for raw_user in raw_users:
user_id = raw_user.get("id")
if not user_id:
continue
detailed_user = self.__repository.get_raw_user_details(user_id)
full_user = {**raw_user, **detailed_user}
accesses = self.__build_accesses_for_user(user_id, full_user)
identity = self.__mapper.to_identity(full_user, accesses=accesses)
identities.append(identity)
logger.info("Collected %d Dolibarr identities.", len(identities))
return identities
[docs]
def collect_assets(self) -> list[Asset]:
"""Collects all Dolibarr groups and critical modules as assets.
Returns:
list[Asset]: List of collected assets (groups and virtual modules).
"""
raw_groups = self.__repository.get_raw_groups()
self._raw_groups = raw_groups
assets = [self.__mapper.to_asset(g) for g in raw_groups]
# Add virtual assets for critical modules
for module in CRITICAL_MODULES:
label = MODULE_LABELS.get(module, module.capitalize())
assets.append(
Asset(
source=self.source_name,
external_id=f"module_{module}",
asset_type="module",
name=f"Module {label}",
description=f"Critical Dolibarr module: {label}",
state="active",
)
)
self._assets_cache = {str(a.external_id): a for a in assets}
logger.info("Collected %d Dolibarr assets (groups and modules).", len(assets))
return assets
[docs]
def collect_accesses(self) -> list[Access]:
"""Returns all accesses extracted from identities and groups.
Accesses include:
- Group memberships (access_type="group")
- Direct/effective user permissions (access_type="module_permission")
- Permissions granted by groups (access_type="group_permission")
Returns:
list[Access]: Consolidated list of all Dolibarr accesses.
"""
identities = self.collect_identities()
all_accesses: list[Access] = []
for identity in identities:
if identity.access:
all_accesses.extend(identity.access)
# Extract access statistics
num_groups = sum(1 for a in all_accesses if a.access_type == "group")
user_level_perms = sum(1 for a in all_accesses if a.access_type == "module_permission")
logger.info(
"Dolibarr: %d group accesses, %d direct/effective user permissions.",
num_groups,
user_level_perms,
)
group_perms = self.__collect_group_permissions(identities)
all_accesses.extend(group_perms)
logger.info("Extracted %d Dolibarr accesses in total.", len(all_accesses))
return all_accesses
# --- Internal methods ---
def __find_asset(self, group_id: Any) -> Asset | None:
"""Finds an asset in the cache by its external identifier.
Args:
group_id: External identifier of the asset to search.
Returns:
Asset | None: The matching asset, or None if not found.
"""
if group_id is None:
return None
if self._assets_cache is None:
self.collect_assets()
return self._assets_cache.get(str(group_id)) if self._assets_cache else None
def __build_accesses_for_user(self, user_id: Any, full_user: dict[str, Any]) -> list[Access]:
"""Builds the access list for a user (groups + critical permissions).
Args:
user_id: User's Dolibarr identifier.
full_user: Full user data including permissions.
Returns:
list[Access]: List of user's accesses.
"""
accesses: list[Access] = []
# Group memberships
raw_groups = self.__repository.get_raw_user_groups(user_id)
for raw_group in raw_groups:
group_id = raw_group.get("id")
asset = self.__find_asset(group_id)
accesses.append(self.__mapper.to_access(raw_group, asset=asset, user_info=full_user))
# Permissions on critical modules (stored in user details)
permissions: dict[str, Any] = full_user.get("rights") or full_user.get("permissions") or {}
accesses.extend(self.__extract_critical_module_accesses(permissions, full_user))
return accesses
def __extract_critical_module_accesses(
self, permissions: dict[str, Any], user_info: dict[str, Any]
) -> list[Access]:
"""Extracts rights on critical modules as Access objects.
Only modules declared in CRITICAL_MODULES are transformed.
Nested permissions (e.g. user.user.lire) are flattened at the first level.
Each permission is attached to its corresponding virtual module Asset.
Args:
permissions: Dictionary of user permissions, indexed by module.
user_info: Full user data.
Returns:
list[Access]: Access objects of type ``module_permission`` for active rights.
"""
accesses: list[Access] = []
for module, module_perms in permissions.items():
if module not in CRITICAL_MODULES or not isinstance(module_perms, dict):
continue
# Retrieve virtual asset for this module
asset = self.__find_asset(f"module_{module}")
for perm_key, perm_value in module_perms.items():
if isinstance(perm_value, dict):
# Nested permissions (e.g. user.user.lire, user.self.creer)
for sub_key, sub_value in perm_value.items():
if str(sub_value) == "1":
accesses.append(
self.__mapper.to_module_permission_access(
module=f"{module}.{perm_key}",
permission_key=sub_key,
user_info=user_info,
asset=asset,
)
)
elif str(perm_value) == "1":
accesses.append(
self.__mapper.to_module_permission_access(
module=module,
permission_key=perm_key,
user_info=user_info,
asset=asset,
)
)
return accesses
def __get_user_permissions(self, identities: list[Identity]) -> dict[str, set[tuple[str, str]]]:
"""Maps each user to the set of their module_permission accesses."""
user_perms = {}
for identity in identities:
uid = str(identity.external_id)
perms = set()
for a in identity.access or []:
if a.access_type == "module_permission" and a.metadata:
raw_mod = a.metadata.get("raw_module")
raw_pk = a.metadata.get("raw_permission_key")
if raw_mod and raw_pk:
perms.add((raw_mod, raw_pk))
user_perms[uid] = perms
return user_perms
def __get_group_members(self, identities: list[Identity]) -> dict[str, list[str]]:
"""Maps each group to the list of its active members."""
group_members: dict[str, list[str]] = {}
for identity in identities:
uid = str(identity.external_id)
for a in identity.access or []:
if a.access_type == "group":
meta = a.metadata or {}
gid = str(a.asset.external_id) if a.asset else str(meta.get("group_id", ""))
if gid:
group_members.setdefault(gid, []).append(uid)
return group_members
def __collect_group_permissions(self, identities: list[Identity]) -> list[Access]:
"""Collects permissions on modules granted by each Dolibarr group.
Reconstructs group permissions by calculating the intersection of the
effective rights of all their active members.
"""
if self._group_accesses_cache is not None:
return self._group_accesses_cache
if self._assets_cache is None:
self.collect_assets()
user_perms = self.__get_user_permissions(identities)
group_members = self.__get_group_members(identities)
raw_groups_by_id = {str(g.get("id", "")): g for g in self._raw_groups}
group_accesses: list[Access] = []
for asset in (self._assets_cache or {}).values():
if asset.asset_type != "group":
continue
group_id = str(asset.external_id)
members = group_members.get(group_id)
if not members:
logger.info("Group %s ('%s') has no active members.", group_id, asset.name)
continue
# Intersection of permissions of all active group members
common_perms = set.intersection(*(user_perms.get(uid, set()) for uid in members))
raw_group = raw_groups_by_id.get(group_id, {})
for raw_mod, raw_pk in common_perms:
group_accesses.append(
self.__mapper.to_group_permission_access(
group_info=raw_group,
module=raw_mod,
permission_key=raw_pk,
asset=asset,
)
)
self._group_accesses_cache = group_accesses
logger.info("Collected %d Dolibarr group permissions.", len(group_accesses))
return group_accesses