Skip to content

Tenant Administration

Day-to-day management of tenants in Mantis.

Tenant administration covers ongoing management tasks:

Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants"

Response:

{
"data": [
{
"id": "019b937d-4862-84ae-9c2a-6ac230cc4c7e",
"name": "Acme Corp",
"description": "Enterprise customer",
"contact_email": "ops@acme.com",
"created_at": "2024-01-15T10:30:00Z"
},
{
"id": "019b937d-4862-84ae-9c2a-6ac230cc4c80",
"name": "Globex Inc",
"description": "SMB customer",
"contact_email": "admin@globex.com",
"created_at": "2024-01-20T14:00:00Z"
}
],
"meta": {
"total": 2,
"page": 1,
"per_page": 20
}
}
Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID"

Response includes relationships:

{
"id": "019b937d-4862-84ae-9c2a-6ac230cc4c7e",
"name": "Acme Corp",
"description": "Enterprise customer",
"contact_email": "ops@acme.com",
"logo_url": "https://acme.com/logo.png",
"tags": [
{
"id": "019b937d-bad5-8cd6-a67d-db03dd598065",
"key": "region",
"value": "us-east"
},
{
"id": "019b937d-b403-85af-a687-afc4320b48cb",
"key": "tier",
"value": "enterprise"
}
],
"created_at": "2024-01-15T10:30:00Z",
"updated_at": "2024-02-01T09:15:00Z"
}
Terminal window
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme Corporation",
"description": "Updated enterprise customer description",
"contact_email": "newops@acme.com"
}' \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID"
FieldUpdate Behavior
nameMust remain unique
descriptionSet to null to clear
contact_emailSet to null to clear
logo_urlSet to null to clear
  1. Navigate to Tenancy → Tenants
  2. Click on the tenant name
  3. Click Edit
  4. Update fields
  5. Click Save Changes
Terminal window
# List solutions
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/solutions"
# List targets
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/targets"
# List environments
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/environments"
Terminal window
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"solution_id": "019b937d-33f5-8f92-ae77-b58f3bea15c4",
"environment_id": "019b937d-739d-8cd8-a8af-636d98857494"
}' \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/solutions"
Terminal window
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/solutions/$SOLUTION_ID/environments/$ENV_ID"

Document tenant entitlements:

TenantSolutionProductionStagingDevelopment
Acme CorpWebApp✓✓✗
Acme CorpAPI✓✓✗
Globex IncWebApp✓✗✗

Tenant variables live in variable sets. A variable takes its tenant from the set it is created in, recorded as owner_tenant_id; scope_tenant_id is a separate condition scope that no request field sets. There is no /tenants/{id}/variables endpoint.

Terminal window
# List variables in a set
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/variable-sets/$VARIABLE_SET_ID/variables"
# Update one
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"value": "https://api-v2.acme.com"}' \
"https://api.mantis.local/api/v1/variable-sets/$VARIABLE_SET_ID/variables/$VARIABLE_ID"
# Delete one
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/variable-sets/$VARIABLE_SET_ID/variables/$VARIABLE_ID"

Rotating a secret is an update with variable_type: "sensitive" — there is no is_encrypted field on the request. Reads of a sensitive variable return "********" in value, so the plaintext never leaves the database.

Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/deployments"

Response:

{
"data": [
{
"id": "019b937d-4862-8de0-1111-222233334444",
"source_type": "solution",
"solution_name": "Solution 019b937d-4862-8de0-1111-222233334444",
"state": "success",
"progress": 1.0,
"tenant_name": "Acme Corp",
"environment_name": "Production",
"created_at": "2024-02-01T10:00:00Z",
"completed_at": "2024-02-01T10:05:32Z",
"duration_ms": 332000
}
],
"meta": {
"total": 150,
"page": 1,
"per_page": 20
}
}
Terminal window
# Pagination
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/deployments?page=2&per_page=50"

State-based filtering is not currently supported on this endpoint. Use the SQL monitoring queries below for status-filtered analysis.

Track tenant health with deployment metrics:

SELECT
DATE_TRUNC('day', created_at) AS day,
COUNT(*) AS total_deployments,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successful,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
AVG(EXTRACT(EPOCH FROM (completed_at - started_at))) AS avg_duration_secs
FROM deployment_history
WHERE tenant_id = $tenant_id
AND created_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE_TRUNC('day', created_at)
ORDER BY day DESC;
Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/admin/users"

System admins see all users; a tenant-scoped caller sees users in their tenant plus tenant-less (global) users (tenant_id IS NULL), resolved by effective_tenant_scope. There is an admin-only tenant_id query parameter to narrow the listing; it is ignored for tenant-scoped callers.

The body is deny_unknown_fields and declares only email, username, display_name, role_ids and tenant_id. There is no password: the account is created in an invited state and Mandible emails an activation link. role_ids are role UUIDs.

Terminal window
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"username": "bob",
"email": "bob@acme.com",
"tenant_id": "019b937d-4862-84ae-9c2a-6ac230cc4c7e",
"role_ids": ["019b93a2-1f04-8c7e-b6d1-2a5f0c83e714"]
}' \
"https://api.mantis.local/api/v1/admin/users"

To set a password directly instead of using the emailed invitation, post to /api/v1/admin/users/{id}/password with {"new_password": "..."} — see Creating a Tenant.

Terminal window
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tenant_id": "019b937d-1f00-88a6-ab60-42b775a77452"
}' \
"https://api.mantis.local/api/v1/admin/users/$USER_ID"

Disable rather than delete for audit retention:

Terminal window
curl -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "inactive"}' \
"https://api.mantis.local/api/v1/admin/users/$USER_ID"

Audit logs are read from /api/v1/audit/logs. A tenant-scoped caller sees their tenant’s entries and none of the platform’s (tenant_id IS NULL); an admin with the admin role and no tenant sees everything. An admin-only tenant_id query parameter narrows the listing (it is ignored for tenant-scoped callers).

A tenant’s entries are the ones about what it owns, whoever acted: a platform administrator’s change to one of its resources, what a Thorax instance reports about its deployments, and a sign-in refused for one of its accounts, API keys or identity providers. A sign-in attempt that names no known account is the platform’s.

Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/audit/logs"
Terminal window
# Authentication events
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/audit/logs?category=authentication"
# Deployment events
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/audit/logs?category=deployment"
# Security events
curl -H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/audit/logs?severity=security"

audit_log_entries enforces row-level security, so the scope must be declared or the export silently produces an empty file. Use \copy from psql — server-side COPY TO <file> needs superuser or pg_write_server_files.

SET mantis.audit_tenant_id = '<tenant-uuid>';
\copy (
SELECT
occurred_at,
event_type,
event_category,
severity,
actor_id,
action,
outcome,
metadata
FROM audit_log_entries
WHERE tenant_id = '<tenant-uuid>'
AND occurred_at >= '2024-01-01'
AND occurred_at < '2024-02-01'
ORDER BY occurred_at
) TO 'audit_export.csv' WITH CSV HEADER
□ Create tenant
□ Configure description and contact
□ Attach required solutions to environments
□ Assign targets
□ Set up variables (including encrypted secrets)
□ Add tags for organization
□ Create initial users
□ Configure SSO (if applicable)
□ Document tenant configuration
□ Notify tenant of access

When a tenant needs to be temporarily disabled:

Terminal window
# Remove all solution entitlements
for SOL_ENV in $(get_tenant_solutions); do
curl -X DELETE "$API_URL/tenants/$TENANT_ID/solutions/$SOL_ENV"
done
# Disable all tenant users (valid statuses: active, inactive, locked,
# pending_verification, pending_approval)
for USER_ID in $(get_tenant_users); do
curl -X PUT -d '{"status": "inactive"}' \
"https://api.mantis.local/api/v1/admin/users/$USER_ID"
done
Terminal window
# Re-enable users
for USER_ID in $(get_tenant_users); do
curl -X PUT -d '{"status": "active"}' \
"https://api.mantis.local/api/v1/admin/users/$USER_ID"
done
# Restore entitlements
curl -X POST -d '{"solution_id": "019b937d-0299-8bb4-aafc-7415e8e3e979", "environment_id": "019b937d-7c74-8ad6-a9cc-215477a41a2f"}' \
"$API_URL/tenants/$TENANT_ID/solutions"

A solution, sequence, action or environment a tenant owns can be handed to another tenant, or made global.

Terminal window
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "sequence", "definition_id": "'"$SEQUENCE_ID"'", "destination_tenant_id": "'"$DEST_TENANT_ID"'"}' \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID/definitions/move"

kind is solution, sequence, action or environment. A destination_tenant_id of null, or none at all, makes the definition global: readable by every tenant.

A move carries what the definition is built from. Only definitions the source tenant owns are carried; a global action a sequence attaches stays global.

MovedCarries
SolutionThe sequences its versions publish, and their actions
SequenceThe actions its versions attach
ActionNothing
EnvironmentNothing

The response lists every definition whose owner changed, deepest first:

{
"moved": [
{ "kind": "action", "id": "01a03…", "name": "Restart web" },
{ "kind": "sequence", "id": "01a03…", "name": "Deploy web" }
]
}

An entitlement names a solution and an environment. The source tenant’s entitlements for a moved solution or environment go to the destination, so the new owner can deploy what it received, except that one is dropped:

  • when its other half belongs to a tenant other than the destination. That is the source tenant when the other half stayed behind: moving an environment drops the source tenant’s entitlements pairing it with the source’s own solutions, and moving a solution drops those pairing it with the source’s own environments. It is a third tenant when the entitlement paired the moved definition with that tenant’s solution or environment, which the destination was never given. A global other half is carried.
  • when the destination already holds the same pair. The destination keeps its own.

Entitlements other tenants hold are left alone. A move to global changes no entitlements: a tenant deploys a global definition by holding an entitlement for it, so the source tenant keeps the ones it has.

The move answers 409 and changes nothing when:

  • A carried definition is also used by something staying behind: a sequence that a solution outside the move publishes, or an action that a sequence outside the move attaches. The message names the definition and what uses it.

  • A carried definition is managed by a config repository. The message names the repository; move the definition in Git instead.

  • Live configuration the source tenant holds still points at what is moving: an enabled deployment schedule naming a moved solution, sequence, action or environment; an enabled deployment freeze naming a moved solution or environment (counted while it is enabled, in its window or not); or a promotion lifecycle that governs a moved solution. Only the source tenant’s rows refuse — one another tenant holds keeps resolving the definition after the move — and none is asked about for a move to global. Disable or remove it, then retry.

  • Work is in flight on what is moving, whoever is running it:

    • a promotion lifecycle with a rule promoting from or to a moved environment;
    • a pending or approved promotion request for a moved solution or into a moved environment;
    • a deployment still running a moved solution, sequence or action (its failure handler included) or into a moved environment, or a dispatch queued for a moved environment;
    • a command that has not finished with a moved action.

    Finish or cancel it, then retry.

  • For a move to another tenant, configuration held by a tenant other than the destination still names what is moving: a variable scoped to a moved solution, environment or action version; a variable set linked to a moved solution; an environment or tenant target assignment, or an active registration token, naming a moved environment; or a test gate on a carried definition or run by a carried action. Platform-wide configuration does not refuse, and none of this is asked for a move to global, which every tenant can still reach. Remove the configuration, move, and recreate what the destination needs.

  • A carried definition left the source tenant, or a new version began using one the move did not carry, while the move was being checked. Retry.

It answers 404 for a definition the source tenant does not own or a source tenant that does not exist, and 400 for a destination that is the source tenant itself or does not exist. The destination is checked before the definition and before any refusal.

The caller needs tenants:manage_solutions in the source tenant and in the destination tenant. Holding it in one does not imply the other: a move writes to the source and grants reach in the destination. Making a definition global needs the permission platform-wide.

Each move is recorded on the audit chain of the source tenant and of the destination, listing everything that moved; for a move to global the destination chain is the platform’s.

The detail page of a tenant-owned solution, sequence, action or environment has a Move to tenant button. It opens a dialog that says what moves with the definition and offers every other tenant the user can see as the destination, plus Global for a platform administrator. After the move it lists every definition whose owner changed; a refusal is shown in the dialog and nothing moves. The button is absent for a global or Git-managed definition, for a user without tenants:manage_solutions, and when there is nowhere to move it: a tenant-scoped user sees only its own tenant, so moving between tenants is a platform administrator’s task.

Before deletion:

  1. Export data - Audit logs, deployment history
  2. Notify stakeholders - Confirm deletion is authorized
  3. Disable users first - Prevent new activity
  4. Document decision - For compliance
ResourceOn Tenant Delete
Solutions, sequences, actions, environmentsCascade deleted
Solution-Environment linksCascade deleted
Target assignmentsCascade deleted
Tenant tagsCascade deleted
Audit logsPreserved
Deployment historyPreserved (tenant set to NULL)

By default the definitions the tenant owns are destroyed with it. A solution, sequence, action or environment whose tenant_id is this tenant goes with it; globally shared definitions (tenant_id NULL) and definitions owned by other tenants are untouched.

The request body can move definitions out first instead:

BodyEffect
None, or {}Every definition the tenant owns is destroyed
{"move_all_to": "<tenant id>"}Every definition and every variable the tenant owns goes to that tenant
{"move_definitions": [...]}The definitions named go where each entry says; the rest are destroyed

move_all_to and move_definitions are alternatives; sending both is a 400. Each move_definitions entry is {"kind", "definition_id", "destination_tenant_id"}, and a named definition carries what it is built from, as in a move. move_definitions does not carry variables, so a tenant that owns any is refused until they are moved or deleted. Neither form can make a definition global, and the tenant being deleted cannot be a destination.

A move during a delete follows the rules of the move endpoint: the same refusals, the same entitlement handling, and tenants:manage_solutions in the tenant being deleted and in every destination, on top of tenants:delete. A definition staying in the tenant being deleted does not count as staying behind, and the outside-tenant configuration checks do not count what it holds — those rows go with the tenant or stop its deletion themselves. Live state it holds still refuses: a schedule, freeze or lifecycle in its name has to go before the tenant can be deleted in any case. A refused move fails the whole delete, a named definition the tenant does not own answers 404, and a destination that does not exist answers 400, whether or not the tenant owns anything to send there.

The response lists how many of each kind were destroyed and every definition and variable that moved; the same are recorded on the audit entry:

{
"id": "01a03…",
"name": "acme",
"deleted_definitions": [
{ "kind": "solution", "count": 12 },
{ "kind": "action", "count": 47 }
],
"moved_definitions": [
{ "kind": "environment", "id": "01a03…", "name": "production" }
]
}

Deletion is refused (409) while the tenant still owns any user, agent registration or agent session. Those columns are ON DELETE SET NULL, so a surviving row would silently become a global one; the API names what is left rather than letting that happen.

Registration tokens are not among them: registration_tokens.tenant_id is ON DELETE CASCADE, so they are destroyed with the tenant and stop enrolling agents at once. The delete response counts them.

Deletion is also blocked (409) while a child row still holds the tenant under ON DELETE RESTRICT or NO ACTION. Those are: variable_sets, variables (by either owner_tenant_id or scope_tenant_id), deployment_schedules, deployment_freezes, promotion_lifecycles, promotion_requests, api_keys, environment_states, environment_state_history, storages_local, storages_git, storages_git_auth, and storages_s3. Remove or reassign these before deleting the tenant.

A variable the tenant owns answers with its own message: delete it, or pass move_all_to to re-own the tenant’s variables to another tenant as part of the delete.

notification_events and deployment_history do not block. Both are records rather than owned resources, so their tenant_id is ON DELETE SET NULL.

Terminal window
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID"
# Move everything the tenant owns to another tenant, then delete it
curl -X DELETE \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"move_all_to": "'"$DEST_TENANT_ID"'"}' \
"https://api.mantis.local/api/v1/tenants/$TENANT_ID"

Delete on the tenants list and on a tenant’s page opens a dialog that asks what becomes of the tenant’s definitions: Delete them with the tenant, or Move them to another tenant, which sends everything to the tenant chosen there (move_all_to) and is offered to a user holding tenants:manage_solutions. Naming definitions one by one is available through the API only. A refused delete is shown in the dialog and nothing is deleted.

There are no orphaned users to clean up, because the API refuses to delete a tenant that still has any. Reassign or remove its users, agent registrations and agent sessions first; the 409 names what is left.

The users.tenant_id foreign key is ON DELETE SET NULL, so a tenant row deleted directly in SQL would clear it and silently turn those users into tenant-less (platform-scope) accounts. That is what the API check exists to prevent — do not work around it by deleting the row by hand.

Monitor key metrics per tenant:

MetricAlert ThresholdAction
Failed deployments>3 in 1 hourInvestigate failures
Inactive users>30 daysReview user access
Variable changesUnexpectedReview audit logs
Target disconnections>1 targetCheck target health

Failed Deployments by Tenant:

SELECT
t.name AS tenant,
COUNT(*) AS failed_count
FROM deployment_history dh
JOIN tenants t ON t.id = dh.tenant_id
WHERE dh.status = 'failed'
AND dh.created_at >= NOW() - INTERVAL '24 hours'
GROUP BY t.name
ORDER BY failed_count DESC;

Active Users per Tenant:

SELECT
t.name AS tenant,
COUNT(*) AS active_users,
MAX(u.last_login_at) AS latest_login
FROM users u
JOIN tenants t ON t.id = u.tenant_id
WHERE u.status = 'active'
GROUP BY t.name;
#!/bin/bash
# Update a variable across all tenants
VARIABLE_NAME="api_version"
NEW_VALUE="v2"
# The variable set carries the tenant, so iterate the per-tenant sets rather than
# passing a tenant on the body: the request is `deny_unknown_fields` and declares
# no `key`, `scope_tenant_id` or `is_encrypted`.
#
# Updating means PUT on the variable's own id. POST to the collection CREATES, and a
# set that already holds `api_version` answers 409 — a loop of POSTs updates nothing and
# reports "Updated" for every one of them. Look the id up first; create it only where
# the set does not have it yet.
for VARIABLE_SET_ID in "${TENANT_VARIABLE_SETS[@]}"; do
VARIABLE_ID=$(curl -sf \
-H "Authorization: Bearer $TOKEN" \
"$API_URL/variable-sets/$VARIABLE_SET_ID/variables" \
| jq -r --arg n "$VARIABLE_NAME" '.data[] | select(.name == $n) | .id')
if [ -n "$VARIABLE_ID" ]; then
curl -sf -X PUT \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"value\": \"$NEW_VALUE\"}" \
"$API_URL/variable-sets/$VARIABLE_SET_ID/variables/$VARIABLE_ID" \
&& echo "Updated $VARIABLE_SET_ID"
else
curl -sf -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"name\": \"$VARIABLE_NAME\",
\"value\": \"$NEW_VALUE\",
\"variable_type\": \"string\"
}" \
"$API_URL/variable-sets/$VARIABLE_SET_ID/variables" \
&& echo "Created in $VARIABLE_SET_ID"
fi
done
#!/bin/bash
# Grant new solution to all enterprise tenants
SOLUTION_ID="sol_new_feature"
ENVIRONMENT_ID="env_production"
# Get tenants with the enterprise tag. NOTE: the LIST response rows carry no
# `tags` field (tags appear only on the per-tenant detail response), so filter
# tags client-side by fetching each tenant's detail, or filter on a field the
# list actually returns.
ENTERPRISE_TENANTS=$(curl -s "$API_URL/tenants" | jq -r '.data[].id' | while read id; do
curl -s "$API_URL/tenants/$id" | jq -r 'select(.tags[]?.value == "enterprise") | .id'
done)
for TENANT_ID in $ENTERPRISE_TENANTS; do
curl -s -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"solution_id\": \"$SOLUTION_ID\",
\"environment_id\": \"$ENVIRONMENT_ID\"
}" \
"$API_URL/tenants/$TENANT_ID/solutions"
echo "Granted to $TENANT_ID"
done
  1. Check solution entitlement:

    Terminal window
    curl "$API_URL/tenants/$TENANT_ID/solutions"
  2. Check target assignment:

    Terminal window
    curl "$API_URL/tenants/$TENANT_ID/targets"
  3. Verify variables: there is no tenant-variables endpoint (see Managing Variables). Read the tenant’s variable sets and the variables in each:

    Terminal window
    curl "$API_URL/variable-sets?tenant_id=$TENANT_ID"
    curl "$API_URL/variable-sets/$VARIABLE_SET_ID/variables"
  1. Verify user’s tenant_id:

    SELECT tenant_id FROM users WHERE id = $user_id;
  2. Check user status:

    SELECT status FROM users WHERE id = $user_id;
  3. Verify tenant exists:

    SELECT id, name FROM tenants WHERE id = $tenant_id;