--- name: add-node-type description: Define a new node schema under cartography/models/MODULE_NAME/, including required properties, sub-resource relationships, extra labels, conditional labels, scoped cleanup, and one-to-many transforms. Use when the user asks to add a node type, model a new resource, configure extra Neo4j labels (Identity, Asset, UserAccount, Tenant), or wire scoped vs global cleanup. --- # add-node-type Add a new `CartographyNodeSchema` (and its `CartographyNodeProperties`) to a Cartography module. This skill assumes the surrounding module already exists; for end-to-end module creation, see the `create-module` skill. ## Critical rules 1. **`@dataclass(frozen=True)` everywhere**, with type annotations on every field (`x: PropertyRef = PropertyRef("x")`). A missing annotation triggers `PropertyRef validation failed`. 2. **Only the standard schema fields are recognised.** Custom fields on `CartographyNodeSchema` / `CartographyRelSchema` subclasses are silently ignored — see "Standard fields" below. 3. **`sub_resource_relationship` always points to a tenant-like node** (`AWSAccount`, `AzureSubscription`, `GCPProject`, `GitHubOrganization`, your `Tenant`). 4. **`scoped_cleanup` defaults to `True`.** Override to `False` only for genuinely global data (CVE feeds, public threat intel, public DNS). ## Instructions ### Step 1 — Required properties Every node type needs at least: ```python @dataclass(frozen=True) class YourNodeProperties(CartographyNodeProperties): id: PropertyRef = PropertyRef("id") # REQUIRED lastupdated: PropertyRef = PropertyRef("lastupdated", set_in_kwargs=True) # REQUIRED # business properties... ``` `PropertyRef` parameters: - First arg: per-record dict key, **or** kwarg name when `set_in_kwargs=True`. - `extra_index=True` — add a database index for query performance. - `set_in_kwargs=True` — value comes from `load(..., KWARG=...)`, not the per-record dict. - `one_to_many=True` — value is a list; expand to many edges (see "One-to-many" below). ### Step 2 — Schema with sub-resource relationship ```python @dataclass(frozen=True) class YourServiceUserSchema(CartographyNodeSchema): label: str = "YourServiceUser" properties: YourServiceUserNodeProperties = YourServiceUserNodeProperties() sub_resource_relationship: YourServiceTenantToUserRel = YourServiceTenantToUserRel() other_relationships: OtherRelationships = OtherRelationships([ YourServiceUserToHumanRel(), ]) ``` ### Step 3 — Pick the right standard fields `CartographyNodeSchema` standard fields: | Field | Type | Required | Notes | | --------------------------- | --------------------------------- | :------: | ----------------------------------------------------------- | | `label` | `str` | Yes | Neo4j node label | | `properties` | `CartographyNodeProperties` subclass | Yes | | | `sub_resource_relationship` | `CartographyRelSchema` subclass | Yes | Tenant-like target. `None` only if `scoped_cleanup=False`. | | `other_relationships` | `OtherRelationships` | No | Additional business relationships | | `extra_node_labels` | `ExtraNodeLabels` | No | Additional Neo4j labels (e.g. `Identity`, `UserAccount`) | | `scoped_cleanup` | `bool` | No | Defaults to `True`. Almost never override. | `CartographyRelSchema` standard fields: `target_node_label`, `target_node_matcher`, `direction`, `rel_label`, `properties`. Custom fields **do nothing** — handle conditional behaviour in `transform()` by setting fields to `None` or filtering before `load()`. ### Step 4 — Extra labels Add additional Neo4j labels by importing reusable uppercase constants and wrapping them in `ExtraNodeLabels`: ```python from cartography.models.core.nodes import ExtraNodeLabels from cartography.models.ontology.labels import USER_ACCOUNT extra_node_labels: ExtraNodeLabels = ExtraNodeLabels([USER_ACCOUNT]) ``` Produces `(:YourServiceUser:UserAccount)`. `ExtraNodeLabel` is one immutable value type, not a base class for one subclass per label. Label definitions are exported uppercase constants; their `description` fields are metadata for introspection and generated documentation. Do not rely on class docstrings. `ExtraNodeLabels` stores the supplied labels as an immutable tuple. For ontology-driven labels (`UserAccount`, `Tenant`, `Database`, ...) see the `enrich-ontology` skill. ### Step 5 — Decide on `scoped_cleanup` Default behaviour `scoped_cleanup=True` is correct for almost everything: user accounts, infrastructure resources, application assets — anything scoped to a tenant. Override to `False` only for genuinely global data with no tenant: ```python @dataclass(frozen=True) class VulnerabilitySchema(CartographyNodeSchema): label: str = "Vulnerability" properties: VulnerabilityNodeProperties = VulnerabilityNodeProperties() sub_resource_relationship: None = None scoped_cleanup: bool = False ``` Examples that justify `scoped_cleanup=False`: CVE databases, threat intel feeds, public certificate transparency logs, global DNS / domain info. ### Step 6 — One-to-many When one source record points to many targets, flatten the IDs in `transform()` and use `one_to_many=True` on the relationship matcher: ```python # transform def transform_route_tables(route_tables): out = [] for rt in route_tables: out.append({ "id": rt["RouteTableId"], "subnet_ids": [a["SubnetId"] for a in rt.get("Associations", []) if "SubnetId" in a], }) return out # relationship @dataclass(frozen=True) class RouteTableToSubnetRel(CartographyRelSchema): target_node_label: str = "AWSEC2Subnet" target_node_matcher: TargetNodeMatcher = make_target_node_matcher({ "subnet_id": PropertyRef("subnet_ids", one_to_many=True), }) direction: LinkDirection = LinkDirection.OUTWARD rel_label: str = "ASSOCIATED_WITH" properties: RouteTableToSubnetRelProperties = RouteTableToSubnetRelProperties() ``` ### Step 7 — Date handling Neo4j 4+ supports native datetimes and ISO 8601 strings. Pass values through directly — do not convert to epoch milliseconds. ```python # DO "created_at": user_data.get("created_at") # DON'T "created_at": int(dt_parse.parse(user_data["created_at"]).timestamp() * 1000) ``` ### Step 8 — Loading ```python from cartography.client.core.tx import load load(neo4j_session, YourServiceTenantSchema(), [{"id": tenant_id}], lastupdated=update_tag) load( neo4j_session, YourServiceUserSchema(), data, lastupdated=update_tag, TENANT_ID=tenant_id, ) ``` ## Common issues - `PropertyRef validation failed` — missing `frozen=True` or missing type annotation. - Custom field on a `Schema` is "ignored" — only standard fields are recognised. - Cleanup deleting too much / too little — verify `scoped_cleanup` and `common_job_parameters["TENANT_ID"]`. For the full troubleshooting list, see the `troubleshooting` skill. ## References (load on demand) - `references/advanced-properties.md` — conditional labels (ECR images / attestations), MatchLink targets, deeper sub-resource rationale, ECS example.