Module Schema Administration¶
Where ModulesAPI (client.modules) is read-only
discovery, client.modules_admin (ModulesAdminAPI)
is the write surface for the Application/Module Editor — create modules, add and
alter fields, track pending changes, and publish.
All examples below were run against a live FortiSOAR appliance; the outputs shown are real (trimmed for length).
How the editor really works¶
FortiSOAR keeps schema in two parallel stores, and a separate physical layer:
Store / layer |
Endpoint |
Holds |
|---|---|---|
Staging |
|
the editable draft of every module |
Published |
|
the committed schema records reads use |
Physical table |
|
only created when a global publish runs its migration |
Both stores mirror all modules. A module has an uncommitted change when its
staging record differs from its published one. Creating a module or editing a field
touches staging only — nothing is live until you publish(),
which runs an appliance-wide backup + DB migrate cycle and creates the table.
Warning
Publish is appliance-wide. PUT /api/publish promotes every pending staged
change across the whole instance, not just modules you touched. On a shared box,
check pending_changes() first.
Walkthrough: two linked modules (a heist tracker)¶
Before the reference sections, here’s the whole arc end to end. We’ll build a
tiny heist tracker: a crew module (the people pulling the job) and a
heists module (the jobs), linked so a heist has a whole crew and a crew member
has a rap sheet of heists. The fun part — you only declare the link once;
the SDK stages the reverse side for you.
admin = client.modules_admin
# 1. The crew. Each member has a name and a specialty.
admin.create_module(
"crew",
label="Crew Member",
plural="Crew",
fields=[
admin.text_field("alias", required=True, grid_column=True), # "The Brains", "Wheels"
admin.picklist_field("specialty", "AlertType"), # reuse any existing picklist
admin.checkbox_field("trustworthy"),
],
record_uniqueness=["alias"],
)
# 2. The heists. The `crew` field is the link — a many-to-many relationship to
# the module we just made. We declare it ONLY here.
admin.create_module(
"heists",
label="Heist",
plural="Heists",
fields=[
admin.text_field("codename", required=True, grid_column=True), # "Operation Cannoli"
admin.text_field("target", grid_column=True),
admin.integer_field("takeUsd"),
admin.datetime_field("goTime"),
admin.relationship_field("crew", "crew", label="Crew"), # <-- the linkage
],
)
That single relationship_field is the whole trick. Because the SDK keeps both
sides of a relationship valid, it auto-stages the reverse field on crew —
so each crew member gets a heists field listing every job they’re on, without
you touching the crew module again:
[a["name"] for a in admin.get_staging("crew")["attributes"]]
# ['alias', 'specialty', 'trustworthy', 'heists'] <-- 'heists' appeared on its own
Nothing is live yet — both modules are staging-only drafts. Check what a publish would commit, then commit it (remember: publish is appliance-wide):
admin.pending_changes()
# [{'module': 'crew', 'change': 'created'}, {'module': 'heists', 'change': 'created'}]
admin.publish() # backup + migrate; blocks ~30–60s while /api/3 is down
Now the tables exist and you can populate the caper. Create the crew, then a heist that references them — the link is just a list of record IRIs:
danny = client.records("crew").create({"alias": "The Brains", "trustworthy": True})
linus = client.records("crew").create({"alias": "Light Fingers", "trustworthy": True})
job = client.records("heists").create({
"codename": "Operation Cannoli",
"target": "Bellagio Vault",
"takeUsd": 150_000_000,
"crew": [danny["@id"], linus["@id"]], # link by IRI
})
Because the reverse field exists, the relationship reads both ways for free — ask a heist for its crew, or a crew member for their heists:
client.records("heists").get(job["uuid"], relationships=True)["crew"]
# -> [{'alias': 'The Brains', ...}, {'alias': 'Light Fingers', ...}]
client.records("crew").get(danny["uuid"], relationships=True)["heists"]
# -> [{'codename': 'Operation Cannoli', ...}]
That’s the full loop: two create_module calls, one relationship, one
publish — and a bidirectional link you only had to describe once. The rest of
this guide is the reference behind each step.
Inspecting existing schema (read-only)¶
admin = client.modules_admin
admin.is_published("alerts") # -> True
admin.is_published("nonexistentmod") # -> False
pub = admin.get_published("alerts")
# {'uuid': 'f43192a7-d6ef-498c-8cd2-57521928e500', 'type': 'alerts',
# 'module': 'alerts', 'tableName': 'alerts'} (+ 126 fields under 'attributes')
admin.get_field("alerts", "name")
# {'name': 'name', 'type': 'string', 'formType': 'text', 'searchable': True}
Note
is_published() reports presence in model_metadatas. A freshly created module is
staging-only until you publish, so it reads False until then.
Building fields¶
Tip
For the full field-type catalogue — every display type, its storage type, properties, and relationship/reverse-field semantics — see Module & Field Schema Reference. This section is a quick start; that page is the authoring reference.
Prefer the typed builders, which set the storage type and formType (display type)
to a matching pair for you (e.g. a datetime field must store integer; a text field
must store string):
admin.text_field("summary", area=True) # string / textarea
admin.integer_field("score") # integer / integer
admin.datetime_field("detectedOn") # integer / datetime
admin.checkbox_field("isExternal") # boolean / checkbox
admin.object_field("payload", label="Payload") # object / object
Warning
There is no text storage type (and no json type). Text fields store string;
JSON stores object. Hand-setting db_type="text" stages fine but fails at publish
(“Attribute type ‘text’ does not exist”). The typed builders avoid this entirely.
field() is the low-level escape hatch where
you set both axes yourself; admin.typed_field(name, display_type) derives the storage
type for any scalar display type. The object field above produces:
{
"name": "payload",
"type": "object",
"formType": "object",
"descriptions": {"singular": "Payload"},
"displayName": "{{ payload }}",
"searchable": false,
"collection": false,
"visibility": true,
"readable": true,
"writeable": true,
"validation": {"required": false, "minlength": 0, "maxlength": 10485760}
}
Field options¶
field() mirrors the editor’s Properties panel. Beyond db_type/form_type, it
exposes the full options surface:
admin.field(
"secret",
label="API Secret", # Field Title (name is the immutable API Key)
editable=True, # UI "Editable" -> writeable
searchable=False, # Field Options row...
grid_column=True, # "Default Grid Column"
encrypted=True, # "Encrypted" (mutually exclusive with searchable)
required=True, # or a condition dict for "Required by condition"
visibility=True, # or a condition dict for "Visible by Condition"
default_value="",
tooltip="Stored encrypted",
minlength=0, maxlength=1024, enable_range=True, # Length Constraints
bulk_edit=True, # "Allow Bulk Edit" -> bulkAction.allow
)
Picklist and relationship fields¶
# single- or multi-select picklist, bound to a picklist list name
admin.picklist_field("severity", "AlertSeverity", grid_column=True)
admin.picklist_field("tags", "AlertType", multi=True) # -> multiselectpicklist
# a single reference to one record of another module (many-to-one, no reverse field)
admin.lookup_field("owner", "people", label="Owner")
# a many-to-many relationship to another module (reverse field auto-created on target)
admin.relationship_field("relatedalerts", "alerts", label="Related Alerts")
Note
add_field keeps both sides of a relationship valid: it creates the reverse field on the
target when the platform won’t (the oneToMany target lookup, the custom-inverse
manyToMany mirror). Pass create_reverse=False to manage the target side yourself. See
Module & Field Schema Reference for the per-relationship rules and reverse_field() verification.
Creating a module¶
create_module posts to staging and — matching the in-product editor — also creates
the default list/detail/form layouts so the module renders in the UI. Pass
create_view_templates=False for an API-only module. The keyword flags map directly to
the editor’s Additional Settings.
admin.create_module(
"widgets",
label="Widget",
plural="Widgets",
fields=[
admin.text_field("name", required=True, grid_column=True),
admin.text_field("payload", area=True),
admin.picklist_field("severity", "AlertSeverity"),
admin.relationship_field("relatedalerts", "alerts"),
],
# Additional Settings:
ownable=True, # Team Ownable (also sets userOwnable)
trackable=True,
indexable=True,
taggable=True,
queueable=False,
recycle_bin=True, # Enable Recycle Bin -> softDeleteable
multi_tenancy=False, # Enable Multi-Tenancy -> peerReplicable
record_uniqueness=["name"], # uniqueConstraint
default_sort=[{"field": "createDate", "direction": "DESC"}],
)
# staging record -> {'uuid': '868221dc-...', 'type': 'widgets',
# 'module': 'widgets', 'displayName': '{{ name }}'}
admin.get_view_templates("widgets")
# layouts created -> ['detail', 'form', 'list']
Edit staged fields before publishing:
admin.add_field("widgets", admin.email_field("reporter"))
admin.set_field_type("widgets", "payload", db_type="object", form_type="object")
[(a["name"], a["type"], a["formType"]) for a in admin.get_staging("widgets")["attributes"]]
# [('name', 'string', 'text'), ('payload', 'object', 'object'), ('reporter', 'string', 'email')]
Editing settings on an existing module¶
set_module_settings updates the Additional Settings (and display template / sort)
of a staged module, using the same friendly names as create_module:
admin.set_module_settings(
"widgets",
taggable=False,
ownable=True, # also syncs userOwnable
recycle_bin=True, # -> softDeleteable
display_template="{{ name }}",
default_sort=[{"field": "createDate", "direction": "DESC"}],
)
Note
Auto-mirror appliances. Some builds (e.g. with the dev-mode schema toggle on)
re-sync staging_model_metadatas into model_metadatas on every write — so a staged
create or edit shows up in the “published” store immediately, and a settings PUT can
surface a sync error in its response even though the staging row updated. Because of
this, set_module_settings confirms the change by re-reading staging and only raises
if a value did not actually take. It’s also why is_published() may read True for a
module you have not explicitly published on such a box.
Tracking pending changes¶
Before an appliance-wide publish, see exactly what would be committed.
pending_changes() diffs staging against
published:
admin.pending_changes()
# [{'module': 'widgets', 'change': 'created'}]
# change is one of: 'created' | 'modified' | 'deleted'
An empty list means the appliance is fully published — nothing for publish() to do.
Publishing¶
admin.publish() # appliance-wide commit; blocks until the migrate cycle finishes
PUT /api/publish only starts the publish — its response is {"status": "started"} —
and the backup + DB migrate then runs asynchronously, during which the whole API
(/api/3) returns 503 for ~30–60s. By default publish() is synchronous: it waits out
that outage and confirms the result via /api/publish/error (a fresh last_publish_time
with status: "Success"), returning that body so you can read the published schema
immediately. It is always synchronous — during the migrate the whole appliance is down, so
there is nothing else to do but wait.
Note
Validation errors are raised synchronously, before any migrate. A field whose type
does not exist, or a oneToMany with no matching lookup on its target, comes back as an
APIError (HTTP 400) on the PUT itself — its message is the
appliance’s own (e.g. “there is no lookup field present in ‘alerts’ module”), so surface
it to the user. If the async publish fails instead, publish() raises
FortiSOARException with the status from /api/publish/error; a
publish that never reports back raises TimeoutError.
Discarding an unpublished draft¶
discard_staging_draft fires the same DELETE the editor’s Revert button uses, and
additionally cleans up the module’s view templates (which the UI’s own revert leaves
orphaned):
admin.discard_staging_draft("widgets") # -> True
admin.get_view_templates("widgets") # -> [] (cleaned up)
Danger
There is no API path to delete a published module. discard_staging_draft only
undoes an unpublished draft. If a module was ever published (its draft committed by any
publish on the appliance), the live module and its Postgres table remain, with no API to
remove them — that needs backend CLI/SQL. For a clean throwaway, never publish it;
then discarding the draft removes it entirely.