Module & Field Schema Reference¶
This is the authoring reference for FortiSOAR modules and fields: every field type you can create, the properties each carries, and how relationship fields wire to other modules. It is the companion to Module Schema Administration (which covers the staging → publish workflow); this page is the data model you build with — and is written so an LLM can generate valid field definitions from it.
Everything here was extracted from a live FortiSOAR appliance (its 64 modules and ~1,500 real fields) and verified by creating and publishing test modules.
See also
examples/all_field_types_module.py
builds a module exercising every field type described here.
The two axes of a field: type vs formType¶
Every field (an attribute in the metadata) has two type axes, and they are not the same thing:
Axis |
Metadata key |
What it is |
Allowed values |
|---|---|---|---|
Storage type |
|
the Postgres column type the platform stores |
|
Display type |
|
the kind of field shown/edited in the editor |
|
Important
There is no text storage type. Text-like fields (text, textarea, richtext,
email, …) all store string. Setting type: "text" looks fine in staging but
fails at publish with:
Attribute type 'text' does not exist as core or custom model metadata.
This is the single most common authoring mistake. Always pair the display type with its correct storage type — or let the typed builders do it for you (see below).
The display type → storage mapping is exposed in code as
pyfsr.api.modules_admin.DISPLAY_STORAGE_TYPE:
|
|
Notes |
|---|---|---|
|
|
single-line |
|
|
multi-line plain text |
|
|
WYSIWYG rich text |
|
|
raw HTML |
|
|
email-format validation |
|
|
URL field |
|
|
phone field |
|
|
masked input; pair with |
|
|
hash field (MD5/SHA) |
|
|
IP field |
|
|
file attachment ( |
|
|
whole number |
|
|
stored as epoch-millis integer — not a bug |
|
|
true/false |
|
|
arbitrary JSON object |
|
|
single-select — see Picklist fields |
|
|
multi-select (a collection) |
|
target module |
single reference (many-to-one) |
|
target module |
collection relationship |
|
target module |
collection relationship |
Building fields: typed builders vs. field()¶
client.modules_admin gives you typed builders that set both axes correctly. Prefer
them — they make the type: "text" mistake impossible:
admin = client.modules_admin
fields = [
admin.text_field("name", required=True), # string / text
admin.text_field("summary", area=True), # string / textarea
admin.text_field("writeup", rich=True), # string / richtext
admin.integer_field("score"), # integer / integer
admin.datetime_field("detectedOn"), # integer / datetime
admin.checkbox_field("isExternal"), # boolean / checkbox
admin.email_field("reporter"), # string / email
admin.url_field("reference"), # string / url
admin.object_field("rawPayload"), # object / object
admin.picklist_field("status", "AlertStatus"), # picklists / picklist
admin.lookup_field("owner", "people"), # people / lookup (many-to-one)
admin.relationship_field("relatedAlerts", "alerts"), # alerts / manyToMany
]
admin.create_module("widgets", label="Widget", fields=fields)
admin.typed_field(name, display_type, ...) is the generic form for any scalar display
type in the table above (e.g. admin.typed_field("md5", "filehash")). For the low-level
escape hatch where you set both axes yourself, use
field() directly.
Field properties (the editor’s Properties panel)¶
Every builder accepts these keyword options (they map 1:1 to the in-product editor):
Builder kwarg |
Metadata |
Meaning |
|---|---|---|
|
|
Field Title (human-readable). |
|
|
|
|
|
Indexed for search. Mutually exclusive with |
|
|
Editable in the UI. |
|
|
Shown as a default column in the list/grid view. |
|
|
Stored encrypted at rest. Cannot be searchable. |
|
|
|
|
|
Pre-filled value on new records. |
|
|
Help text shown next to the field. |
|
|
Length constraints (default max |
|
|
Enables the min/max numeric range UI. |
|
|
Allow editing this field in bulk actions. |
Conditional required/visibility take a condition. Pass a Query
and pyfsr renders the FortiSOAR filter shape for you — e.g. “require emailFrom only
when type is Phishing”:
from pyfsr import Query
admin.email_field("emailFrom",
required=Query(module="alerts").eq("type", "Phishing"))
Because the Query is module-bound, the picklist field auto-resolves to
type.itemValue and you compare by the friendly name instead of hunting down
/api/3/picklists/<uuid>. A pre-built condition dict (or a plain True/False)
still works if you’d rather assemble it yourself.
Picklist fields¶
Picklists store type: "picklists" and bind to a named picklist via dataSource:
admin.picklist_field("severity", "AlertSeverity") # single-select
admin.picklist_field("threatTypes", "ThreatType", multi=True) # multi-select (collection)
picklist_nameis the picklist’s list name (e.g."AlertSeverity"), discoverable viaclient.picklists.multi=Trueswitches the display type tomultiselectpicklistand setscollection=True.The builder writes the
dataSourcequery that filterspicklistsbylistName__name == <picklist_name>, sorted byorderIndex.
To create the picklist values themselves, manage /api/3/picklist_names and
/api/3/picklists separately — fields only reference an existing picklist.
Relationships¶
Three relationship display types, distinguished by cardinality and which side owns the join:
Display type |
Cardinality |
|
Owns join? |
Reverse field on target |
|---|---|---|---|---|
|
many-to-one |
|
no |
none — one-directional pointer |
|
many-to-many |
|
yes |
always exists (see below) |
|
one-to-many |
|
yes |
a |
Wiring keys on a relationship attribute:
type— the target module type (e.g."alerts"), notstring.inversedField— name of the field on the target that points back.ownsRelationship—Trueon the side that owns the join table.
pyfsr keeps both sides valid for you.
add_field() creates the reverse side on the
target whenever the platform won’t, so you only declare the relationship once. Pass
create_reverse=False to opt out and manage the target side yourself.
Lookup (many-to-one)¶
admin.add_field("incidents", admin.lookup_field("owner", "people"))
A single pointer to one target record — not a collection, owns nothing, and intentionally
has no reverse field. Two modules can each look up people independently. Use it
whenever a record just references one X.
Many-to-many¶
# Default inverse — FortiSOAR mirrors the reverse field itself:
admin.add_field("incidents", admin.relationship_field("relatedAlerts", "alerts"))
# Custom inverse name — pyfsr adds the matching reverse field to the target:
admin.add_field("incidents",
admin.relationship_field("relatedAlerts", "alerts", inversed_field="parentIncidents"))
A many-to-many always ends up two-directional. With the default inverse the platform
creates the reverse field (named after the source module) at staging time. With a custom
inversed_field the platform does not — so add_field adds the mirror manyToMany
(ownsRelationship=False, inversedField pointing back) to the target for you.
One-to-many¶
admin.add_field("incidents",
admin.relationship_field("relatedAlerts", "alerts", many=False, inversed_field="incident"))
A oneToMany requires a matching lookup (many-to-one) on the target whose name equals
inversed_field — without it, publish fails with “there is no lookup field present in
‘add_field creates that lookup on the target automatically, so the single
call above leaves both modules publishable. (The target module must already exist.)
Verifying & publishing¶
After publish, confirm the reverse attribute the platform actually stored with
reverse_field():
admin.create_module("widgets", fields=[admin.text_field("name", required=True)])
admin.add_field("widgets", admin.relationship_field("relatedAlerts", "alerts"))
admin.publish() # appliance-wide; blocks until committed
rev = admin.reverse_field("widgets", "relatedAlerts", published=True)
print("Reverse field:", rev["name"], rev["formType"])
Warning
A publish that reports “started” is not a publish that committed. PUT /api/publish
only kicks off an asynchronous backup + migrate + commit, during which the entire API
(/api/3) returns 503 for ~30–60s. The default
publish() waits out that outage and confirms
the outcome via /api/publish/error (a fresh last_publish_time with status: "Success"),
raising the appliance’s reported error on any other state. Test reverse-field behavior
end-to-end on a real publish — staging shows the intended wiring, but only a committed
publish creates the physical join.
Note
Schema validation errors are synchronous. A bad field — a type that does not exist,
or a oneToMany whose target has no matching lookup — is rejected on the PUT /api/publish
itself (HTTP 400) before any migrate runs, raised as an
APIError whose message is the appliance’s own, e.g.:
For many-to-one 'brokenRel' field in 'widgets' module there is no lookup field present in 'alerts' module.
Surface that message verbatim — it names the offending field and module. (Each error line
is prefixed with internal modelMetadatas[uuid].attributes[uuid].formtype: ids you can
strip for end users.) Because validation fails before the migrate, /api/publish/error is
untouched and nothing on the appliance changes.
Validation: caught early vs. caught at publish¶
The appliance accepts a lot of invalid schema into staging and only rejects it during
the slow, appliance-wide publish — or worse, publishes a broken module. To avoid that
round-trip, the builders reject the known-bad inputs client-side, raising ValueError
before anything is sent:
Bad input |
Where the appliance catches it |
pyfsr guard |
|---|---|---|
|
publish (“Attribute type ‘text’ does not exist”) |
|
field name with spaces / punctuation / leading digit |
publish (bad SQL column) |
|
|
silently broken |
|
module name with uppercase / spaces / leading digit |
publish / broken table |
|
empty |
invalid module |
|
name longer than 63 chars |
publish (Postgres identifier limit) |
both raise |
duplicate field name in a module |
staging POST (fast) |
appliance already rejects — “Duplicate field ‘x’… names are case-insensitive” |
reserved key |
staging POST (fast) |
appliance already rejects — “‘id’ is a reserved keyword” |
duplicate module type |
staging POST (fast) |
appliance already rejects (uniqueness constraint) |
|
publish |
|
relationship to a non-existent target module |
publish |
|
Note
The last group is not guarded client-side because the appliance already fails fast (at
the cheap staging POST, not at publish) with a clear message — surface it as-is. Only the
checks that would otherwise slip through to the expensive publish are enforced in pyfsr.
A bad draft anywhere wedges the whole publish¶
Publish is appliance-wide, so a single illegally-named draft created outside pyfsr (in the in-product editor, or by another tool) makes every publish fail mid-migrate with a cryptic Postgres error and no module name:
syntax error, unexpected integer "9", expecting identifier
Worse, /api/publish/error can still report Success while nothing actually commits. To
prevent this, publish() runs
find_invalid_drafts() first and refuses with
a named error before the destructive PUT:
admin.find_invalid_drafts()
# [{'module': '9probe', 'uuid': '...', 'problem': 'invalid module name'}]
admin.publish()
# ValueError: refusing to publish: invalid staged draft(s) would fail the
# appliance-wide migrate: '9probe' (invalid module name). Fix or discard them...
admin.discard_staging_draft("9probe") # remove the offender, then publish cleanly
admin.publish()
Use find_invalid_drafts(deep=True) to also scan every draft’s field names (one read
per module). Pass publish(precheck=False) only if you deliberately want to skip the check.
Module-level settings¶
When you create_module(...) (or set_module_settings(...)), these flags map to the
editor’s Additional Settings:
Builder kwarg |
Metadata |
Meaning |
|---|---|---|
|
|
Team/user record ownership. |
|
|
Record-level change history. |
|
|
Full-text indexing. |
|
|
Allow tags on records. |
|
|
Eligible for queue management. |
|
|
Soft-delete / recycle bin. |
|
|
Replicate across tenants. |
|
|
Jinja record title, e.g. |
|
|
List of field names enforcing uniqueness. |
|
|
e.g. |
Note
There is no delete-module API. An unpublished draft can be removed with
discard_staging_draft(); a published
module and its Postgres table persist even after the draft is discarded.