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

/api/3/staging_model_metadatas

the editable draft of every module

Published

/api/3/model_metadatas

the committed schema records reads use

Physical table

/api/3/<module>

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.