π§© Plugin Development Guideο
β οΈ Warning: This documentation is a work in progress. Some sections may be incomplete, inaccurate, or subject to change.
This guide explains how to develop a plugin for Assets Guardian. Each plugin integrates an external Identity and Access Management (IAM) source (GitLab, Microsoft 365, Dolibarr, etc.) into the framework without modifying the core codebase.
Architecture Overviewο
Assets Guardian uses a registry-based plugin architecture. Plugins are discovered dynamically at runtime based on the application configuration. The framework scans the plugins/ directory, imports configured plugin modules, and collects components registered via decorators.
graph LR
subgraph "Registration Flow (Dynamic Discovery)"
Config["config.yml"] -->|1. Declares active sources| Discovery["Discovery Engine"]
Discovery -->|2. Imports modules| Plugin["Plugin Package"]
Plugin -->|3. Registers decorators| Registries["Registries"]
end
graph LR
subgraph "Ingestion Pipeline (Orchestration & Data Flow)"
Engine["Core Engine"] -->|1. Invokes collection| Collector["Collector"]
Collector -->|2. Calls| Repo["Repository"]
Repo -->|3. Queries| API[("External API / DB")]
Collector <-->|4. Normalizes raw data| Mapper["Mapper"]
Collector -.->|5. Returns normalized entities| Engine
end
Key Design Principlesο
Configuration-driven loading: A plugin is imported and loaded only if its source name appears in
config.yml. Unconfigured plugins remain inert.Mode-aware discovery: Components are selectively loaded based on execution mode:
SYNC mode: Loads core interfaces +
ISheetBuilder.AUDIT mode: Loads core interfaces +
IRule&IPDFBuilder.
Decorator registration: Components self-register via class decorators (e.g.
@CollectorRegistry.register("myplugin")).Multi-instance support: The framework supports configuring multiple named instances for the same plugin (e.g.,
prod,staging), each with independent configs.
Plugin Directory Structureο
A standard plugin resides in src/assets_guardian/plugins/ and follows this layout:
plugins/
βββ myplugin/
βββ __init__.py # Marks the directory as a Python package
βββ constants.py # SOURCE_NAME, role mappings, and other constants
βββ client.py # IClientProvider - creates the connection client
βββ repository.py # IRepository - fetches raw data from the external source
βββ mapper.py # IMapper - normalizes raw data to domain models
βββ collector.py # Collector - orchestrates repository and mapper
βββ rules.py # Optional - entry point re-exporting the rule classes below
βββ compliance.py # Optional - IComplianceRule implementations
βββ compare.py # Optional - IComparisonRule implementations
βββ matrix.py # Optional - IMatrixRule implementations
βββ excel_config.json # Optional - declarative Excel sheets (GenericSheetBuilder)
βββ sheet_builders.py # Optional - ISheetBuilder implementation (sync mode only)
βββ pdf_builder.py # Optional - IPDFBuilder implementation (audit mode only)
βββ CREDENTIALS.md # Optional - operator guide: credentials and required permissions
The discovery engine only ever imports client.py and collector.py, plus rules.py, sheet_builders.py and pdf_builder.py depending on the execution mode. Those are the only filenames the framework knows about, so they must be named exactly like this.
constants.py, repository.py and mapper.py are a strong convention rather than a framework requirement: nothing loads them directly, your collector.py imports them itself. Splitting the rules across compliance.py / compare.py / matrix.py is the same kind of convention, only rules.py is imported, and it re-exports the classes defined in those files.
π‘ Tip:
plugins/_template/is a working skeleton containing all of the above. Copy it as your starting point rather than creating files one by one.
Core Interfaces (Required)ο
IClientProviderο
File: core/domain/ports/client.py
Purpose: Establish and health-check the connection client for the external source.
class IClientProvider(ABC):
"""Port: Client provider used to collect data.
Defines the contract for obtaining a client
specific to a data source.
"""
def __init__(self, config: dict[str, Any]) -> None:
"""Initialize the client provider with the instance's configuration dictionary."""
raise NotImplementedError
@abstractmethod
def instantiate_client(self) -> HttpClient | MySQLClient | Any:
"""Returns a ready-to-use client.
The return type depends on the specific implementation (e.g., GraphServiceClient,
requests Session, GitLab client, etc.).
"""
raise NotImplementedError
@abstractmethod
def health_check(self) -> bool:
"""Verifies that the client connection, return True if the remote service is reachable and credentials are valid, else False.""" # noqa: E501
raise NotImplementedError
health_checkmust never raise exceptions. Always catch connection errors, log them, and returnFalse.
core/clients/ ships three reusable low-level clients. Wrap one of them, or return any client object your source needs, the framework never inspects the type:
Client |
File |
Use for |
|---|---|---|
|
|
REST APIs over HTTP, with header-based authentication |
|
|
Sources read straight from a MySQL database |
|
|
Microsoft Graph, wrapping |
π‘ Tip:
MicrosoftGraphis not only used by the Microsoft 365 plugin:core/microsoft365/relies on it to resolveremote:SharePoint paths and to send the audit report by email, for any plugin.
Example:
@ClientProviderRegistry.register("myplugin")
class MyPluginClientProvider(IClientProvider):
def __init__(self, config: dict[str, Any]) -> None:
self.__config = config
def instantiate_client(self) -> HttpClient:
token = self.__config["credentials"]["api_token"]
return HttpClient(
base_url=self.__config["url"],
headers={"Authorization": f"Bearer {token}"},
)
def health_check(self) -> bool:
try:
client = self.instantiate_client()
return client.get("/health").status_code == 200
except Exception as e:
logger.error(f"Health check failed for myplugin: {e}")
return False
IRepositoryο
File: core/domain/ports/repository.py
Purpose: Retrieve raw JSON/dict payloads from the external system.
class IRepository(ABC):
"""Port: Raw data repository access.
Defines the contract for retrieving raw data
from an external source (API, database, etc.).
"""
@abstractmethod
def get_raw_users(self) -> Iterable[dict[str, Any]]:
"""Retrieves the raw list of users."""
raise NotImplementedError
@abstractmethod
def get_raw_assets(self) -> Iterable[dict[str, Any]]:
"""Retrieves the raw list of assets (projects, sites, etc.)."""
raise NotImplementedError
@abstractmethod
def get_raw_accesses(self) -> Iterable[dict[str, Any]]:
"""Retrieves the raw list of accesses/permissions."""
raise NotImplementedError
Return
Iterable(generators or lazy iterables) to optimize memory footprint when fetching large quantities of records.
Example: see plugins/myplugin/repository.py in the complete example below.
IMapperο
File: core/domain/ports/mapper.py
Purpose: Pure data normalization. Converts raw, source-specific payloads into clean, strongly typed Assets Guardian domain models (Identity, Asset, Access).
class IMapper(ABC):
"""Port: Data normalizer (mapper).
Defines the contract for converting raw data from APIs
to the domain's normalized data models.
"""
@property
@abstractmethod
def source_name(self) -> str:
"""Unique source name (must match config.yml)."""
raise NotImplementedError
@property
@abstractmethod
def instance_id(self) -> str:
"""The specific instance identifier (e.g., 'main', 'prod')."""
raise NotImplementedError
@abstractmethod
def to_identity(self, raw_data: Any) -> Identity:
"""Converts raw data to an Identity object."""
raise NotImplementedError
@abstractmethod
def to_asset(self, raw_data: Any) -> Asset:
"""Converts raw data to an Asset object."""
raise NotImplementedError
@abstractmethod
def to_access(self, raw_data: Any, asset: Asset | None = None) -> Access:
"""Converts raw data to an Access object."""
raise NotImplementedError
Example: see plugins/myplugin/mapper.py in the complete example below.
Collectorο
File: core/domain/ports/collector.py
Purpose: Orchestrates the ingestion pipeline. It coordinates the repository (data retrieval) and mapper (data translation).
class Collector:
def __init__(self, client: Any, instance_config: dict[str, Any]) -> None:
self._client = client
self._config = instance_config
self._repository: IRepository
self._mapper: IMapper
@property
def source_name(self) -> str: ...
@property
def instance_id(self) -> str: ...
def collect_identities(self) -> Iterable[Identity]: ...
def collect_assets(self) -> Iterable[Asset]: ...
def collect_accesses(self) -> Iterable[Access]: ...
def collect_groups(self) -> Iterable[Any]: ...
def collect_permissions(self) -> Iterable[Any]: ...
The base
Collectorclass provides default loop implementations that fetch raw items from the repository and map them. You only need to overridecollect_*methods if you require custom filtering, secondary API requests, or advanced orchestration.
Example: see plugins/myplugin/collector.py in the complete example below.
Optional Interfacesο
ISheetBuilder (Sync Mode)ο
File: core/domain/ports/sheet_builders.py
Purpose: Dictates how sync data is printed to the output Excel reference sheet and which manual columns should be protected during regenerations.
class ISheetBuilder(ABC):
"""Port: Excel sheet builder (Plugin).
Contract for plugins wishing to add specific sheets
to the Excel referential.
"""
# Injected by the registry upon registration
source_name: str = ""
@property
@abstractmethod
def sheet_names(self) -> list[str]:
"""List of Excel sheet names managed by this builder."""
raise NotImplementedError
@property
@abstractmethod
def preserved_columns(self) -> dict[str, dict[str, list[str]]]:
"""Defines the columns to preserve per sheet.
Example::
{
'Sheet1': {'primary_keys': ['ID'], 'columns': ['Comments']},
'Sheet2': {'primary_keys': ['UUID'], 'columns': ['Account Type']}
}
"""
raise NotImplementedError
@abstractmethod
def get_rules(self) -> dict[str, Any]:
"""Returns plugin-specific Excel formatting rules."""
raise NotImplementedError
@abstractmethod
def build(
self,
worksheet: Any,
data: Any,
preserved: Any,
rules: Any,
) -> None:
"""Builds the Excel sheet for this plugin.
Args:
worksheet: The Excel sheet (ExcelWorksheet).
data: Dictionary of collection results (source, instance) -> CollectorResult.
preserved: Manual data extracted from the existing sheet for preservation.
rules: Formatting and validation rules.
"""
raise NotImplementedError
β οΈ Warning:
source_nameis declared on the interface, but the Excel writer also reads aninstance_idattribute on your builder to name sheets per instance (Myplugin (prod) Users). It is looked up withgetattr, so a builder that does not expose it does not crash: it silently falls back to the generic sheet name, and two instances of the same plugin then collide on the same sheet. Expose both when your plugin is multi-instance.π‘ Tip: Every sheet your builder returns is automatically read-only locked in Excel and checksummed for tamper detection by
ExcelWriter(unless its name ends in" Matrix") β there is nothing to opt into or configure on your side. SeeExcelWriter.__protect_sheet/__finalize_integrity_signatureincore/reporting/excel/writer.py.
Example:
TODO: Complete with an exampleβ¦
IRule (Audit Mode)ο
File: core/domain/models/rules/rule.py
Purpose: Inspects normalized domain collections to find anomalies or non-compliance states, producing Finding objects.
class IRule(ABC):
"""Base interface for all audit rules."""
rule_id: str = ""
@property
@abstractmethod
def rule_category(self) -> RuleCategory:
"""Category of the rule."""
raise NotImplementedError
@property
@abstractmethod
def severity(self) -> SeverityType:
"""Severity level of the rule."""
raise NotImplementedError
@property
@abstractmethod
def target_entity(self) -> str:
"""Entity targeted by the rule."""
raise NotImplementedError
@property
@abstractmethod
def name(self) -> str:
"""Name of the rule."""
raise NotImplementedError
@property
@abstractmethod
def description(self) -> str:
"""Description of the rule."""
raise NotImplementedError
@abstractmethod
def evaluate(self, **kwargs: Any) -> Iterable[Finding]:
"""Evaluates the rule on the provided data."""
raise NotImplementedError
Never inherit from IRule directly. Pick the sub-interface matching what the rule compares, it determines which arguments the engine passes to evaluate():
Sub-interface |
File |
Category |
|
Use it for |
|---|---|---|---|---|
|
|
|
|
A criterion checked on live identities or assets (MFA disabled, inactive account) |
|
|
|
|
A change since the last |
|
|
|
|
Active grants versus the authorized access matrix (see ACCESS_MATRIX.md) |
Reading severity from the configuration. severity is a property of your class, but its value must come from rules_config.yml, never be hardcoded. Read it in __init__ from kwargs, and when it is absent log a warning and fall back to a default rather than raising: an unconfigured severity must not abort the audit.
@RuleRegistry.register("COMPLIANCE-001")
class MyPluginInactiveAccountRule(IComplianceRule):
rule_id: str = "COMPLIANCE-001"
def __init__(self, **kwargs: Any) -> None:
self._name = kwargs.get("name", "Inactive account")
self._description = kwargs.get("description", "The account is disabled on the source.")
severity_value = kwargs.get("severity")
if not severity_value:
logger.warning(
"Rule %s: no 'severity' configured in rules_config.yml, defaulting to %s.",
self.rule_id,
SeverityType.WARNING,
)
severity_value = SeverityType.WARNING
self._severity = SeverityType(severity_value)
@property
def severity(self) -> SeverityType:
return self._severity
β οΈ Warning: Test
if not severity_valueexplicitly, do not rely onkwargs.get("severity", DEFAULT). The default of.get()only applies when the key is missing: a key present but left empty in the YAML (severity:with no value) yieldsNone, andSeverityType(None)raises aValueErrorthat aborts the whole audit.π‘ Tip: Every field of a rule entry in
rules_config.ymlreaches__init__throughkwargs, so custom parameters (thresholds, IP ranges, flags) are read exactly the same way. Apply the same warning-and-fallback treatment to them.π‘ Tip: If your plugin registers several rules, extract the warning-and-fallback logic into a small module-level helper (e.g.
_resolve_severity(rule_id, raw_severity, default)) instead of duplicating it in every__init__. Seeplugins/default_rules.pyfor a concrete example shared by theCTRL_HUMAN_*/CTRL_SERVICE_*/CTRL_GENERIC_*rules.
Example:
The CTRL_HUMAN_* rules in plugins/default_rules.py verify that identity fields (last name, first name, username, email, β¦) follow the companyβs identity-naming convention. Each rule inspects a single, narrow criterion on entries filtered by identity.identity_type, and only emits a Finding when the field is present but incorrectly formatted β a rule never flags a field that the source simply does not provide (e.g. GitLab has no separate first_name/last_name, only a merged name), it silently skips those identities instead of guessing:
@RuleRegistry.register("CTRL_HUMAN_LAST_NAME")
class HumanLastNameFormatRule(IComplianceRule):
rule_id: str = "CTRL_HUMAN_LAST_NAME"
def __init__(self, **kwargs: Any) -> None:
self.__name = kwargs.get("name", "Last name not uppercase")
self.__description = kwargs.get(
"description",
"Last name must be entered entirely in uppercase, while preserving "
"accents and special characters (e.g. hyphens).",
)
self.__severity = _resolve_severity(
self.rule_id, kwargs.get("severity"), SeverityType.WARNING
)
@property
def severity(self) -> SeverityType:
return self.__severity
def evaluate(self, entries: Iterable[Identity], config: dict[Any, Any] | None = None):
for identity in entries:
if identity.identity_type != IdentityType.HUMAN:
continue
if identity.last_name and identity.last_name != identity.last_name.upper():
yield Finding(...)
IPDFBuilder (Audit Mode)ο
File: core/domain/ports/pdf_builders.py
Purpose: Appends custom styled chapters summarizing plugin-specific findings inside the compiled PDF report.
class IPDFBuilder(ABC):
"""Contract that each plugin implements to render
its section in the PDF audit report.
"""
@property
@abstractmethod
def source_name(self) -> str:
"""Name of the source ('gitlab', 'm365', 'dolibarr')."""
raise NotImplementedError
@property
@abstractmethod
def section_title(self) -> str:
"""Title of the section in the PDF report.
E.g., 'GitLab - gitlab.com'
"""
raise NotImplementedError
@abstractmethod
def render(self, pdf: Any, findings: Iterable[Finding]) -> None:
"""Renders the PDF section for this source.
Args:
pdf: The PDF object currently being built (FPDF or equivalent).
findings: List of Finding elements filtered for this source,
already grouped by severity by the calling PDFWriter.
"""
raise NotImplementedError
Example:
TODO: Complete with an exampleβ¦
Registries & Registrationο
Decoupled components register themselves dynamically using Python decorators.
Component |
Target Registry |
Decorator |
Required |
|---|---|---|---|
|
|
|
β |
|
|
|
β |
|
|
|
β οΈ |
|
|
|
β |
|
|
|
β |
The two first are mandatory: without them the plugin cannot authenticate nor collect anything, and it is effectively absent from every command.
IRule is partially optional. A plugin with no rule of its own still collects and syncs normally, and still benefits from the built-in DEFAULT-XXX and CTRL_* rules of plugins/default_rules.py, which any source can inherit by pulling the <<: *default_rules anchor into its rules_config.yml section. It simply contributes no source-specific finding to the audit.
ISheetBuilder and IPDFBuilder are fully optional, because the framework provides a fallback for each. Without a sheet builder, the GenericSheetBuilder renders your sheets from excel_config.json, which is the recommended path. Without a PDF builder, findings are still rendered in the report using the default layout, only without a section styled specifically for your source.
Do not register
IRepositoryorIMapper. They are instantiated internally within the pluginβsCollector.
Naming rule IDsο
RuleRegistry.register() accepts any string, and namespaces it under the source it infers from the module path, so gitlabβs MATRIX-001 and dolibarrβs MATRIX-001 are two independent rules that never collide. The recommended convention is therefore to name rules by category, not by plugin:
Prefix |
Category |
Interface |
|---|---|---|
|
Criterion checked on live data |
|
|
Change since the last |
|
|
Grants versus the access matrix |
|
|
Reserved for the built-in rules of |
|
|
Reserved for the built-in identity naming-convention rules of |
|
Numbering restarts at 001 per category and per plugin. A plugin-prefixed form (MYPLUGIN-001) also works and appears in a few places in the codebase, but it carries no information about the ruleβs category, which is what actually determines the arguments evaluate() receives.
β οΈ Warning: Never register a rule of your own under a
DEFAULT-XXXorCTRL_*ID. Those IDs belong to the built-in rules shared by every source, and reusing one under your pluginβs namespace makesrules_config.ymlambiguous to read.π‘ Exception to the numbering convention: unlike every other prefix,
CTRL_<TYPE>_<FIELD>does not end in a numeric suffix. Each ID instead mirrors, field for field, the identity-creation naming-convention procedure it enforces (e.g.CTRL_HUMAN_LAST_NAMEchecks the βlast nameβ attribute for human identities), so the rule ID stays traceable back to that source document rather than to an arbitrary sequence number.
JSON Excel Configuration (Generic Sheet Builder)ο
Instead of writing a custom ISheetBuilder from scratch in Python, it is highly recommended to leverage the frameworkβs metadata-driven GenericSheetBuilder. This lets you declare sheet names, columns, value mappings, validations, and conditional styles using a simple JSON file (traditionally named excel_config.json inside your plugin directory).
JSON Configuration Structureο
A pluginβs Excel configuration is structured as a dictionary of sheet configurations. Here is a complete structure illustrating all possible properties, mappings, list validations, and conditional formats:
{
"MyPlugin - Users": {
"data_source": "identities",
"columns": [
{
"column_name": "User ID",
"field": "external_id",
"is_primary_key": true,
"width": 12
},
{
"column_name": "Full Name",
"field": "name",
"width": 25
},
{
"column_name": "Account Type",
"is_preserved": true,
"width": 15
},
{
"column_name": "2FA",
"field": "mfa_enabled",
"width": 12,
"mapping": {
"Enabled": true,
"Disabled": false
},
"rules": [
{
"rule_type": "list_validation",
"validate": "list",
"source": ["Enabled", "Disabled"]
},
{
"rule_type": "conditional_format",
"criteria": "==",
"value": "Disabled",
"format": "red"
}
]
},
{
"column_name": "Administrator",
"field": "is_privileged",
"width": 15,
"mapping": {
"True": true,
"False": false
},
"rules": [
{
"rule_type": "list_validation",
"validate": "list",
"source": ["True", "False"]
},
{
"rule_type": "conditional_format",
"criteria": "==",
"value": "True",
"format": "orange"
}
]
},
{
"column_name": "Comments",
"is_preserved": true,
"width": 50
}
]
}
}
Available Configuration Optionsο
Sheet Levelο
data_source(String, default:"identities"): The domain list to fetch from the collection output. Can be:"identities": YieldsIdentityentities."assets": YieldsAssetentities."accesses": YieldsAccessentities connecting a user to an asset.
TODO: Challenge the role of the filter_by_asset_type attribute; review its implications in Assets Guardian.
filter_by_asset_type(String, optional): Filter accesses by asset category (e.g.,"group","project").
Column Levelο
column_name(String, required): The display title in the Excel header.field(String, optional): The name of the property to retrieve from the domain model. If omitted (e.g. for user annotations like βCommentsβ), the field wonβt read raw data. The engine automatically checks:Direct class attributes (e.g.
item.email).Fallback keys in the modelβs
metadatadictionary (e.g.item.metadata.get(field)).
is_primary_key(Boolean, default:false): Marks this column as a composite primary key. Required for preserving manual inputs when data rows are refreshed. It also drives the baseline re-read: once at least one column declares it, any row whose primary-key cells are all empty is skipped instead of being turned into a model.is_preserved(Boolean, default:false): Tells the engine to carry over manual user edits made in this column on subsequent syncs.width(Integer, default:20): Column width in characters.
TODO: Challenge the role of the mapping attribute; review its implications in Assets Guardian, possibly together with the mapper used when reading the Excel workbook.
mapping(Object, optional): Translates Python raw types to readable sheet strings (e.g.,{"Enabled": true, "Disabled": false}).
Columns required by the round-tripο
The same excel_config.json is used twice: during sync to write the sheets, and during audit to read the previous workbook back into domain models so comparison rules can diff against it. That second pass rebuilds each row by calling the modelβs constructor with the columns that declare a field.
A column must therefore be mapped to every required constructor argument of the sheetβs data_source model, otherwise no row can be rebuilt:
|
Fields a column must map to |
|---|---|
|
|
|
|
|
|
source is injected by the parser, and identity_type falls back to GENERIC, so neither needs a column.
β οΈ Warning: A missing required field fails silently. The row is dropped,
Failed to instantiate model <Model>.is logged, and the audit continues against an empty baseline, so comparison rules simply find nothing to report.nameis the easy one to forget, since it is rarely displayed under that title: map it from whatever column carries the display name (Full Name,Display Name, β¦).
Rules & Formatting Referenceο
Each column can declare a list of dynamic Excel rules under the "rules" array:
List Validation (Dropdowns)ο
Forces a dropdown list in the generated Excel sheet for that columnβs cells.
rule_type:"list_validation"validate:"list"source(Array of Strings): The options available in the cell dropdown (e.g.,["Active", "Blocked"]).ignore_blank(Boolean, default:true): Allows empty inputs without triggering an Excel error flag.
Conditional Formattingο
Applies custom fills and text styles dynamically in Excel based on values.
rule_type:"conditional_format"criteria: Comparison operator. Available options:"==": Cell is equal to."!=": Cell is not equal to."is_empty": Cell has no content."is_not_empty": Cell is not blank.
value(String, Number, Boolean, or List): The comparison value(s). If list, generates composite checks (e.g., cell not equal to any of the listed items).format(String): Color code from the standard framework palette:"red": Red background with dark red text (for danger/non-compliance)."orange": Light orange fill with dark orange text (for warnings/privileges)."yellow": Light yellow fill with dark gold text (for blocked/inactive states)."green": Soft green fill with dark green text (for compliant/ok states)."blue": Light blue background with navy text."black": Black fill with white text."white": White background with black text.
Configuration Workflowο
Declare instances in config/config.yml. The primary key must match the registered SOURCE_NAME.
# config/config.yml
myplugin:
production: # instance_id
url: "https://api.myplugin.io/v1"
credentials:
api_token: "${MYPLUGIN_PROD_TOKEN}" # Resolves environment variable at runtime
staging: # instance_id
url: "https://staging.myplugin.io/v1"
credentials:
api_token: "${MYPLUGIN_STAGE_TOKEN}"
TODO: Explain the fields involved in declaring a new plugin. Check consistency with the config.yml-related section in ARCHITECTURE.md
Auditing Rules Setupο
Configure thresholds and severities for rules dynamically via config/rules_config.yml:
β οΈ Warning:
severityaccepts exactly four values, in uppercase:INFO,WARNING,DANGER,CRITICAL. They are the values of theSeverityTypeenum, so any other spelling ("High", or even"Warning"in mixed case) raises aValueErrorat rule instantiation.
myplugin:
<<: *default_rules # Optional: inherit the built-in DEFAULT-XXX and CTRL_* rules
COMPLIANCE-001:
description: "Description of the rule"
severity: "WARNING"
MATRIX-001:
description: "Description of the rule"
severity: "DANGER"
my_custom_threshold: 30 # Free-form parameter, read by the rule itself
β οΈ Warning: A rule is instantiated only if its ID appears under the pluginβs section here. A rule implemented and registered in Python but absent from this file is silently ignored at runtime, no warning is logged.
π‘ Tip: Custom parameters deserve the same defensive reading as
severity: check for a missing or empty value, log a warning, and fall back to a sane default rather than raising. SeeIRuleabove.
Every key under a rule ID is forwarded as-is to the ruleβs __init__ through **kwargs. Three of them are conventional and understood by all rules, the rest are free-form:
Field |
Read by |
Purpose |
|---|---|---|
|
The rule |
Short title of the finding in the report. Falls back to a value hardcoded in the rule |
|
The rule |
Detailed wording of the finding. Falls back to a value hardcoded in the rule |
|
The rule |
Ranking and colour of the finding. Falls back to the ruleβs default, with a warning |
anything else |
The rule |
Custom parameters: thresholds, IP ranges, module lists, flags. What is accepted depends entirely on the ruleβs implementation, the framework never inspects them |
Plugin Lifecycle & Data Flowο
sequenceDiagram
autonumber
participant Engine as Core Engine
participant Plugin as Plugin Module
participant Collector as Collector
participant Repo as Repository
participant API as External API
Engine->>Plugin: 1. Dynamically import active modules
Note over Plugin: Decorators self-register components
Engine->>Collector: 2. Instantiate with configuration
Collector->>Repo: 3. Instantiate with API client
Engine->>Collector: 4. collect_identities()
Collector->>Repo: 5. get_raw_users()
Repo->>API: 6. Fetch raw items
API-->>Repo: 7. Return JSON response
Repo-->>Collector: 8. Yield raw dictionaries
Note over Collector: Normalizes raw data via Mapper
Collector-->>Engine: 9. Yield normalized Identities
Complete Example (myplugin)ο
Here is a ready-to-use boilerplate plugin.
plugins/myplugin/constants.pyο
SOURCE_NAME = "myplugin"
ROLES_MAP = {
1: "Admin",
2: "Developer",
3: "Guest"
}
plugins/myplugin/client.pyο
from typing import Any
import logging
from assets_guardian.core.domain.ports.client import IClientProvider
from assets_guardian.core.domain.registry.client_registry import ClientProviderRegistry
from assets_guardian.core.clients.http_client import HttpClient
from .constants import SOURCE_NAME
logger = logging.getLogger(__name__)
@ClientProviderRegistry.register(SOURCE_NAME)
class MyPluginClientProvider(IClientProvider):
def __init__(self, config: dict[str, Any]) -> None:
self.__config = config
def instantiate_client(self) -> HttpClient:
token = self.__config["credentials"]["api_token"]
return HttpClient(
base_url=self.__config["url"],
headers={"Authorization": f"Bearer {token}"},
)
def health_check(self) -> bool:
try:
client = self.instantiate_client()
return client.get("/healthz").status_code == 200
except Exception as e:
logger.error(f"Health check failed for {SOURCE_NAME}: {e}")
return False
plugins/myplugin/repository.pyο
from collections.abc import Iterable
from typing import Any
from assets_guardian.core.domain.ports.repository import IRepository
class MyPluginRepository(IRepository):
def __init__(self, client: Any) -> None:
self._client = client
def get_raw_users(self) -> Iterable[dict[str, Any]]:
return self._client.get("/v1/users").json().get("users", [])
def get_raw_assets(self) -> Iterable[dict[str, Any]]:
return self._client.get("/v1/projects").json().get("projects", [])
def get_raw_accesses(self) -> Iterable[dict[str, Any]]:
return self._client.get("/v1/memberships").json().get("memberships", [])
plugins/myplugin/mapper.pyο
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, IdentityState, IdentityType
from assets_guardian.core.domain.ports.mapper import IMapper
from .constants import SOURCE_NAME, ROLES_MAP
class MyPluginMapper(IMapper):
def __init__(self, instance_id: str) -> None:
self._instance_id = instance_id
@property
def source_name(self) -> str:
return SOURCE_NAME
@property
def instance_id(self) -> str:
return self._instance_id
def to_identity(self, raw_data: Any) -> Identity:
# Safe extraction guarding against unexpected empty dicts
raw = raw_data if isinstance(raw_data, dict) else {}
is_active = raw.get("status") == "active"
return Identity(
source=self.source_name,
external_id=str(raw.get("id", "")),
identity_type=IdentityType.HUMAN,
name=raw.get("display_name", "Unknown User"),
email=raw.get("email_address"),
username=raw.get("login_name"),
state=IdentityState.ACTIVE if is_active else IdentityState.BLOCKED,
mfa_enabled=raw.get("two_factor_auth", False),
)
def to_asset(self, raw_data: Any) -> Asset:
raw = raw_data if isinstance(raw_data, dict) else {}
return Asset(
source=self.source_name,
external_id=str(raw.get("project_id", "")),
asset_type="project",
name=raw.get("title", "Unnamed Project"),
description=raw.get("summary"),
)
def to_access(self, raw_data: Any, asset: Asset | None = None) -> Access:
raw = raw_data if isinstance(raw_data, dict) else {}
role_id = raw.get("role_level_id", 3)
return Access(
source=self.source_name,
access_type="role",
name=ROLES_MAP.get(role_id, "Guest"),
asset=asset,
state="active",
)
plugins/myplugin/collector.pyο
from typing import Any
from assets_guardian.core.domain.ports.collector import Collector
from assets_guardian.core.domain.registry.collector_registry import CollectorRegistry
from .constants import SOURCE_NAME
from .mapper import MyPluginMapper
from .repository import MyPluginRepository
@CollectorRegistry.register(SOURCE_NAME)
class MyPluginCollector(Collector):
def __init__(self, client: Any, instance_config: dict[str, Any]) -> None:
super().__init__(client, instance_config)
self._mapper = MyPluginMapper(instance_id=self.instance_id)
self._repository = MyPluginRepository(client)
@property
def source_name(self) -> str:
return SOURCE_NAME
@property
def instance_id(self) -> str:
return str(self._config.get("instance_id", "default"))
Troubleshooting & Common Pitfallsο
Plugin is not registered or discoveredο
Is it declared in
config/config.yml? The discovery system only imports plugins that have an active configuration entry matching theirSOURCE_NAME.Are there Python compilation or import errors inside the plugin package? Check your console output carefully. An unhandled syntax error or import error in
client.pyorcollector.pywill quietly skip registration or fail the boot process.Is the directory structured as a valid Python package? Ensure an
__init__.pyfile exists insideplugins/myplugin/.
Health check fails or crashes the applicationο
Ensure your
health_checkmethod catches all subclasses ofException. If the external API is completely offline, raising aConnectionRefusedErrorin the provider will crash the CLI. Wrap your request in a cleantry...except Exception:block.
Columns disappear during Excel sync regenerationο
Depending on how your sheet builder is designed:
Generic Sheet Builder (JSON-driven): Ensure your manual columns are configured in your JSON file (e.g.,
excel_config.json) with"is_preserved": trueand a primary key is declared with"is_primary_key": true.Custom Sheet Builder (Python-driven): Ensure you register your manual comments/columns inside the
preserved_columnsproperty of your customISheetBuilderPython class. If you omit this setup, manual annotations made in the Excel file will be discarded when a new sync is calculated.
Testing & Validation Checklistο
Execute these validation tasks before submitting code changes:
[ ]
SOURCE_NAMEinconstants.pyis identical to your configuration block keys and decorators.[ ]
IClientProvider.health_check()handles exceptions cleanly and returns a boolean value.[ ] Mappers use safe
.get()commands to prevent raising unexpectedKeyErrorexceptions when parsing raw, malformed API payloads.[ ]
Collector.__init__executessuper().__init__(client, instance_config)correctly.[ ] No API keys, tokens, or credentials are hardcoded.
[ ] All methods have appropriate type annotations.
TODO: This list will need to be completed.