diff --git a/README.md b/README.md
index 3a66772a..4c16b157 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ A Spring Boot plugin that extends [Apache Fineract](https://fineract.apache.org/
- [Deploy with Fineract (Docker)](#deploy-with-fineract-docker)
- [Deploy with Fineract (Tomcat)](#deploy-with-fineract-tomcat)
- [API Reference](#api-reference)
+- [Tenant Management](#tenant-management)
- [Contributing](#contributing)
- [History](#history)
- [Important Notices](#important-notices)
@@ -143,7 +144,7 @@ Pre-built snapshots are published to JFrog Artifactory:
## API Reference
-All endpoints live under `/v1/self/`. Here's a summary of the available resources:
+Most endpoints live under `/v1/self/`. Here's a summary of the available resources:
| Base Path | Resource | Methods |
|---|---|---|
@@ -165,10 +166,32 @@ All endpoints live under `/v1/self/`. Here's a summary of the available resource
| `/v1/self/surveys` | Surveys (SPM) | `GET` |
| `/v1/self/surveys/scorecards` | Survey Scorecards | `GET`, `POST` |
+### Administrative Endpoints
+
+These are consumed by staff-facing clients such as the web app rather than by self-service users,
+so they sit **outside** `/v1/self/`: that prefix is matched by the self-service security chain and
+authenticates self-service users only. Outside it, ordinary platform credentials apply.
+
+| Base Path | Resource | Methods |
+|---|---|---|
+| `/v1/branding` | Tenant Branding | `GET`, `PUT` |
+| `/v1/admin/tenants` | Tenant Management (master users only) — see [TENANT_MANAGEMENT.md](TENANT_MANAGEMENT.md) | `GET`, `POST`, `PUT`, `DELETE` |
+
Full OpenAPI/Swagger documentation is available at runtime via the Fineract Swagger UI when the plugin is loaded.
A [Postman collection](postman/) is also included for hands-on API exploration.
+## Tenant Management
+
+The plugin can manage the tenants of the installation itself — create, update, activate, deactivate,
+suspend and remove — with schema provisioning, an audit trail, and enforcement so a suspended tenant
+actually stops being served.
+
+Because this administers the platform itself rather than any one tenant, it runs in a separate
+master context — master users with the `SUPER_MASTER` role — and is documented separately:
+
+**→ [TENANT_MANAGEMENT.md](TENANT_MANAGEMENT.md)**
+
## Contributing
Interested in contributing? We'd love your help. See the **[Contributing Guide](CONTRIBUTING.md)** for everything you need — development setup, running tests, code style, and project structure.
diff --git a/TENANT_MANAGEMENT.md b/TENANT_MANAGEMENT.md
new file mode 100644
index 00000000..4e9e011f
--- /dev/null
+++ b/TENANT_MANAGEMENT.md
@@ -0,0 +1,280 @@
+# Tenant Management
+
+API-first lifecycle management for the tenants of a Mifos X / Apache Fineract installation:
+list, create, update, activate, deactivate, suspend and remove.
+
+Tracked as [MX-406](https://mifosforge.jira.com/browse/MX-406). The administrative UI that
+consumes these endpoints is tracked separately as WEB-1242 in the
+[web-app](https://github.com/openMF/web-app) repository, and the user guide for that UI belongs
+with it.
+
+## Contents
+
+- [Why it lives here](#why-it-lives-here)
+- [Security model](#security-model)
+- [Configuration](#configuration)
+- [Database changes](#database-changes)
+- [API reference](#api-reference)
+- [What creating a tenant actually does](#what-creating-a-tenant-actually-does)
+- [What removing a tenant does not do](#what-removing-a-tenant-does-not-do)
+- [Audit trail](#audit-trail)
+- [Operational notes](#operational-notes)
+
+## Why it lives here
+
+Apache Fineract is multi-tenant by design, but has never exposed tenant administration: the
+registry is edited with SQL scripts or an external tool. This plugin adds the API without
+requiring a fork of Fineract.
+
+Two things make that possible:
+
+- Fineract's `JerseyConfig` registers **any** Spring bean annotated `@Path`, wherever it lives, so
+ a plugin can contribute REST endpoints.
+- The registry (`tenants`, `tenant_server_connections`) lives in the **tenant store** database,
+ reachable through the `hikariTenantDataSource` bean. This feature talks to it with JDBC — a
+ Spring Data repository would be bound to the per-tenant datasource and would not find these
+ tables at all.
+
+## Security model
+
+Tenant management runs in a **master context**, above every tenant.
+
+Fineract resolves every user inside the tenant named by `Fineract-Platform-TenantId`, so any
+permission granted there — even `ALL_FUNCTIONS` — belongs to that tenant. Administering the
+tenants themselves cannot belong to any one of them. This plugin therefore adds its own security
+chain for `/v1/admin/tenants`, ordered ahead of Fineract's `/api/**` chain (the same approach the
+self-service chain uses for `/v1/self/**`), so no change to core is needed:
+
+- Requests authenticate with HTTP Basic against **master users** stored in the tenant store
+ (`tenant_master_user`), not against any tenant's users.
+- A master user must hold the **`SUPER_MASTER`** role.
+- No tenant header is needed. A tenant's own users, however privileged in their tenant, get `401`.
+- Each endpoint also checks the role itself, so a mistake in the chain's path matching fails closed.
+
+Master passwords are stored only as Spring Security delegating hashes (`{bcrypt}…`).
+
+### The first master user
+
+No API can create the first master user without already requiring one, so it comes from
+configuration at startup:
+
+```bash
+FINERACT_TENANT_MANAGEMENT_BOOTSTRAP_MASTER_USERNAME=master
+FINERACT_TENANT_MANAGEMENT_BOOTSTRAP_MASTER_PASSWORD='a long, unique password'
+```
+
+- Created once. If the user already exists nothing changes — its password is **not** reset — so a
+ lingering or edited variable can never silently overwrite a master credential.
+- Passwords shorter than 12 characters are refused and no user is created.
+- Safe when several nodes start at once: if another node creates the user first, the others
+ confirm it exists and start normally.
+- With no configuration and no master user, startup logs a warning and `/v1/admin/tenants` refuses every
+ request.
+
+Managing further master users through the API is not part of this change.
+
+Database credentials are **write-only** throughout: they are encrypted with core's
+`DatabasePasswordEncryptor`, are never selected into any projection, and are never returned by any
+endpoint.
+
+## Configuration
+
+| Property | Default | Meaning |
+|---|---|---|
+| `fineract.tenant-management.bootstrap-master-username` | *(empty)* | Master user created at startup if it does not exist. |
+| `fineract.tenant-management.bootstrap-master-password` | *(empty)* | Its password, at least 12 characters. Applied only when the user is first created. |
+| `fineract.tenant-management.migrate-on-create` | `true` | Migrate a new tenant's schema immediately. Set `false` to leave it to the next platform startup. |
+| `fineract.tenant-management.status-cache-seconds` | `30` | How long a tenant's status is cached by the enforcement filter. Also the worst-case delay before a suspension takes effect on **other** nodes in a cluster. |
+| `fineract.tenant-management.status-stale-grace-seconds` | `300` | How long past its cache expiry an `ACTIVE` status is still trusted while the tenant store cannot be read. After that the tenant is refused until the store answers. |
+| `fineract.tenant-management.plugin-changelogs` | self-service, then savings | Comma-separated plugin changelogs applied to a new tenant after core's, in order. Must use the exact `classpath:/...` strings each plugin's startup migration uses. Entries not on the classpath are skipped. Add a plugin here when you install one that owns tables. |
+
+## Database changes
+
+Applied to the **tenant store** database by `TenantManagementConfig`, after core's own upgrade.
+Core's tenant-store changelog is a flat, hard-coded list with no module extension point — unlike
+the per-tenant master — so the plugin runs its own changelog rather than appending to core's.
+
+- `tenants.status` — `ACTIVE` / `INACTIVE` / `SUSPENDED`. **Existing rows default to `ACTIVE`**, so
+ an existing installation behaves exactly as before.
+- `tenants.description`, `tenants.contact_email` — optional metadata.
+- `tenant_administration_audit` — the audit trail (below).
+- `tenant_master_user` — master users for the master context.
+- `tenant_retained_schema` — databases kept after a tenant is removed, and the identifier that owns them.
+
+Master users are stored in `tenant_master_user` in the tenant store.
+
+## API reference
+
+Base path `/v1/admin/tenants`, served by the master security chain described above: authenticate as a
+master user, and send no tenant header.
+
+| Method | Path | Role | Purpose |
+|---|---|---|---|
+| `GET` | `/v1/admin/tenants` | `SUPER_MASTER` | List, with `search`, `status`, `offset`, `limit` |
+| `GET` | `/v1/admin/tenants/template` | `SUPER_MASTER` | Selectable timezones and statuses |
+| `GET` | `/v1/admin/tenants/{id}` | `SUPER_MASTER` | One tenant |
+| `POST` | `/v1/admin/tenants` | `SUPER_MASTER` | Register and provision |
+| `PUT` | `/v1/admin/tenants/{id}` | `SUPER_MASTER` | Partial update |
+| `POST` | `/v1/admin/tenants/{id}?command=activate\|deactivate\|suspend` | `SUPER_MASTER` | Change status |
+| `DELETE` | `/v1/admin/tenants/{id}` | `SUPER_MASTER` | Remove the registry entry |
+| `POST` | `/v1/admin/tenants/test-connection` | `SUPER_MASTER` | Probe a database before committing |
+
+`search` matches identifier and name case-insensitively, and matches **literally** — a term
+containing `%` or `_` finds those characters rather than acting as a wildcard. `limit` is capped so
+one request cannot pull an entire large registry into memory.
+
+Ready-to-run requests are in
+[`api-reference/bruno`](api-reference/bruno) under **TENANT MANAGEMENT**. The requests read
+`{{master_username}}`, `{{master_password}}` and `{{tenant_db_password}}` from your Bruno
+environment instead of embedding credentials.
+
+The namespace is `/v1/admin/tenants` rather than `/v1/tenants` because core Fineract already serves
+`/v1/tenants/{tenantId}/oidc-config`; a master chain claiming `/v1/tenants/**` would capture that core
+endpoint.
+
+### OpenAPI
+
+The OpenAPI 3 description of these endpoints is
+[`api-reference/openapi/tenant-management.yaml`](api-reference/openapi/tenant-management.yaml). It is
+generated from the resource's annotations, and `TenantManagementOpenApiSpecTest` fails the build if
+the committed file drifts from them. After changing the API, regenerate it with:
+
+```bash
+./mvnw test -Dtest=TenantManagementOpenApiSpecTest -Dopenapi.update=true
+```
+
+It is published as a file rather than through Fineract's Swagger UI: that UI loads a static
+`fineract.json` produced when core itself is built, which plugin endpoints are never part of.
+
+### Create
+
+A local-development example. Supply the tenant's database password through `TENANT_DB_PASSWORD`
+rather than typing it inline; production tenants need a unique database secret.
+
+```bash
+curl -X POST http://localhost:8080/fineract-provider/api/v1/admin/tenants \
+ -H 'Content-Type: application/json' \
+ -u "$MASTER_USERNAME:$MASTER_PASSWORD" \
+ -d '{
+ "identifier": "acme",
+ "name": "Acme Microfinance",
+ "timezoneId": "Asia/Kolkata",
+ "schemaName": "mifostenant_acme",
+ "schemaServer": "localhost",
+ "schemaServerPort": "5432",
+ "schemaUsername": "postgres",
+ "schemaPassword": "'"$TENANT_DB_PASSWORD"'"
+ }'
+```
+
+`identifier` is restricted to `[a-z0-9][a-z0-9_-]*` and `schemaName` to `[A-Za-z_][A-Za-z0-9_]{0,62}`.
+These are narrow on purpose: the identifier travels in the `Fineract-Platform-TenantId` header and
+is compared on every request, and the schema name is concatenated into `CREATE DATABASE` DDL,
+which no JDBC driver allows to be bound as a parameter. The pattern — not escaping — is what makes
+that safe. It must start with a letter or underscore, because PostgreSQL rejects an unquoted
+database name that starts with a digit. It is stored in lower case: PostgreSQL folds an unquoted
+name to lower case, and the connection must use the name the database was created with. 63 is PostgreSQL's identifier limit and the shortest across supported engines.
+
+`identifier` **cannot be changed** afterwards: it is how every request selects a tenant and is
+embedded in that tenant's existing sessions and integrations. Sending one to `PUT` is rejected
+rather than silently ignored.
+
+On `PUT`, a field sent blank is refused for `name`, `timezoneId`, `schemaServer`, `schemaServerPort`
+and `schemaUsername`. For `description`, `contactEmail` and `schemaConnectionParameters`, an empty
+string (or `null`) clears the value; omitting a field leaves it unchanged.
+
+### Status
+
+Status is enforced, not merely recorded. `TenantStatusEnforcementFilter` runs ahead of the security
+chain and refuses any request addressed to a tenant that is not `ACTIVE` with **503 Service
+Unavailable** — before any credential is read.
+
+This is enforced in a filter because Fineract resolves a tenant with `where t.identifier = ?` and
+no status predicate, in both `JdbcTenantDetailsService` and the authentication path
+`AuthTenantDetailsServiceJdbc`. Without the filter the column would be decorative and a suspended
+tenant would keep serving requests.
+
+**Tenant administration itself is never blocked** by this filter: `/v1/admin/tenants` is not addressed to
+a tenant, so a UI that always sends a tenant header cannot be locked out by that tenant's suspension.
+No tenant — including `default` — is exempt from suspension.
+
+A tenant whose stored status is not one of the three (a hand edit or corruption) is also refused
+with 503, reported as `UNRECOGNISED`. The raw stored value is never echoed. The administration API returns such a tenant with `status: null`
+rather than guessing, and setting a status through the API corrects it.
+
+Status changes are idempotent: activating an already-active tenant succeeds and changes nothing, so
+a retried request does not look like a failure.
+
+## What creating a tenant actually does
+
+1. Checks the identifier is free, and that the database may be bound to it: not the tenant store
+ itself or a system database (`postgres`, `template0`, …), not already used by another registered
+ tenant (same server, port and name — servers compared as written), and not retained from a
+ removed tenant under a different identifier.
+2. Creates the schema if absent — an existing schema is **reused, never emptied**.
+3. Opens a connection to prove the credentials work.
+4. Writes the connection and tenant rows in one transaction, with the password encrypted and the
+ master password hash stamped. (Without that hash core's `TenantDataSourceFactory` refuses to
+ open the tenant, failing later at startup with a bare "Invalid master password".)
+5. Migrates the schema — core's changelog, then each plugin changelog the platform applies at
+ startup (self-service, then savings, by default) — so the new tenant ends up with the same schema
+ as one migrated at startup, and is usable immediately. A plugin changelog that isn't installed is
+ skipped. Classpath discovery isn't used: at least one module (`fineract-branch`) ships a changelog
+ that startup never applies, and discovery would give API-created tenants tables other tenants
+ lack.
+
+Steps 2 and 3 happen **before** anything is written, so a tenant that could never have worked
+leaves no row behind. If step 5 fails, the registry entry is removed again and the error is
+returned; the schema is left in place, and a retry resumes the migration rather than restarting it.
+
+## What removing a tenant does not do
+
+`DELETE` removes the **registry entry only**. It never drops a schema and never deletes tenant
+data: the database is left intact for retention, audit or reinstatement. Dropping a live financial
+database from an HTTP endpoint is not a capability this API has.
+
+The retained database stays bound to the removed tenant's identifier (`tenant_retained_schema`):
+only a tenant created again under that same identifier can reuse it, so a later tenant with a
+different identifier can never be routed to a removed tenant's data.
+
+The status check is part of the delete statement itself, so a tenant activated by a concurrent
+request after the check is not removed.
+
+An `ACTIVE` tenant is refused — deactivate it first, so removal is a deliberate two-step action
+rather than something one mistaken request can do to a tenant currently serving users.
+
+## Audit trail
+
+Every mutation writes a row to `tenant_administration_audit` in the tenant store, recording the
+action, outcome, tenant identifier, acting user, and the tenant they acted from.
+
+It lives in the tenant store rather than per-tenant so it **outlives the tenants it describes** — a
+trail that vanished along with the tenant whose deletion it recorded would be worthless — and so an
+auditor has one place to look.
+
+**No credentials are recorded.** The `detail` column holds the *names* of the fields a request
+changed, never their values, so a password rotation is recorded as having happened while the
+password itself never reaches the table.
+
+A failure to write the trail never fails the action being recorded: losing an audit row is bad, but
+rolling back a completed tenant change because its bookkeeping failed — leaving the registry
+inconsistent with what the caller was told — is worse.
+
+## Operational notes
+
+- **Clustering.** The status cache is per-node. A suspension applied on one node takes effect on
+ the others within `status-cache-seconds`. Lower it if you need suspensions to bite faster;
+ raise it to reduce reads against the tenant store.
+- **Cache eviction.** Writes evict both this plugin's status cache and core's `tenantsById` cache,
+ which holds the connection details the platform routes on. Without that, a changed database host
+ would keep routing to the old server until restart.
+- **Registry unavailable.** If the tenant store cannot be read, the filter uses the last status it
+ saw for that tenant. One last seen `ACTIVE` keeps working for up to `status-stale-grace-seconds`
+ (5 minutes) past its cache expiry, and is refused after that, since another node may have
+ suspended it meanwhile. One last seen suspended or unrecognised stays refused. A tenant with no
+ earlier status is refused with 503 (`UNAVAILABLE`).
+- **Unrecognised status.** Fails **closed** — the tenant is refused until its status is corrected.
+- **Cache bound.** Only tenants that exist are cached, so invented identifiers in request headers
+ cannot grow memory.
+- **Both engines.** MariaDB/MySQL and PostgreSQL are supported. The integration test runs against
+ real PostgreSQL; MariaDB has not yet been exercised end to end.
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/01. LIST TENANTS.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/01. LIST TENANTS.yml
new file mode 100644
index 00000000..99f4607c
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/01. LIST TENANTS.yml
@@ -0,0 +1,18 @@
+info:
+ name: 01. LIST TENANTS
+ type: http
+ seq: 1
+
+http:
+ method: GET
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants?offset=0&limit=20
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/02. LIST TENANTS FILTERED.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/02. LIST TENANTS FILTERED.yml
new file mode 100644
index 00000000..b2f1da44
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/02. LIST TENANTS FILTERED.yml
@@ -0,0 +1,18 @@
+info:
+ name: 02. LIST TENANTS FILTERED
+ type: http
+ seq: 2
+
+http:
+ method: GET
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants?search=acme&status=ACTIVE&offset=0&limit=20
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/03. GET TENANT TEMPLATE.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/03. GET TENANT TEMPLATE.yml
new file mode 100644
index 00000000..4776c231
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/03. GET TENANT TEMPLATE.yml
@@ -0,0 +1,18 @@
+info:
+ name: 03. GET TENANT TEMPLATE
+ type: http
+ seq: 3
+
+http:
+ method: GET
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants/template
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/04. GET TENANT.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/04. GET TENANT.yml
new file mode 100644
index 00000000..d3a35f7f
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/04. GET TENANT.yml
@@ -0,0 +1,18 @@
+info:
+ name: 04. GET TENANT
+ type: http
+ seq: 4
+
+http:
+ method: GET
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants/1
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/05. CREATE TENANT.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/05. CREATE TENANT.yml
new file mode 100644
index 00000000..f10a240a
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/05. CREATE TENANT.yml
@@ -0,0 +1,35 @@
+info:
+ name: 05. CREATE TENANT
+ type: http
+ seq: 5
+
+http:
+ method: POST
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants
+ body:
+ type: json
+ data: |-
+ {
+ "identifier": "acme",
+ "name": "Acme Microfinance",
+ "timezoneId": "Asia/Kolkata",
+ "status": "ACTIVE",
+ "description": "Created through the tenant management API",
+ "contactEmail": "ops@acme.example.org",
+ "schemaName": "mifostenant_acme",
+ "schemaServer": "localhost",
+ "schemaServerPort": "5432",
+ "schemaUsername": "postgres",
+ "schemaPassword": "{{tenant_db_password}}",
+ "autoUpdate": true
+ }
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/06. UPDATE TENANT.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/06. UPDATE TENANT.yml
new file mode 100644
index 00000000..0eccf8d7
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/06. UPDATE TENANT.yml
@@ -0,0 +1,25 @@
+info:
+ name: 06. UPDATE TENANT
+ type: http
+ seq: 6
+
+http:
+ method: PUT
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants/1
+ body:
+ type: json
+ data: |-
+ {
+ "name": "Acme Microfinance Ltd",
+ "contactEmail": "newops@acme.example.org"
+ }
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/07. CHANGE TENANT STATUS.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/07. CHANGE TENANT STATUS.yml
new file mode 100644
index 00000000..3fecbd34
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/07. CHANGE TENANT STATUS.yml
@@ -0,0 +1,22 @@
+info:
+ name: 07. CHANGE TENANT STATUS
+ type: http
+ seq: 7
+
+http:
+ method: POST
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants/1?command=suspend
+ body:
+ type: json
+ data: |-
+ {}
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/08. TEST TENANT CONNECTION.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/08. TEST TENANT CONNECTION.yml
new file mode 100644
index 00000000..a1291961
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/08. TEST TENANT CONNECTION.yml
@@ -0,0 +1,28 @@
+info:
+ name: 08. TEST TENANT CONNECTION
+ type: http
+ seq: 8
+
+http:
+ method: POST
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants/test-connection
+ body:
+ type: json
+ data: |-
+ {
+ "schemaName": "mifostenant_acme",
+ "schemaServer": "localhost",
+ "schemaServerPort": "5432",
+ "schemaUsername": "postgres",
+ "schemaPassword": "{{tenant_db_password}}"
+ }
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/09. REMOVE TENANT.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/09. REMOVE TENANT.yml
new file mode 100644
index 00000000..dd0793c7
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/09. REMOVE TENANT.yml
@@ -0,0 +1,18 @@
+info:
+ name: 09. REMOVE TENANT
+ type: http
+ seq: 9
+
+http:
+ method: DELETE
+ url: http://localhost:8080/fineract-provider/api/v1/admin/tenants/1
+ auth:
+ type: basic
+ username: "{{master_username}}"
+ password: "{{master_password}}"
+
+settings:
+ encodeUrl: true
+ timeout: 0
+ followRedirects: true
+ maxRedirects: 5
diff --git a/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/folder.yml b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/folder.yml
new file mode 100644
index 00000000..8e1d805d
--- /dev/null
+++ b/api-reference/bruno/SELF SERVICE PLUGIN/SELF SERVICE (MOBILE OR WEB BANKING) - LOCALHOST/TENANT MANAGEMENT/folder.yml
@@ -0,0 +1,7 @@
+info:
+ name: TENANT MANAGEMENT
+ type: folder
+ seq: 11
+
+request:
+ auth: inherit
diff --git a/api-reference/openapi/tenant-management.yaml b/api-reference/openapi/tenant-management.yaml
new file mode 100644
index 00000000..4c6c14a1
--- /dev/null
+++ b/api-reference/openapi/tenant-management.yaml
@@ -0,0 +1,521 @@
+openapi: 3.0.1
+info:
+ title: Fineract Tenant Management API
+ description: "Tenant lifecycle administration provided by the Mifos self-service\
+ \ plugin (MX-406). Served in a master context: authenticate as a master user holding\
+ \ the SUPER_MASTER role, with no tenant header."
+ version: "1.0"
+servers:
+- url: /fineract-provider/api
+ description: Apache Fineract with the self-service plugin loaded
+security:
+- masterBasicAuth: []
+tags:
+- name: Tenant Management
+ description: Lifecycle management of the tenants on this installation. Requires
+ a master user with the SUPER_MASTER role; database credentials are write-only
+ and are never returned.
+paths:
+ /v1/admin/tenants:
+ get:
+ tags:
+ - Tenant Management
+ summary: List Tenants
+ description: |-
+ Returns the tenants on this installation, newest registry entries last.
+
+ Optional `search` matches the identifier and the name, case insensitively and literally. Optional `status` restricts to ACTIVE, INACTIVE or SUSPENDED. `offset` and `limit` page the result; `limit` is capped so one request cannot return an entire large registry.
+
+ Database credentials are never included.
+ operationId: retrieveAll
+ parameters:
+ - name: search
+ in: query
+ description: "Matches identifier and name, literally and case-insensitively"
+ schema:
+ type: string
+ - name: status
+ in: query
+ description: "ACTIVE, INACTIVE or SUSPENDED"
+ schema:
+ type: string
+ - name: offset
+ in: query
+ schema:
+ type: integer
+ format: int32
+ - name: limit
+ in: query
+ schema:
+ type: integer
+ format: int32
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetTenantsResponse"
+ "400":
+ description: Validation failed
+ "401":
+ description: Not authenticated as a master user
+ post:
+ tags:
+ - Tenant Management
+ summary: Create a Tenant
+ description: |-
+ Registers a tenant, creates its schema and migrates it.
+
+ The schema is created and proved reachable before anything is written to the registry, so a tenant that could never have worked leaves no row behind. An existing schema is reused rather than rejected, and is never emptied.
+
+ `schemaPassword` is stored encrypted and is never returned by any endpoint.
+ operationId: create
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PostTenantsRequest"
+ required: true
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetTenantResponse"
+ "400":
+ description: Validation failed
+ "401":
+ description: Not authenticated as a master user
+ "403":
+ description: Refused by a domain rule
+ /v1/admin/tenants/template:
+ get:
+ tags:
+ - Tenant Management
+ summary: Retrieve Tenant Template
+ description: "Returns the selectable time zones and lifecycle statuses, so a\
+ \ client never hardcodes a list the backend owns."
+ operationId: retrieveTemplate
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetTenantsTemplateResponse"
+ "401":
+ description: Not authenticated as a master user
+ /v1/admin/tenants/test-connection:
+ post:
+ tags:
+ - Tenant Management
+ summary: Test a Tenant Database Connection
+ description: |-
+ Opens a connection with the supplied details and reports whether it succeeded, so an administrator can check credentials before committing a tenant.
+
+ Returns only whether the database answered. The driver's own error is written to the server log rather than returned, since those messages routinely echo the connection string and user back.
+ operationId: testConnection
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PostTestConnectionRequest"
+ required: true
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PostTestConnectionResponse"
+ "400":
+ description: Validation failed
+ "401":
+ description: Not authenticated as a master user
+ /v1/admin/tenants/{id}:
+ get:
+ tags:
+ - Tenant Management
+ summary: Retrieve a Tenant
+ description: Returns one tenant. Database credentials are never included.
+ operationId: retrieveOne
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetTenantResponse"
+ "401":
+ description: Not authenticated as a master user
+ "404":
+ description: No tenant has this id
+ put:
+ tags:
+ - Tenant Management
+ summary: Update a Tenant
+ description: |-
+ Updates a tenant. Every field is optional; omitting one leaves it unchanged, and omitting `schemaPassword` keeps the stored credential.
+
+ `identifier` cannot be changed: it is how every request selects a tenant and is embedded in that tenant's existing sessions and integrations. Sending one is rejected rather than ignored.
+ operationId: update
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/PutTenantsRequest"
+ required: true
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetTenantResponse"
+ "400":
+ description: Validation failed
+ "401":
+ description: Not authenticated as a master user
+ "403":
+ description: Refused by a domain rule
+ "404":
+ description: No tenant has this id
+ post:
+ tags:
+ - Tenant Management
+ summary: Change Tenant Status
+ description: |-
+ Activates, deactivates or suspends a tenant, selected with the `command` query parameter.
+
+ Idempotent: issuing a command a tenant is already in succeeds and changes nothing, so a retried request does not look like a failure.
+ operationId: changeStatus
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ - name: command
+ in: query
+ description: "activate, deactivate or suspend"
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/GetTenantResponse"
+ "400":
+ description: Validation failed
+ "401":
+ description: Not authenticated as a master user
+ "404":
+ description: No tenant has this id
+ delete:
+ tags:
+ - Tenant Management
+ summary: Remove a Tenant
+ description: |-
+ Removes the tenant's registry entry so the platform stops routing to it.
+
+ This never drops a schema or deletes tenant data: the database is left intact for retention, audit or reinstatement.
+
+ An active tenant is refused; deactivate it first, so removal is a deliberate two-step action.
+ operationId: delete
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema:
+ type: integer
+ format: int64
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/DeleteTenantResponse"
+ "401":
+ description: Not authenticated as a master user
+ "403":
+ description: Refused by a domain rule
+ "404":
+ description: No tenant has this id
+components:
+ schemas:
+ DeleteTenantResponse:
+ type: object
+ properties:
+ resourceId:
+ type: integer
+ format: int64
+ example: 3
+ description: DeleteTenantsResponse
+ GetTenantConnectionResponse:
+ type: object
+ properties:
+ autoUpdate:
+ type: boolean
+ example: true
+ id:
+ type: integer
+ format: int64
+ example: 3
+ schemaConnectionParameters:
+ type: string
+ example: sslmode=require
+ schemaName:
+ type: string
+ example: mifostenant_acme
+ schemaServer:
+ type: string
+ example: localhost
+ schemaServerPort:
+ type: string
+ example: "5432"
+ schemaUsername:
+ type: string
+ example: fineract
+ description: GetTenantConnectionResponse - never includes a password
+ GetTenantResponse:
+ type: object
+ properties:
+ connection:
+ $ref: "#/components/schemas/GetTenantConnectionResponse"
+ contactEmail:
+ type: string
+ example: ops@acme.example.org
+ createdDate:
+ type: string
+ example: 2026-09-14T10:15:30Z
+ description:
+ type: string
+ example: Pilot tenant
+ id:
+ type: integer
+ format: int64
+ example: 3
+ identifier:
+ type: string
+ example: acme
+ joinedDate:
+ type: array
+ description: "Year, month, day"
+ example:
+ - 2026
+ - 9
+ - 14
+ items:
+ type: integer
+ description: "Year, month, day"
+ format: int32
+ lastModifiedDate:
+ type: string
+ example: 2026-09-14T11:02:00Z
+ name:
+ type: string
+ example: Acme Microfinance
+ status:
+ type: string
+ description: null when the stored status is not one this plugin recognises;
+ such a tenant is refused until its status is set again
+ nullable: true
+ example: ACTIVE
+ enum:
+ - ACTIVE
+ - INACTIVE
+ - SUSPENDED
+ timezoneId:
+ type: string
+ example: Asia/Kolkata
+ description: GetTenantResponse
+ GetTenantsResponse:
+ type: object
+ properties:
+ pageItems:
+ type: array
+ items:
+ $ref: "#/components/schemas/GetTenantResponse"
+ totalFilteredRecords:
+ type: integer
+ format: int32
+ example: 1
+ description: GetTenantsResponse
+ GetTenantsTemplateResponse:
+ type: object
+ properties:
+ statuses:
+ type: array
+ example:
+ - ACTIVE
+ - INACTIVE
+ - SUSPENDED
+ items:
+ type: string
+ example: "[\"ACTIVE\",\"INACTIVE\",\"SUSPENDED\"]"
+ timezones:
+ type: array
+ example:
+ - Africa/Abidjan
+ - Asia/Kolkata
+ items:
+ type: string
+ example: "[\"Africa/Abidjan\",\"Asia/Kolkata\"]"
+ description: GetTenantsTemplateResponse
+ PostTenantsRequest:
+ required:
+ - identifier
+ - name
+ - schemaName
+ - schemaPassword
+ - schemaServer
+ - schemaServerPort
+ - schemaUsername
+ - timezoneId
+ type: object
+ properties:
+ autoUpdate:
+ type: boolean
+ description: Migrate the schema on startup; defaults to true
+ example: true
+ contactEmail:
+ type: string
+ example: ops@acme.example.org
+ description:
+ type: string
+ example: Pilot tenant
+ identifier:
+ type: string
+ description: "Unique, lower case; cannot be changed later"
+ example: acme
+ name:
+ type: string
+ example: Acme Microfinance
+ schemaConnectionParameters:
+ type: string
+ example: sslmode=require
+ schemaName:
+ type: string
+ description: "Letters, digits and underscore, starting with a letter or\
+ \ underscore, at most 63 characters, stored in lower case. Created if\
+ \ absent; must not belong to another tenant."
+ example: mifostenant_acme
+ schemaPassword:
+ type: string
+ description: "Write-only: stored encrypted and never returned by any endpoint"
+ example: a-unique-database-secret
+ schemaServer:
+ type: string
+ example: localhost
+ schemaServerPort:
+ type: string
+ description: 1-65535
+ example: "5432"
+ schemaUsername:
+ type: string
+ example: fineract
+ status:
+ type: string
+ description: "ACTIVE, INACTIVE or SUSPENDED; defaults to ACTIVE"
+ example: ACTIVE
+ timezoneId:
+ type: string
+ description: IANA time zone
+ example: Asia/Kolkata
+ description: PostTenantsRequest
+ PostTestConnectionRequest:
+ required:
+ - schemaName
+ - schemaPassword
+ - schemaServer
+ - schemaServerPort
+ - schemaUsername
+ type: object
+ properties:
+ schemaConnectionParameters:
+ type: string
+ example: sslmode=require
+ schemaName:
+ type: string
+ example: mifostenant_acme
+ schemaPassword:
+ type: string
+ description: Used for this probe only; never stored
+ schemaServer:
+ type: string
+ example: localhost
+ schemaServerPort:
+ type: string
+ example: "5432"
+ schemaUsername:
+ type: string
+ example: fineract
+ description: PostTenantsTestConnectionRequest
+ PostTestConnectionResponse:
+ type: object
+ properties:
+ reachable:
+ type: boolean
+ example: true
+ description: PostTenantsTestConnectionResponse
+ PutTenantsRequest:
+ type: object
+ properties:
+ autoUpdate:
+ type: boolean
+ example: true
+ contactEmail:
+ type: string
+ example: newops@acme.example.org
+ description:
+ type: string
+ name:
+ type: string
+ example: Acme Microfinance Ltd
+ schemaConnectionParameters:
+ type: string
+ example: sslmode=require
+ schemaPassword:
+ type: string
+ description: Write-only; omit to keep the stored password
+ schemaServer:
+ type: string
+ example: db.internal
+ schemaServerPort:
+ type: string
+ example: "5432"
+ schemaUsername:
+ type: string
+ example: fineract
+ timezoneId:
+ type: string
+ example: Asia/Kolkata
+ description: "PutTenantsRequest - every field optional. Omitted fields are unchanged;\
+ \ blank values are refused except for description, contactEmail and schemaConnectionParameters,\
+ \ which an empty string or null clears."
+ securitySchemes:
+ masterBasicAuth:
+ type: http
+ description: A master user holding the SUPER_MASTER role
+ scheme: basic
diff --git a/src/main/java/org/apache/fineract/tenant/api/TenantManagementApiResource.java b/src/main/java/org/apache/fineract/tenant/api/TenantManagementApiResource.java
new file mode 100644
index 00000000..16b12f1f
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/api/TenantManagementApiResource.java
@@ -0,0 +1,367 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.api;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.parameters.RequestBody;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.POST;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Path;
+import jakarta.ws.rs.PathParam;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
+import jakarta.ws.rs.core.MediaType;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException;
+import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
+import org.apache.fineract.infrastructure.core.service.Page;
+import org.apache.fineract.tenant.data.TenantConnectionTestRequest;
+import org.apache.fineract.tenant.data.TenantCreateRequest;
+import org.apache.fineract.tenant.data.TenantData;
+import org.apache.fineract.tenant.data.TenantManagementDataValidator;
+import org.apache.fineract.tenant.data.TenantUpdateRequest;
+import org.apache.fineract.tenant.domain.TenantStatus;
+import org.apache.fineract.tenant.security.TenantMasterAccess;
+import org.apache.fineract.tenant.service.TenantManagementReadService;
+import org.apache.fineract.tenant.service.TenantManagementWriteService;
+import org.apache.fineract.tenant.service.TenantProvisioningService;
+import org.springframework.stereotype.Component;
+
+/**
+ * Administration of the tenants on this installation, under {@code /v1/admin/tenants}.
+ *
+ *
Served in the master context: requests authenticate as a master user from the tenant store and
+ * must hold the {@code SUPER_MASTER} role - see {@code TenantManagementSecurityConfiguration}. No
+ * tenant user, however privileged inside its own tenant, can call these endpoints, and no {@code
+ * Fineract-Platform-TenantId} header is needed.
+ *
+ *
The chain enforces the role before a request arrives here. Each method checks it again through
+ * {@link TenantMasterAccess#requireSuperMaster()}, so a mistake in the chain's path matching fails
+ * closed instead of exposing tenant administration.
+ */
+@Path("/v1/admin/tenants")
+@Component
+@Tag(
+ name = "Tenant Management",
+ description =
+ "Lifecycle management of the tenants on this installation. Requires a master user with the"
+ + " SUPER_MASTER role; database credentials are write-only and are never returned.")
+@RequiredArgsConstructor
+public class TenantManagementApiResource {
+
+ private static final String COMMAND_ACTIVATE = "activate";
+ private static final String COMMAND_DEACTIVATE = "deactivate";
+ private static final String COMMAND_SUSPEND = "suspend";
+
+ private final TenantManagementReadService readService;
+ private final TenantManagementWriteService writeService;
+ private final TenantProvisioningService provisioningService;
+ private final TenantManagementDataValidator validator;
+ private final DefaultToApiJsonSerializer toApiJsonSerializer;
+
+ /** Lists tenants, optionally filtered and paged. */
+ @GET
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "List Tenants",
+ description =
+ "Returns the tenants on this installation, newest registry entries last.\n\n"
+ + "Optional `search` matches the identifier and the name, case insensitively and"
+ + " literally. Optional `status` restricts to ACTIVE, INACTIVE or SUSPENDED. `offset`"
+ + " and `limit` page the result; `limit` is capped so one request cannot return an"
+ + " entire large registry.\n\n"
+ + "Database credentials are never included.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation =
+ TenantManagementApiResourceSwagger.GetTenantsResponse.class)))
+ @ApiResponse(responseCode = "400", description = "Validation failed")
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ public String retrieveAll(
+ @QueryParam("search")
+ @Parameter(description = "Matches identifier and name, literally and case-insensitively")
+ final String search,
+ @QueryParam("status") @Parameter(description = "ACTIVE, INACTIVE or SUSPENDED")
+ final String status,
+ @QueryParam("offset") final Integer offset,
+ @QueryParam("limit") final Integer limit) {
+
+ TenantMasterAccess.requireSuperMaster();
+
+ final TenantStatus statusFilter =
+ status == null || status.isBlank()
+ ? null
+ : TenantStatus.fromString(status)
+ .orElseThrow(
+ () ->
+ new UnrecognizedQueryParamException(
+ "status", status, TenantStatus.names().toArray()));
+
+ final Page tenants = readService.retrieveAll(search, statusFilter, offset, limit);
+ return toApiJsonSerializer.serialize(tenants);
+ }
+
+ /** Options an administration client needs to build its create and edit forms. */
+ @GET
+ @Path("/template")
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Retrieve Tenant Template",
+ description =
+ "Returns the selectable time zones and lifecycle statuses, so a client never hardcodes a"
+ + " list the backend owns.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation =
+ TenantManagementApiResourceSwagger.GetTenantsTemplateResponse.class)))
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ public String retrieveTemplate() {
+ TenantMasterAccess.requireSuperMaster();
+ return toApiJsonSerializer.serialize(readService.retrieveTemplate());
+ }
+
+ /** Retrieves a single tenant. */
+ @GET
+ @Path("/{id}")
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Retrieve a Tenant",
+ description = "Returns one tenant. Database credentials are never included.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation = TenantManagementApiResourceSwagger.GetTenantResponse.class)))
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ @ApiResponse(responseCode = "404", description = "No tenant has this id")
+ public String retrieveOne(@PathParam("id") final Long id) {
+ TenantMasterAccess.requireSuperMaster();
+ return toApiJsonSerializer.serialize(readService.retrieveOne(id));
+ }
+
+ /** Registers a new tenant and provisions its schema. */
+ @POST
+ @Consumes({MediaType.APPLICATION_JSON})
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Create a Tenant",
+ description =
+ "Registers a tenant, creates its schema and migrates it.\n\n"
+ + "The schema is created and proved reachable before anything is written to the"
+ + " registry, so a tenant that could never have worked leaves no row behind. An"
+ + " existing schema is reused rather than rejected, and is never emptied.\n\n"
+ + "`schemaPassword` is stored encrypted and is never returned by any endpoint.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation = TenantManagementApiResourceSwagger.GetTenantResponse.class)))
+ @ApiResponse(responseCode = "400", description = "Validation failed")
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ @ApiResponse(responseCode = "403", description = "Refused by a domain rule")
+ @RequestBody(
+ required = true,
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation =
+ TenantManagementApiResourceSwagger.PostTenantsRequest.class)))
+ public String create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
+ TenantMasterAccess.requireSuperMaster();
+ final TenantCreateRequest request = validator.validateForCreate(apiRequestBodyAsJson);
+ return toApiJsonSerializer.serialize(writeService.create(request));
+ }
+
+ /** Applies a partial update to a tenant. */
+ @PUT
+ @Path("/{id}")
+ @Consumes({MediaType.APPLICATION_JSON})
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Update a Tenant",
+ description =
+ "Updates a tenant. Every field is optional; omitting one leaves it unchanged, and"
+ + " omitting `schemaPassword` keeps the stored credential.\n\n"
+ + "`identifier` cannot be changed: it is how every request selects a tenant and is"
+ + " embedded in that tenant's existing sessions and integrations. Sending one is"
+ + " rejected rather than ignored.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation = TenantManagementApiResourceSwagger.GetTenantResponse.class)))
+ @ApiResponse(responseCode = "400", description = "Validation failed")
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ @ApiResponse(responseCode = "403", description = "Refused by a domain rule")
+ @ApiResponse(responseCode = "404", description = "No tenant has this id")
+ @RequestBody(
+ required = true,
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation = TenantManagementApiResourceSwagger.PutTenantsRequest.class)))
+ public String update(
+ @PathParam("id") final Long id, @Parameter(hidden = true) final String apiRequestBodyAsJson) {
+ TenantMasterAccess.requireSuperMaster();
+ final TenantUpdateRequest request = validator.validateForUpdate(apiRequestBodyAsJson);
+ return toApiJsonSerializer.serialize(writeService.update(id, request));
+ }
+
+ /** Moves a tenant between lifecycle states. */
+ @POST
+ @Path("/{id}")
+ @Consumes({MediaType.APPLICATION_JSON})
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Change Tenant Status",
+ description =
+ "Activates, deactivates or suspends a tenant, selected with the `command` query"
+ + " parameter.\n\n"
+ + "Idempotent: issuing a command a tenant is already in succeeds and changes"
+ + " nothing, so a retried request does not look like a failure.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation = TenantManagementApiResourceSwagger.GetTenantResponse.class)))
+ @ApiResponse(responseCode = "400", description = "Validation failed")
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ @ApiResponse(responseCode = "404", description = "No tenant has this id")
+ public String changeStatus(
+ @PathParam("id") final Long id,
+ @QueryParam("command")
+ @Parameter(description = "activate, deactivate or suspend", required = true)
+ final String command) {
+
+ TenantMasterAccess.requireSuperMaster();
+
+ final TenantStatus target =
+ switch (command == null ? "" : command) {
+ case COMMAND_ACTIVATE -> TenantStatus.ACTIVE;
+ case COMMAND_DEACTIVATE -> TenantStatus.INACTIVE;
+ case COMMAND_SUSPEND -> TenantStatus.SUSPENDED;
+ default ->
+ throw new UnrecognizedQueryParamException(
+ "command", command, COMMAND_ACTIVATE, COMMAND_DEACTIVATE, COMMAND_SUSPEND);
+ };
+
+ return toApiJsonSerializer.serialize(writeService.changeStatus(id, target));
+ }
+
+ /** Removes a tenant from the registry, leaving its data intact. */
+ @DELETE
+ @Path("/{id}")
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Remove a Tenant",
+ description =
+ "Removes the tenant's registry entry so the platform stops routing to it.\n\n"
+ + "This never drops a schema or deletes tenant data: the database is left intact for"
+ + " retention, audit or reinstatement.\n\n"
+ + "An active tenant is refused; deactivate it first, so removal is a deliberate"
+ + " two-step action.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation =
+ TenantManagementApiResourceSwagger.DeleteTenantResponse.class)))
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ @ApiResponse(responseCode = "403", description = "Refused by a domain rule")
+ @ApiResponse(responseCode = "404", description = "No tenant has this id")
+ public String delete(@PathParam("id") final Long id) {
+ TenantMasterAccess.requireSuperMaster();
+ writeService.delete(id);
+ return toApiJsonSerializer.serialize(Map.of("resourceId", id));
+ }
+
+ /** Probes a database without registering anything. */
+ @POST
+ @Path("/test-connection")
+ @Consumes({MediaType.APPLICATION_JSON})
+ @Produces({MediaType.APPLICATION_JSON})
+ @Operation(
+ summary = "Test a Tenant Database Connection",
+ description =
+ "Opens a connection with the supplied details and reports whether it succeeded, so an"
+ + " administrator can check credentials before committing a tenant.\n\n"
+ + "Returns only whether the database answered. The driver's own error is written to"
+ + " the server log rather than returned, since those messages routinely echo the"
+ + " connection string and user back.")
+ @ApiResponse(
+ responseCode = "200",
+ description = "OK",
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation =
+ TenantManagementApiResourceSwagger.PostTestConnectionResponse.class)))
+ @ApiResponse(responseCode = "400", description = "Validation failed")
+ @ApiResponse(responseCode = "401", description = "Not authenticated as a master user")
+ @RequestBody(
+ required = true,
+ content =
+ @Content(
+ schema =
+ @Schema(
+ implementation =
+ TenantManagementApiResourceSwagger.PostTestConnectionRequest.class)))
+ public String testConnection(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
+ TenantMasterAccess.requireSuperMaster();
+
+ final TenantConnectionTestRequest request =
+ validator.validateForConnectionTest(apiRequestBodyAsJson);
+
+ final boolean reachable =
+ provisioningService.isReachable(
+ request.schemaServer(),
+ request.schemaServerPort(),
+ request.schemaName(),
+ request.schemaConnectionParameters(),
+ request.schemaUsername(),
+ request.schemaPassword());
+
+ return toApiJsonSerializer.serialize(Map.of("reachable", reachable));
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/api/TenantManagementApiResourceSwagger.java b/src/main/java/org/apache/fineract/tenant/api/TenantManagementApiResourceSwagger.java
new file mode 100644
index 00000000..e0204f07
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/api/TenantManagementApiResourceSwagger.java
@@ -0,0 +1,251 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.api;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+
+/**
+ * Request and response shapes for the OpenAPI description of {@link TenantManagementApiResource}.
+ *
+ *
Documentation only, following Fineract's {@code *ApiResourceSwagger} convention. The resource
+ * parses JSON through {@code FromJsonHelper} and serialises with Gson, so these classes describe
+ * the wire format without taking part in it.
+ */
+final class TenantManagementApiResourceSwagger {
+
+ private TenantManagementApiResourceSwagger() {}
+
+ @Schema(description = "PostTenantsRequest")
+ static final class PostTenantsRequest {
+ private PostTenantsRequest() {}
+
+ @Schema(
+ requiredMode = Schema.RequiredMode.REQUIRED,
+ example = "acme",
+ description = "Unique, lower case; cannot be changed later")
+ public String identifier;
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "Acme Microfinance")
+ public String name;
+
+ @Schema(
+ requiredMode = Schema.RequiredMode.REQUIRED,
+ example = "Asia/Kolkata",
+ description = "IANA time zone")
+ public String timezoneId;
+
+ @Schema(example = "ACTIVE", description = "ACTIVE, INACTIVE or SUSPENDED; defaults to ACTIVE")
+ public String status;
+
+ @Schema(example = "Pilot tenant")
+ public String description;
+
+ @Schema(example = "ops@acme.example.org")
+ public String contactEmail;
+
+ @Schema(
+ requiredMode = Schema.RequiredMode.REQUIRED,
+ example = "mifostenant_acme",
+ description =
+ "Letters, digits and underscore, starting with a letter or underscore, at most 63"
+ + " characters, stored in lower case. Created if absent; must not belong to another"
+ + " tenant.")
+ public String schemaName;
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "localhost")
+ public String schemaServer;
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "5432", description = "1-65535")
+ public String schemaServerPort;
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "fineract")
+ public String schemaUsername;
+
+ @Schema(
+ requiredMode = Schema.RequiredMode.REQUIRED,
+ example = "a-unique-database-secret",
+ description = "Write-only: stored encrypted and never returned by any endpoint")
+ public String schemaPassword;
+
+ @Schema(example = "sslmode=require")
+ public String schemaConnectionParameters;
+
+ @Schema(example = "true", description = "Migrate the schema on startup; defaults to true")
+ public Boolean autoUpdate;
+ }
+
+ @Schema(
+ description =
+ "PutTenantsRequest - every field optional. Omitted fields are unchanged; blank values are"
+ + " refused except for description, contactEmail and schemaConnectionParameters,"
+ + " which an empty string or null clears.")
+ static final class PutTenantsRequest {
+ private PutTenantsRequest() {}
+
+ @Schema(example = "Acme Microfinance Ltd")
+ public String name;
+
+ @Schema(example = "Asia/Kolkata")
+ public String timezoneId;
+
+ @Schema(example = "")
+ public String description;
+
+ @Schema(example = "newops@acme.example.org")
+ public String contactEmail;
+
+ @Schema(example = "db.internal")
+ public String schemaServer;
+
+ @Schema(example = "5432")
+ public String schemaServerPort;
+
+ @Schema(example = "fineract")
+ public String schemaUsername;
+
+ @Schema(description = "Write-only; omit to keep the stored password")
+ public String schemaPassword;
+
+ @Schema(example = "sslmode=require")
+ public String schemaConnectionParameters;
+
+ @Schema(example = "true")
+ public Boolean autoUpdate;
+ }
+
+ @Schema(description = "PostTenantsTestConnectionRequest")
+ static final class PostTestConnectionRequest {
+ private PostTestConnectionRequest() {}
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "mifostenant_acme")
+ public String schemaName;
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "localhost")
+ public String schemaServer;
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "5432")
+ public String schemaServerPort;
+
+ @Schema(requiredMode = Schema.RequiredMode.REQUIRED, example = "fineract")
+ public String schemaUsername;
+
+ @Schema(
+ requiredMode = Schema.RequiredMode.REQUIRED,
+ description = "Used for this probe only; never stored")
+ public String schemaPassword;
+
+ @Schema(example = "sslmode=require")
+ public String schemaConnectionParameters;
+ }
+
+ @Schema(description = "GetTenantConnectionResponse - never includes a password")
+ static final class GetTenantConnectionResponse {
+ private GetTenantConnectionResponse() {}
+
+ @Schema(example = "3")
+ public Long id;
+
+ @Schema(example = "mifostenant_acme")
+ public String schemaName;
+
+ @Schema(example = "localhost")
+ public String schemaServer;
+
+ @Schema(example = "5432")
+ public String schemaServerPort;
+
+ @Schema(example = "fineract")
+ public String schemaUsername;
+
+ @Schema(example = "sslmode=require")
+ public String schemaConnectionParameters;
+
+ @Schema(example = "true")
+ public Boolean autoUpdate;
+ }
+
+ @Schema(description = "GetTenantResponse")
+ static final class GetTenantResponse {
+ private GetTenantResponse() {}
+
+ @Schema(example = "3")
+ public Long id;
+
+ @Schema(example = "acme")
+ public String identifier;
+
+ @Schema(example = "Acme Microfinance")
+ public String name;
+
+ @Schema(example = "Asia/Kolkata")
+ public String timezoneId;
+
+ @Schema(
+ example = "ACTIVE",
+ nullable = true,
+ allowableValues = {"ACTIVE", "INACTIVE", "SUSPENDED"},
+ description =
+ "null when the stored status is not one this plugin recognises; such a tenant is"
+ + " refused until its status is set again")
+ public String status;
+
+ @Schema(example = "Pilot tenant")
+ public String description;
+
+ @Schema(example = "ops@acme.example.org")
+ public String contactEmail;
+
+ @Schema(example = "[2026, 9, 14]", description = "Year, month, day")
+ public List joinedDate;
+
+ @Schema(example = "2026-09-14T10:15:30Z")
+ public String createdDate;
+
+ @Schema(example = "2026-09-14T11:02:00Z")
+ public String lastModifiedDate;
+
+ public GetTenantConnectionResponse connection;
+ }
+
+ @Schema(description = "GetTenantsResponse")
+ static final class GetTenantsResponse {
+ private GetTenantsResponse() {}
+
+ @Schema(example = "1")
+ public Integer totalFilteredRecords;
+
+ public List pageItems;
+ }
+
+ @Schema(description = "GetTenantsTemplateResponse")
+ static final class GetTenantsTemplateResponse {
+ private GetTenantsTemplateResponse() {}
+
+ @Schema(example = "[\"Africa/Abidjan\", \"Asia/Kolkata\"]")
+ public List timezones;
+
+ @Schema(example = "[\"ACTIVE\", \"INACTIVE\", \"SUSPENDED\"]")
+ public List statuses;
+ }
+
+ @Schema(description = "DeleteTenantsResponse")
+ static final class DeleteTenantResponse {
+ private DeleteTenantResponse() {}
+
+ @Schema(example = "3")
+ public Long resourceId;
+ }
+
+ @Schema(description = "PostTenantsTestConnectionResponse")
+ static final class PostTestConnectionResponse {
+ private PostTestConnectionResponse() {}
+
+ @Schema(example = "true")
+ public Boolean reachable;
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/config/TenantManagementConfig.java b/src/main/java/org/apache/fineract/tenant/config/TenantManagementConfig.java
new file mode 100644
index 00000000..c2631246
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/config/TenantManagementConfig.java
@@ -0,0 +1,130 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.config;
+
+import javax.sql.DataSource;
+import liquibase.integration.spring.SpringLiquibase;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.tenant.filter.TenantStatusEnforcementFilter;
+import org.apache.fineract.tenant.service.TenantStatusLookupService;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.core.Ordered;
+import org.springframework.jdbc.datasource.DataSourceTransactionManager;
+import org.springframework.transaction.PlatformTransactionManager;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * Wiring for tenant administration against the central tenant store.
+ *
+ *
Everything here is bound to the {@code hikariTenantDataSource} bean - the registry database -
+ * rather than the per-tenant {@code routingDataSource} the rest of the plugin uses.
+ */
+@Configuration
+@Slf4j
+public class TenantManagementConfig {
+
+ /**
+ * Applies this plugin's migrations to the tenant store database.
+ *
+ *
Fineract core's tenant-store changelog is a flat, hard-coded list of parts with no module
+ * extension point - unlike the per-tenant master, which has one. A plugin therefore cannot append
+ * to it, so the plugin runs its own changelog here instead. This keeps the whole feature inside
+ * the plugin: no fork of Apache Fineract is required to add the {@code status} column MX-406
+ * needs.
+ *
+ *
Runs after core's own upgrade so the {@code tenants} table it alters is guaranteed to exist;
+ * on a fresh installation core creates the registry and this then extends it. The changesets are
+ * additive and individually guarded by preconditions, so a repeat run is a no-op.
+ */
+ @Bean
+ @DependsOn("tenantDatabaseUpgradeService")
+ public String runTenantManagementTenantStoreMigrations(
+ @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource) {
+
+ log.info("Applying tenant administration migrations to the tenant store");
+
+ // Built and run locally rather than returned as a SpringLiquibase bean, matching
+ // SelfServiceLiquibaseConfig. Publishing a SpringLiquibase bean would enter the
+ // pool that Spring Boot's Liquibase autoconfiguration and the platform's own
+ // migration wiring select from, and this changelog must apply to the tenant store
+ // and nothing else.
+ final SpringLiquibase liquibase = new SpringLiquibase();
+ liquibase.setDataSource(tenantStoreDataSource);
+ liquibase.setChangeLog(
+ "classpath:/db/changelog/tenantstore/module/tenantmanagement/module-changelog-master.xml");
+ liquibase.setShouldRun(true);
+
+ try {
+ liquibase.afterPropertiesSet();
+ } catch (final Exception e) {
+ // Fail fast and loudly: if the status column is missing, every tenant
+ // administration query would fail later with an obscure SQL error instead.
+ throw new IllegalStateException("Tenant administration migrations failed", e);
+ }
+
+ log.info("Tenant administration migrations completed");
+ return "Tenant administration migrations completed";
+ }
+
+ /**
+ * The status filter, as a plain bean so it is not auto-registered a second time.
+ *
+ *
A filter annotated {@code @Component} is picked up by Spring Boot's servlet
+ * auto-registration as well as by the registration below, which would run it twice per request.
+ */
+ @Bean
+ public TenantStatusEnforcementFilter tenantStatusEnforcementFilter(
+ final TenantStatusLookupService statusLookupService) {
+ return new TenantStatusEnforcementFilter(statusLookupService);
+ }
+
+ /**
+ * Puts the status filter in front of everything else.
+ *
+ *
Ordered ahead of Spring Security's chain so a suspended tenant is turned away before any
+ * credential is read, and mapped to every path because a suspension has to hold for the whole
+ * platform, not only for the endpoints this plugin adds.
+ */
+ @Bean
+ public FilterRegistrationBean
+ tenantStatusEnforcementFilterRegistration(final TenantStatusEnforcementFilter filter) {
+ final FilterRegistrationBean registration =
+ new FilterRegistrationBean<>(filter);
+ registration.addUrlPatterns("/*");
+ registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
+ return registration;
+ }
+
+ /**
+ * Transaction manager for the registry.
+ *
+ *
Not marked {@code @Primary}: the platform's own transaction manager must keep serving every
+ * other component. This one is injected by name, and only by tenant administration.
+ */
+ @Bean
+ public PlatformTransactionManager tenantStoreTransactionManager(
+ @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource) {
+ return new DataSourceTransactionManager(tenantStoreDataSource);
+ }
+
+ /**
+ * Wraps registry writes in a single transaction.
+ *
+ *
Creating a tenant touches two tables and must be all-or-nothing: a connection row with no
+ * tenant row would be an orphan no API surfaces, and a tenant row is impossible without one
+ * because {@code oltp_id} is NOT NULL.
+ */
+ @Bean
+ public TransactionTemplate tenantStoreTransactionTemplate(
+ @Qualifier("tenantStoreTransactionManager") final PlatformTransactionManager manager) {
+ return new TransactionTemplate(manager);
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/data/TenantConnectionData.java b/src/main/java/org/apache/fineract/tenant/data/TenantConnectionData.java
new file mode 100644
index 00000000..ec4ec171
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/data/TenantConnectionData.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.data;
+
+/**
+ * Database connection behind a tenant, as returned to clients.
+ *
+ *
Deliberately carries no password field. {@code tenant_server_connections} stores both a
+ * read-write and a read-only password, encrypted at rest by Fineract core, and neither is ever
+ * serialised back to a caller: a credential that is written but never read cannot leak through the
+ * API, and the administration UI has no use for the current value. Passwords are therefore
+ * write-only, set through create and update and omitted here.
+ *
+ * @param id primary key in {@code tenant_server_connections}
+ * @param schemaName database or schema holding the tenant's data
+ * @param schemaServer host the schema lives on
+ * @param schemaServerPort port the schema is reached on
+ * @param schemaUsername user the platform connects as, never its password
+ * @param schemaConnectionParameters extra JDBC parameters, may be null
+ * @param autoUpdate whether the schema is migrated automatically on startup
+ */
+public record TenantConnectionData(
+ Long id,
+ String schemaName,
+ String schemaServer,
+ String schemaServerPort,
+ String schemaUsername,
+ String schemaConnectionParameters,
+ boolean autoUpdate) {}
diff --git a/src/main/java/org/apache/fineract/tenant/data/TenantConnectionTestRequest.java b/src/main/java/org/apache/fineract/tenant/data/TenantConnectionTestRequest.java
new file mode 100644
index 00000000..32d66f42
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/data/TenantConnectionTestRequest.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.data;
+
+/**
+ * A validated request to probe a database before a tenant is committed to the registry.
+ *
+ *
Carries a password because probing is the one operation that genuinely needs one; it is used
+ * to open a connection and then discarded, never stored and never echoed back.
+ *
+ * @param schemaServer host to reach
+ * @param schemaServerPort port to reach it on
+ * @param schemaName database or schema to open
+ * @param schemaUsername user to connect as
+ * @param schemaPassword that user's password, used once and discarded
+ * @param schemaConnectionParameters optional extra JDBC parameters
+ */
+public record TenantConnectionTestRequest(
+ String schemaServer,
+ String schemaServerPort,
+ String schemaName,
+ String schemaUsername,
+ String schemaPassword,
+ String schemaConnectionParameters) {}
diff --git a/src/main/java/org/apache/fineract/tenant/data/TenantCreateRequest.java b/src/main/java/org/apache/fineract/tenant/data/TenantCreateRequest.java
new file mode 100644
index 00000000..2ad3b65c
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/data/TenantCreateRequest.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.data;
+
+import org.apache.fineract.tenant.domain.TenantStatus;
+
+/**
+ * A validated request to create a tenant.
+ *
+ *
Produced only by {@code TenantManagementDataValidator}; every field has already been checked
+ * by the time one of these exists, so the write service does not re-validate.
+ *
+ * @param identifier unique key clients will send as {@code X-Mifos-Platform-TenantId}
+ * @param name human readable name
+ * @param timezoneId IANA zone identifier
+ * @param status initial lifecycle state, never null - defaulted to ACTIVE when not supplied
+ * @param description optional free text
+ * @param contactEmail optional administrative contact
+ * @param schemaName database or schema to provision and connect to
+ * @param schemaServer host the schema lives on
+ * @param schemaServerPort port the schema is reached on
+ * @param schemaUsername user the platform connects as
+ * @param schemaPassword password for that user, write-only and never returned
+ * @param schemaConnectionParameters optional extra JDBC parameters
+ * @param autoUpdate whether the schema is migrated automatically on startup
+ */
+public record TenantCreateRequest(
+ String identifier,
+ String name,
+ String timezoneId,
+ TenantStatus status,
+ String description,
+ String contactEmail,
+ String schemaName,
+ String schemaServer,
+ String schemaServerPort,
+ String schemaUsername,
+ String schemaPassword,
+ String schemaConnectionParameters,
+ boolean autoUpdate) {}
diff --git a/src/main/java/org/apache/fineract/tenant/data/TenantData.java b/src/main/java/org/apache/fineract/tenant/data/TenantData.java
new file mode 100644
index 00000000..ec0be590
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/data/TenantData.java
@@ -0,0 +1,41 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.data;
+
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import org.apache.fineract.tenant.domain.TenantStatus;
+
+/**
+ * A tenant as returned to clients.
+ *
+ * @param id primary key in {@code tenants}
+ * @param identifier unique key clients send as {@code X-Mifos-Platform-TenantId}, immutable once
+ * created because it is how every request selects a tenant
+ * @param name human readable name
+ * @param timezoneId IANA zone the tenant's business dates are interpreted in
+ * @param status lifecycle state, or null when the stored value is not one this plugin recognises -
+ * such a tenant is refused by the status filter until its status is set again
+ * @param description optional free text, may be null
+ * @param contactEmail optional administrative contact, may be null
+ * @param joinedDate date the tenant joined, may be null on rows predating the field
+ * @param createdDate when the row was created, may be null on rows predating the field
+ * @param lastModifiedDate when the row was last changed, may be null
+ * @param connection the tenant's read-write connection, without credentials
+ */
+public record TenantData(
+ Long id,
+ String identifier,
+ String name,
+ String timezoneId,
+ TenantStatus status,
+ String description,
+ String contactEmail,
+ LocalDate joinedDate,
+ OffsetDateTime createdDate,
+ OffsetDateTime lastModifiedDate,
+ TenantConnectionData connection) {}
diff --git a/src/main/java/org/apache/fineract/tenant/data/TenantManagementDataValidator.java b/src/main/java/org/apache/fineract/tenant/data/TenantManagementDataValidator.java
new file mode 100644
index 00000000..d2f3d335
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/data/TenantManagementDataValidator.java
@@ -0,0 +1,536 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.data;
+
+import com.google.gson.JsonElement;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Pattern;
+import lombok.RequiredArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.fineract.infrastructure.core.data.ApiParameterError;
+import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
+import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
+import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
+import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
+import org.apache.fineract.tenant.domain.TenantSchemaName;
+import org.apache.fineract.tenant.domain.TenantStatus;
+import org.springframework.stereotype.Component;
+
+/**
+ * Validates tenant administration payloads and reduces them to a typed request.
+ *
+ *
Validation here is a security boundary, not a convenience. Two of these fields reach places
+ * that cannot be parameterised or easily undone:
+ *
+ *
+ *
{@code schemaName} is interpolated into {@code CREATE SCHEMA} DDL, because no JDBC driver
+ * lets an identifier be bound as a parameter. It is therefore restricted to letters, digits
+ * and underscore, which makes injection impossible by construction rather than by escaping.
+ *
{@code identifier} becomes the value clients send as {@code X-Mifos-Platform-TenantId} and
+ * is matched against the registry on every request, so it is held to the same narrow shape.
+ *
+ *
+ *
SOUL_GUARDRAILS forbids weakening validation for convenience; if either pattern seems
+ * restrictive, that is deliberate.
+ */
+@Component
+@RequiredArgsConstructor
+public class TenantManagementDataValidator {
+
+ private static final String RESOURCE_NAME = "tenant";
+
+ /**
+ * Lower case letters, digits, underscore and dash, starting with a letter or digit.
+ *
+ *
Anchored and deliberately narrow: the identifier travels in an HTTP header and is compared
+ * against the registry on every single request, so it must not be able to carry whitespace,
+ * control characters or anything that could be read differently by header parsing than by SQL.
+ * Case is fixed to lower so two tenants cannot differ only by case and become ambiguous.
+ */
+ private static final Pattern IDENTIFIER_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9_-]{0,99}$");
+
+ /**
+ * The shared schema-name contract; see {@link TenantSchemaName} for why it is shaped as it is.
+ *
+ *
Read from there rather than declared here so this validator and {@code
+ * TenantProvisioningService}, which concatenates the value into DDL, cannot drift apart.
+ */
+ private static final Pattern SCHEMA_NAME_PATTERN = TenantSchemaName.PATTERN;
+
+ /**
+ * System databases a tenant must never be bound to, compared case-insensitively.
+ *
+ *
Creating a tenant reuses an existing database of the requested name, so without this a
+ * tenant could be pointed at an engine's own databases and have Fineract's migrations run inside
+ * them.
+ */
+ private static final Set RESERVED_DATABASE_NAMES =
+ Set.of(
+ "postgres",
+ "template0",
+ "template1",
+ "information_schema",
+ "performance_schema",
+ "mysql",
+ "sys");
+
+ /** Digits only. The port is concatenated into a JDBC URL, so it must carry nothing else. */
+ private static final Pattern PORT_PATTERN = Pattern.compile("^[0-9]{1,5}$");
+
+ /**
+ * A pragmatic e-mail shape: something, an {@code @}, then a dotted domain.
+ *
+ *
Not an attempt at RFC 5322 - this address is contact metadata that is displayed, never used
+ * to authenticate or to route mail, so catching obvious typos is the whole job.
+ */
+ private static final Pattern EMAIL_PATTERN =
+ Pattern.compile("^[^@\\s]+@[^@\\s.]+(\\.[^@\\s.]+)+$");
+
+ private final FromJsonHelper fromApiJsonHelper;
+
+ /**
+ * Validates a create payload.
+ *
+ * @param json request body
+ * @return the validated request
+ * @throws InvalidJsonException when the body is absent
+ * @throws PlatformApiDataValidationException when any field is missing or malformed
+ */
+ public TenantCreateRequest validateForCreate(final String json) {
+ if (StringUtils.isBlank(json)) {
+ throw new InvalidJsonException();
+ }
+
+ final List errors = new ArrayList<>();
+ final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE_NAME);
+ final JsonElement element = this.fromApiJsonHelper.parse(json);
+
+ final String identifier = trimmed(extract("identifier", element));
+ validator
+ .reset()
+ .parameter("identifier")
+ .value(identifier)
+ .notBlank()
+ .matchesRegularExpression(IDENTIFIER_PATTERN.pattern());
+
+ final String name = trimmed(extract("name", element));
+ validator.reset().parameter("name").value(name).notBlank().notExceedingLengthOf(100);
+
+ final String timezoneId = trimmed(extract("timezoneId", element));
+ validator.reset().parameter("timezoneId").value(timezoneId).notBlank();
+ validateTimezone(validator, timezoneId);
+
+ final String schemaName = lowerCased(trimmed(extract("schemaName", element)));
+ validator
+ .reset()
+ .parameter("schemaName")
+ .value(schemaName)
+ .notBlank()
+ .matchesRegularExpression(SCHEMA_NAME_PATTERN.pattern());
+ if (schemaName != null
+ && RESERVED_DATABASE_NAMES.contains(schemaName.toLowerCase(Locale.ROOT))) {
+ validator
+ .reset()
+ .parameter("schemaName")
+ .value(schemaName)
+ .failWithCode("is.a.reserved.database");
+ }
+
+ final String schemaServer = trimmed(extract("schemaServer", element));
+ validator
+ .reset()
+ .parameter("schemaServer")
+ .value(schemaServer)
+ .notBlank()
+ .notExceedingLengthOf(100);
+
+ final String schemaServerPort = trimmed(extract("schemaServerPort", element));
+ validator
+ .reset()
+ .parameter("schemaServerPort")
+ .value(schemaServerPort)
+ .notBlank()
+ .matchesRegularExpression(PORT_PATTERN.pattern());
+ validatePortRange(validator, schemaServerPort);
+
+ final String schemaUsername = trimmed(extract("schemaUsername", element));
+ validator
+ .reset()
+ .parameter("schemaUsername")
+ .value(schemaUsername)
+ .notBlank()
+ .notExceedingLengthOf(100);
+
+ // Required on create and never defaulted: a tenant silently provisioned with a
+ // guessable password would be reachable by anyone who guessed it.
+ final String schemaPassword = extract("schemaPassword", element);
+ validator.reset().parameter("schemaPassword").value(schemaPassword).notBlank();
+
+ final String description = trimmed(extract("description", element));
+ validator
+ .reset()
+ .parameter("description")
+ .value(description)
+ .ignoreIfNull()
+ .notExceedingLengthOf(500);
+
+ final String contactEmail = trimmed(extract("contactEmail", element));
+ validateEmail(validator, contactEmail);
+
+ final TenantStatus status = resolveStatus(validator, element, TenantStatus.ACTIVE);
+
+ final String connectionParameters = trimmed(extract("schemaConnectionParameters", element));
+ final Boolean autoUpdate = this.fromApiJsonHelper.extractBooleanNamed("autoUpdate", element);
+
+ throwIfErrors(errors);
+
+ return new TenantCreateRequest(
+ identifier,
+ name,
+ timezoneId,
+ status,
+ description,
+ contactEmail,
+ schemaName,
+ schemaServer,
+ schemaServerPort,
+ schemaUsername,
+ schemaPassword,
+ connectionParameters,
+ // Defaults to true, matching the tenant_server_connections column default, so a
+ // tenant created through the API is migrated on startup like every existing one.
+ autoUpdate == null || autoUpdate);
+ }
+
+ /**
+ * Validates an update payload. Absent fields mean "leave unchanged".
+ *
+ * @param json request body
+ * @return the validated request
+ * @throws InvalidJsonException when the body is absent
+ * @throws PlatformApiDataValidationException when a supplied field is malformed, or the body
+ * would change nothing
+ */
+ public TenantUpdateRequest validateForUpdate(final String json) {
+ if (StringUtils.isBlank(json)) {
+ throw new InvalidJsonException();
+ }
+
+ final List errors = new ArrayList<>();
+ final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE_NAME);
+ final JsonElement element = this.fromApiJsonHelper.parse(json);
+
+ // Rejected rather than ignored. Silently dropping an identifier the caller believed
+ // it was changing is worse than refusing: the caller would carry on assuming the
+ // rename took effect.
+ if (this.fromApiJsonHelper.parameterExists("identifier", element)) {
+ validator.reset().parameter("identifier").value(null).failWithCode("cannot.be.changed");
+ }
+
+ final String name = requiredWhenPresent(validator, element, "name");
+ validator.reset().parameter("name").value(name).ignoreIfNull().notExceedingLengthOf(100);
+
+ final String timezoneId = requiredWhenPresent(validator, element, "timezoneId");
+ validateTimezone(validator, timezoneId);
+
+ final String description = clearableWhenPresent(element, "description");
+ validator
+ .reset()
+ .parameter("description")
+ .value(description)
+ .ignoreIfNull()
+ .notExceedingLengthOf(500);
+
+ final String contactEmail = clearableWhenPresent(element, "contactEmail");
+ validateEmail(validator, contactEmail);
+
+ final String schemaServer = requiredWhenPresent(validator, element, "schemaServer");
+ validator
+ .reset()
+ .parameter("schemaServer")
+ .value(schemaServer)
+ .ignoreIfNull()
+ .notExceedingLengthOf(100);
+
+ final String schemaServerPort = requiredWhenPresent(validator, element, "schemaServerPort");
+ if (schemaServerPort != null) {
+ validator
+ .reset()
+ .parameter("schemaServerPort")
+ .value(schemaServerPort)
+ .matchesRegularExpression(PORT_PATTERN.pattern());
+ validatePortRange(validator, schemaServerPort);
+ }
+
+ final String schemaUsername = requiredWhenPresent(validator, element, "schemaUsername");
+ validator
+ .reset()
+ .parameter("schemaUsername")
+ .value(schemaUsername)
+ .ignoreIfNull()
+ .notExceedingLengthOf(100);
+
+ final String schemaPassword = extract("schemaPassword", element);
+ if (schemaPassword != null) {
+ validator.reset().parameter("schemaPassword").value(schemaPassword).notBlank();
+ }
+
+ final String connectionParameters = clearableWhenPresent(element, "schemaConnectionParameters");
+ final Boolean autoUpdate = this.fromApiJsonHelper.extractBooleanNamed("autoUpdate", element);
+
+ final TenantUpdateRequest request =
+ new TenantUpdateRequest(
+ name,
+ timezoneId,
+ description,
+ contactEmail,
+ schemaServer,
+ schemaServerPort,
+ schemaUsername,
+ schemaPassword,
+ connectionParameters,
+ autoUpdate);
+
+ if (errors.isEmpty() && request.isEmpty()) {
+ validator.reset().parameter("id").value(null).failWithCode("no.parameters.for.update");
+ }
+
+ throwIfErrors(errors);
+ return request;
+ }
+
+ /**
+ * Validates a connection-test payload.
+ *
+ *
Holds the same shape rules as create - in particular the schema name pattern - so a probe
+ * cannot be used to reach a target that create itself would refuse.
+ *
+ * @param json request body
+ * @return the validated request
+ * @throws InvalidJsonException when the body is absent
+ * @throws PlatformApiDataValidationException when any field is missing or malformed
+ */
+ public TenantConnectionTestRequest validateForConnectionTest(final String json) {
+ if (StringUtils.isBlank(json)) {
+ throw new InvalidJsonException();
+ }
+
+ final List errors = new ArrayList<>();
+ final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE_NAME);
+ final JsonElement element = this.fromApiJsonHelper.parse(json);
+
+ final String schemaName = lowerCased(trimmed(extract("schemaName", element)));
+ validator
+ .reset()
+ .parameter("schemaName")
+ .value(schemaName)
+ .notBlank()
+ .matchesRegularExpression(SCHEMA_NAME_PATTERN.pattern());
+
+ final String schemaServer = trimmed(extract("schemaServer", element));
+ validator
+ .reset()
+ .parameter("schemaServer")
+ .value(schemaServer)
+ .notBlank()
+ .notExceedingLengthOf(100);
+
+ final String schemaServerPort = trimmed(extract("schemaServerPort", element));
+ validator
+ .reset()
+ .parameter("schemaServerPort")
+ .value(schemaServerPort)
+ .notBlank()
+ .matchesRegularExpression(PORT_PATTERN.pattern());
+ validatePortRange(validator, schemaServerPort);
+
+ final String schemaUsername = trimmed(extract("schemaUsername", element));
+ validator
+ .reset()
+ .parameter("schemaUsername")
+ .value(schemaUsername)
+ .notBlank()
+ .notExceedingLengthOf(100);
+
+ final String schemaPassword = extract("schemaPassword", element);
+ validator.reset().parameter("schemaPassword").value(schemaPassword).notBlank();
+
+ final String connectionParameters = trimmed(extract("schemaConnectionParameters", element));
+
+ throwIfErrors(errors);
+
+ return new TenantConnectionTestRequest(
+ schemaServer,
+ schemaServerPort,
+ schemaName,
+ schemaUsername,
+ schemaPassword,
+ connectionParameters);
+ }
+
+ /**
+ * Reads a field an update may omit but, when it is sent, must carry a value.
+ *
+ *
A blank value is refused rather than read as "leave unchanged": a caller who sent {@code
+ * "name": " "} meant to change the name, and quietly applying the rest of the request would hide
+ * that it did not happen.
+ */
+ private String requiredWhenPresent(
+ final DataValidatorBuilder validator, final JsonElement element, final String parameterName) {
+ final String value = trimmed(extract(parameterName, element));
+ if (value == null && this.fromApiJsonHelper.parameterExists(parameterName, element)) {
+ validator.reset().parameter(parameterName).value(null).failWithCode("cannot.be.blank");
+ }
+ return value;
+ }
+
+ /**
+ * Reads an optional field an update may clear.
+ *
+ * @return null when the field is omitted (leave unchanged), an empty string when it is sent blank
+ * or null (clear it), otherwise the trimmed value
+ */
+ private String clearableWhenPresent(final JsonElement element, final String parameterName) {
+ final String value = trimmed(extract(parameterName, element));
+ if (value == null && this.fromApiJsonHelper.parameterExists(parameterName, element)) {
+ return "";
+ }
+ return value;
+ }
+
+ /**
+ * Rejects a port outside 1-65535.
+ *
+ *
The digit pattern alone admits 65536-99999, which would pass validation and then fail as an
+ * unreachable database - reported as a connection problem instead of the input mistake it is.
+ * Blank and non-numeric values are left to the checks that already report them.
+ */
+ private static void validatePortRange(final DataValidatorBuilder validator, final String port) {
+ if (port == null || !PORT_PATTERN.matcher(port).matches()) {
+ return;
+ }
+ final int value = Integer.parseInt(port);
+ if (value < 1 || value > 65535) {
+ validator
+ .reset()
+ .parameter("schemaServerPort")
+ .value(port)
+ .failWithCode("is.not.a.valid.port", 1, 65535);
+ }
+ }
+
+ /**
+ * Resolves a {@code status} field.
+ *
+ *
Reported through {@link DataValidatorBuilder#failWithCode} rather than a chain of rules
+ * because "is one of these three names" is a single decision; {@link TenantStatus#fromString}
+ * already owns it, and duplicating the list here would let the two drift.
+ */
+ private TenantStatus resolveStatus(
+ final DataValidatorBuilder validator,
+ final JsonElement element,
+ final TenantStatus fallback) {
+ final String status = trimmed(extract("status", element));
+ if (status == null) {
+ // A status sent blank is malformed input, not an omission: defaulting it to ACTIVE
+ // would create a live tenant the caller never asked for.
+ if (this.fromApiJsonHelper.parameterExists("status", element)) {
+ validator.reset().parameter("status").value(null).failWithCode("cannot.be.blank");
+ }
+ return fallback;
+ }
+ return TenantStatus.fromString(status)
+ .orElseGet(
+ () -> {
+ validator
+ .reset()
+ .parameter("status")
+ .value(status)
+ .failWithCode(
+ "is.not.a.supported.status", String.join(", ", TenantStatus.names()));
+ return fallback;
+ });
+ }
+
+ /** Checks a zone against this JVM, which is what will ultimately resolve it at runtime. */
+ private void validateTimezone(final DataValidatorBuilder validator, final String timezoneId) {
+ if (timezoneId == null || timezoneId.isBlank()) {
+ return;
+ }
+ if (!ZoneId.getAvailableZoneIds().contains(timezoneId)) {
+ validator
+ .reset()
+ .parameter("timezoneId")
+ .value(timezoneId)
+ .failWithCode("is.not.a.known.timezone");
+ }
+ }
+
+ private void validateEmail(final DataValidatorBuilder validator, final String contactEmail) {
+ if (contactEmail == null) {
+ return;
+ }
+ validator.reset().parameter("contactEmail").value(contactEmail).notExceedingLengthOf(150);
+ if (!contactEmail.isBlank() && !EMAIL_PATTERN.matcher(contactEmail).matches()) {
+ validator
+ .reset()
+ .parameter("contactEmail")
+ .value(contactEmail)
+ .failWithCode("is.not.a.valid.email");
+ }
+ }
+
+ /**
+ * Reads a string field.
+ *
+ *
Goes through {@link FromJsonHelper} rather than {@code JsonCommand.from(String)}: that
+ * factory leaves the command's own helper null, so reading a parameter off it throws.
+ */
+ private String extract(final String parameterName, final JsonElement element) {
+ return this.fromApiJsonHelper.extractStringNamed(parameterName, element);
+ }
+
+ /**
+ * Canonicalises a schema name to lower case.
+ *
+ *
PostgreSQL folds an unquoted {@code CREATE DATABASE ACME} to {@code acme}, while the
+ * existence check and the JDBC URL use the name exactly as given. A mixed-case name therefore
+ * created a database the platform could then neither find nor connect to, and left it orphaned.
+ * One lower-case value is used everywhere instead. Pinned to {@link Locale#ROOT} so a Turkish
+ * locale cannot fold {@code I} to a dotless {@code ı}.
+ */
+ private static String lowerCased(final String value) {
+ return value == null ? null : value.toLowerCase(Locale.ROOT);
+ }
+
+ /** Trims, mapping a value that was only whitespace to null so it is treated as absent. */
+ private static String trimmed(final String value) {
+ if (value == null) {
+ return null;
+ }
+ final String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+
+ /**
+ * @return the identifier lower-cased for comparison, pinned to {@link Locale#ROOT} so a Turkish
+ * locale cannot fold {@code I} to a dotless {@code ı} and change what matches
+ */
+ public static String normaliseIdentifier(final String identifier) {
+ return identifier == null ? null : identifier.trim().toLowerCase(Locale.ROOT);
+ }
+
+ private static void throwIfErrors(final List errors) {
+ if (!errors.isEmpty()) {
+ throw new PlatformApiDataValidationException(errors);
+ }
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/data/TenantTemplateData.java b/src/main/java/org/apache/fineract/tenant/data/TenantTemplateData.java
new file mode 100644
index 00000000..f204b7b0
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/data/TenantTemplateData.java
@@ -0,0 +1,20 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.data;
+
+import java.util.List;
+
+/**
+ * Options an administration client needs to build the create and edit forms.
+ *
+ *
Served so the UI never hardcodes a list the backend owns, and so a status added later reaches
+ * the UI without a frontend release.
+ *
+ * @param timezones selectable IANA zone identifiers
+ * @param statuses selectable lifecycle states
+ */
+public record TenantTemplateData(List timezones, List statuses) {}
diff --git a/src/main/java/org/apache/fineract/tenant/data/TenantUpdateRequest.java b/src/main/java/org/apache/fineract/tenant/data/TenantUpdateRequest.java
new file mode 100644
index 00000000..553d18e5
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/data/TenantUpdateRequest.java
@@ -0,0 +1,101 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.data;
+
+/**
+ * A validated request to update a tenant.
+ *
+ *
Every field is optional and a null means "leave unchanged", so a client may send only what it
+ * is actually changing. The optional {@code description}, {@code contactEmail} and {@code
+ * schemaConnectionParameters} are cleared with an empty string; the other fields cannot be blank.
+ * {@code identifier} is absent by design: it is how every request selects a tenant and is embedded
+ * in that tenant's existing sessions and integrations, so renaming one through this API would
+ * strand them. A tenant that needs a different identifier is created anew.
+ *
+ * @param name new name, or null to leave unchanged
+ * @param timezoneId new IANA zone identifier, or null
+ * @param description new free text, null to leave unchanged, or empty to clear
+ * @param contactEmail new administrative contact, null to leave unchanged, or empty to clear
+ * @param schemaServer new host, or null
+ * @param schemaServerPort new port, or null
+ * @param schemaUsername new user, or null
+ * @param schemaPassword new password, or null to keep the stored one
+ * @param schemaConnectionParameters new JDBC parameters, null to leave unchanged, or empty to clear
+ * @param autoUpdate new auto-migrate flag, or null
+ */
+public record TenantUpdateRequest(
+ String name,
+ String timezoneId,
+ String description,
+ String contactEmail,
+ String schemaServer,
+ String schemaServerPort,
+ String schemaUsername,
+ String schemaPassword,
+ String schemaConnectionParameters,
+ Boolean autoUpdate) {
+
+ /**
+ * Names the fields this request changes, for the audit trail.
+ *
+ *
Names only - never values. {@code schemaPassword} appears here when a password was rotated,
+ * so the rotation is recorded while the password itself never leaves this object.
+ *
+ * @return the changed field names, in a stable order
+ */
+ public java.util.List changedFieldNames() {
+ final java.util.List changed = new java.util.ArrayList<>();
+ if (name != null) {
+ changed.add("name");
+ }
+ if (timezoneId != null) {
+ changed.add("timezoneId");
+ }
+ if (description != null) {
+ changed.add("description");
+ }
+ if (contactEmail != null) {
+ changed.add("contactEmail");
+ }
+ if (schemaServer != null) {
+ changed.add("schemaServer");
+ }
+ if (schemaServerPort != null) {
+ changed.add("schemaServerPort");
+ }
+ if (schemaUsername != null) {
+ changed.add("schemaUsername");
+ }
+ if (schemaPassword != null) {
+ changed.add("schemaPassword");
+ }
+ if (schemaConnectionParameters != null) {
+ changed.add("schemaConnectionParameters");
+ }
+ if (autoUpdate != null) {
+ changed.add("autoUpdate");
+ }
+ return changed;
+ }
+
+ /**
+ * @return true when the request would change nothing, so the caller can be told its request was
+ * empty rather than silently getting back an unchanged tenant
+ */
+ public boolean isEmpty() {
+ return name == null
+ && timezoneId == null
+ && description == null
+ && contactEmail == null
+ && schemaServer == null
+ && schemaServerPort == null
+ && schemaUsername == null
+ && schemaPassword == null
+ && schemaConnectionParameters == null
+ && autoUpdate == null;
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/domain/TenantAdministrationAction.java b/src/main/java/org/apache/fineract/tenant/domain/TenantAdministrationAction.java
new file mode 100644
index 00000000..0d3b3ac4
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/domain/TenantAdministrationAction.java
@@ -0,0 +1,29 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.domain;
+
+/** The tenant administration actions recorded in the audit trail. */
+public enum TenantAdministrationAction {
+ CREATE,
+ UPDATE,
+ ACTIVATE,
+ DEACTIVATE,
+ SUSPEND,
+ DELETE;
+
+ /**
+ * @param status the status a tenant is moving to
+ * @return the action that records that move
+ */
+ public static TenantAdministrationAction forStatusChange(final TenantStatus status) {
+ return switch (status) {
+ case ACTIVE -> ACTIVATE;
+ case INACTIVE -> DEACTIVATE;
+ case SUSPENDED -> SUSPEND;
+ };
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/domain/TenantSchemaName.java b/src/main/java/org/apache/fineract/tenant/domain/TenantSchemaName.java
new file mode 100644
index 00000000..3f69fe4e
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/domain/TenantSchemaName.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.domain;
+
+import java.util.regex.Pattern;
+
+/**
+ * The one definition of an acceptable tenant schema name.
+ *
+ *
A schema name is concatenated into {@code CREATE DATABASE} DDL, because no JDBC driver lets an
+ * identifier be bound as a parameter. The pattern - not escaping - is what makes that safe, so the
+ * request validator and the provisioning service must never disagree about it; both read it from
+ * here.
+ *
+ *
The name must start with a letter or underscore: PostgreSQL rejects an unquoted identifier
+ * that starts with a digit ({@code CREATE DATABASE 123tenant} is a syntax error). It is capped at
+ * 63 characters, PostgreSQL's identifier limit and the shortest across the engines Fineract
+ * supports, so a name accepted here is creatable on any of them.
+ */
+public final class TenantSchemaName {
+
+ public static final Pattern PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]{0,62}$");
+
+ private TenantSchemaName() {}
+
+ /**
+ * @param schemaName candidate name, possibly null
+ * @return true when the name is safe to place, unquoted, into DDL on every supported engine
+ */
+ public static boolean isValid(final String schemaName) {
+ return schemaName != null && PATTERN.matcher(schemaName).matches();
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/domain/TenantStatus.java b/src/main/java/org/apache/fineract/tenant/domain/TenantStatus.java
new file mode 100644
index 00000000..ea00132e
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/domain/TenantStatus.java
@@ -0,0 +1,60 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.domain;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+import java.util.Optional;
+
+/**
+ * Lifecycle state of a tenant in the central registry.
+ *
+ *
Apache Fineract has no such concept: a row in {@code tenants} exists and the tenant is
+ * reachable, or it does not exist at all. MX-406 requires the three states below, so they are
+ * introduced by this plugin along with the {@code status} column that stores them.
+ *
+ *
Stored as the enum name rather than an ordinal, so inserting a state later cannot silently
+ * change what existing rows mean.
+ */
+public enum TenantStatus {
+
+ /** Fully operational. The only state in which a tenant serves requests. */
+ ACTIVE,
+
+ /** Deliberately taken out of service, for example a tenant that has been wound down. */
+ INACTIVE,
+
+ /** Temporarily withheld, for example pending payment or investigation. */
+ SUSPENDED;
+
+ /**
+ * Parses a status supplied by a client.
+ *
+ *
Matching is case insensitive for the caller's convenience but pinned to {@link Locale#ROOT},
+ * because the tenant's locale must not decide whether a status is recognised: under a Turkish
+ * locale the default folding turns the {@code I} of {@code INACTIVE} into a dotless {@code ı},
+ * which would match nothing.
+ *
+ * @param value status as supplied, possibly null or blank
+ * @return the matching status, or empty when the value names none
+ */
+ public static Optional fromString(final String value) {
+ if (value == null) {
+ return Optional.empty();
+ }
+ final String candidate = value.trim().toUpperCase(Locale.ROOT);
+ return Arrays.stream(values()).filter(status -> status.name().equals(candidate)).findFirst();
+ }
+
+ /**
+ * @return every status name, in declaration order, for the API template and error messages
+ */
+ public static List names() {
+ return Arrays.stream(values()).map(TenantStatus::name).toList();
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/exception/TenantConnectionFailedException.java b/src/main/java/org/apache/fineract/tenant/exception/TenantConnectionFailedException.java
new file mode 100644
index 00000000..a3a51bd7
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/exception/TenantConnectionFailedException.java
@@ -0,0 +1,51 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.exception;
+
+import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
+
+/**
+ * Thrown when the database behind a tenant cannot be reached with the supplied details.
+ *
+ *
The message names the host, port and schema that were tried but never the credentials, and the
+ * underlying {@link java.sql.SQLException} is attached as the cause for the server log rather than
+ * folded into the user message: driver errors routinely echo the connection URL and user back, and
+ * SOUL_GUARDRAILS requires that infrastructure detail stays out of API responses.
+ */
+public class TenantConnectionFailedException extends AbstractPlatformDomainRuleException {
+
+ /**
+ * Held here instead of passed to {@code initCause}.
+ *
+ *
Fineract's {@code AbstractPlatformException} constructs through {@code
+ * RuntimeException(String, Throwable)}, which marks the cause as already set, so a later {@code
+ * initCause} throws "Can't overwrite cause" - turning a clean domain error into a 500. This holds
+ * on 1.15 and 1.16 alike; it went unnoticed because every test mocked the services that throw
+ * this exception, and surfaced only against a running Fineract. Overriding {@link #getCause()}
+ * chains the cause without that conflict, so loggers still print "Caused by".
+ */
+ private final Throwable underlyingCause;
+
+ @Override
+ public Throwable getCause() {
+ return underlyingCause;
+ }
+
+ public TenantConnectionFailedException(
+ final String schemaServer,
+ final String schemaServerPort,
+ final String schemaName,
+ final Throwable cause) {
+ super(
+ "error.msg.tenant.connection.failed",
+ "Could not connect to " + schemaName + " at " + schemaServer + ":" + schemaServerPort,
+ schemaServer,
+ schemaServerPort,
+ schemaName);
+ this.underlyingCause = cause;
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/exception/TenantIdentifierAlreadyExistsException.java b/src/main/java/org/apache/fineract/tenant/exception/TenantIdentifierAlreadyExistsException.java
new file mode 100644
index 00000000..45c5a39b
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/exception/TenantIdentifierAlreadyExistsException.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.exception;
+
+import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
+
+/**
+ * Thrown when a tenant is created with an identifier already in the registry.
+ *
+ *
The identifier is how every request selects a tenant, so a duplicate would make routing
+ * ambiguous. The database enforces this with a unique constraint; this exception exists so the
+ * clash is reported as a clear validation failure rather than surfacing as a raw constraint
+ * violation.
+ */
+public class TenantIdentifierAlreadyExistsException extends PlatformDataIntegrityException {
+
+ public TenantIdentifierAlreadyExistsException(final String identifier) {
+ super(
+ "error.msg.tenant.identifier.already.exists",
+ "A tenant with the identifier " + identifier + " already exists",
+ "identifier",
+ identifier);
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/exception/TenantNotFoundException.java b/src/main/java/org/apache/fineract/tenant/exception/TenantNotFoundException.java
new file mode 100644
index 00000000..5b68c13c
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/exception/TenantNotFoundException.java
@@ -0,0 +1,24 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.exception;
+
+import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
+
+/** Thrown when no tenant in the central registry matches the requested id or identifier. */
+public class TenantNotFoundException extends AbstractPlatformResourceNotFoundException {
+
+ public TenantNotFoundException(final Long id) {
+ super("error.msg.tenant.id.invalid", "Tenant with id " + id + " does not exist", id);
+ }
+
+ public TenantNotFoundException(final String identifier) {
+ super(
+ "error.msg.tenant.identifier.invalid",
+ "Tenant with identifier " + identifier + " does not exist",
+ identifier);
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/exception/TenantSchemaMigrationFailedException.java b/src/main/java/org/apache/fineract/tenant/exception/TenantSchemaMigrationFailedException.java
new file mode 100644
index 00000000..664bac6f
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/exception/TenantSchemaMigrationFailedException.java
@@ -0,0 +1,44 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.exception;
+
+import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
+
+/**
+ * Thrown when a new tenant's schema could not be populated with the platform's tables.
+ *
+ *
The underlying Liquibase failure is attached as the cause for the server log. It is not folded
+ * into the user message: migration errors quote SQL, schema names and driver internals, which
+ * SOUL_GUARDRAILS keeps out of API responses.
+ */
+public class TenantSchemaMigrationFailedException extends AbstractPlatformDomainRuleException {
+
+ /**
+ * Held here instead of passed to {@code initCause}.
+ *
+ *
Fineract's {@code AbstractPlatformException} constructs through {@code
+ * RuntimeException(String, Throwable)}, which marks the cause as already set, so a later {@code
+ * initCause} throws "Can't overwrite cause" - turning a clean domain error into a 500. This holds
+ * on 1.15 and 1.16 alike; it went unnoticed because every test mocked the services that throw
+ * this exception, and surfaced only against a running Fineract. Overriding {@link #getCause()}
+ * chains the cause without that conflict, so loggers still print "Caused by".
+ */
+ private final Throwable underlyingCause;
+
+ @Override
+ public Throwable getCause() {
+ return underlyingCause;
+ }
+
+ public TenantSchemaMigrationFailedException(final String identifier, final Throwable cause) {
+ super(
+ "error.msg.tenant.schema.migration.failed",
+ "The schema for tenant " + identifier + " could not be migrated",
+ identifier);
+ this.underlyingCause = cause;
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/exception/TenantSchemaUnavailableException.java b/src/main/java/org/apache/fineract/tenant/exception/TenantSchemaUnavailableException.java
new file mode 100644
index 00000000..39dc8aae
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/exception/TenantSchemaUnavailableException.java
@@ -0,0 +1,53 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.exception;
+
+import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
+
+/**
+ * Thrown when a tenant would be bound to a database it must not use.
+ *
+ *
Creating a tenant reuses an existing database of the requested name, so the registry itself
+ * has to decide whose data a database is. This covers the three refusals: the tenant store's own
+ * database, a database another registered tenant already uses, and one retained from a removed
+ * tenant under a different identifier.
+ */
+public class TenantSchemaUnavailableException extends AbstractPlatformDomainRuleException {
+
+ private TenantSchemaUnavailableException(
+ final String code, final String message, final Object... args) {
+ super(code, message, args);
+ }
+
+ /** The requested database is the tenant store itself. */
+ public static TenantSchemaUnavailableException tenantStore(final String schemaName) {
+ return new TenantSchemaUnavailableException(
+ "error.msg.tenant.schema.is.tenant.store",
+ "Database " + schemaName + " is the tenant store and cannot hold a tenant",
+ schemaName);
+ }
+
+ /** Another registered tenant already uses the requested database. */
+ public static TenantSchemaUnavailableException inUse(
+ final String schemaName, final String owner) {
+ return new TenantSchemaUnavailableException(
+ "error.msg.tenant.schema.in.use",
+ "Database " + schemaName + " is already used by tenant " + owner,
+ schemaName,
+ owner);
+ }
+
+ /** The requested database was retained from a removed tenant with a different identifier. */
+ public static TenantSchemaUnavailableException retained(
+ final String schemaName, final String owner) {
+ return new TenantSchemaUnavailableException(
+ "error.msg.tenant.schema.retained",
+ "Database " + schemaName + " holds the retained data of removed tenant " + owner,
+ schemaName,
+ owner);
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/filter/TenantStatusEnforcementFilter.java b/src/main/java/org/apache/fineract/tenant/filter/TenantStatusEnforcementFilter.java
new file mode 100644
index 00000000..84e53a90
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/filter/TenantStatusEnforcementFilter.java
@@ -0,0 +1,153 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.filter;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.tenant.domain.TenantStatus;
+import org.apache.fineract.tenant.service.TenantStatusLookupService;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * Refuses requests addressed to a tenant that is not {@link TenantStatus#ACTIVE}.
+ *
+ *
Why this exists. Storing a status achieves nothing on its own. Apache
+ * Fineract resolves a tenant with {@code where t.identifier = ?} and no status predicate - in
+ * {@code JdbcTenantDetailsService} and in {@code AuthTenantDetailsServiceJdbc}, the authentication
+ * path - so without this filter a suspended tenant would keep authenticating and serving requests,
+ * and MX-406's "tenant status changes correctly affect routing and availability" would be unmet.
+ *
+ *
Enforcing it here rather than in those core services is deliberate: MX-406 lists changes to
+ * core multi-tenancy routing and authentication filters as a non-goal, and a plugin cannot alter
+ * them in any case. A servlet filter sits in front of the whole chain and needs no core change.
+ *
+ *
Deliberately not a {@code @Component}: Spring Boot would auto-register it at an arbitrary
+ * position. {@code TenantManagementConfig} registers it explicitly so that running ahead of the
+ * security chain is a stated decision rather than an accident of bean discovery.
+ *
+ *
Runs before authentication, so a suspended tenant is turned away without any credential being
+ * checked. The tenant is read from the request exactly as core reads it - the {@code
+ * Fineract-Platform-TenantId} header, falling back to a {@code tenantIdentifier} query parameter -
+ * so this filter and the platform always agree on which tenant a request is for.
+ */
+@Slf4j
+public class TenantStatusEnforcementFilter extends OncePerRequestFilter {
+
+ /**
+ * Header naming the tenant. Matches core's {@code TenantAwareBasicAuthenticationFilter}.
+ *
+ *
Note MX-406's technical notes cite {@code X-Mifos-Platform-TenantId}; the platform renamed
+ * this header and the ticket is out of date. The value here follows the running platform.
+ */
+ static final String TENANT_ID_REQUEST_HEADER = "Fineract-Platform-TenantId";
+
+ /** Query-parameter fallback, also matching core. */
+ static final String TENANT_ID_REQUEST_PARAMETER = "tenantIdentifier";
+
+ private final TenantStatusLookupService statusLookupService;
+
+ public TenantStatusEnforcementFilter(final TenantStatusLookupService statusLookupService) {
+ this.statusLookupService = statusLookupService;
+ }
+
+ @Override
+ protected void doFilterInternal(
+ final HttpServletRequest request,
+ final HttpServletResponse response,
+ final FilterChain filterChain)
+ throws ServletException, IOException {
+
+ final String identifier = tenantIdentifierOf(request);
+
+ // Tenant administration is not addressed to a tenant: it runs in the master context,
+ // authenticated against master users rather than any tenant's. It is never blocked
+ // here, so suspending any tenant - including the one a UI happens to send in its
+ // header - cannot lock master users out of reinstating it.
+ if (identifier == null || isTenantAdministration(request)) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ final TenantStatusLookupService.Lookup lookup = statusLookupService.statusOf(identifier);
+
+ // No such tenant is not this filter's decision to make: the platform's own tenant
+ // resolution runs next and produces the proper error. An unrecognised status, or a
+ // registry that cannot be read with no earlier status to fall back on, is refused -
+ // see TenantStatusLookupService.Lookup#refusesService.
+ if (!lookup.refusesService()) {
+ filterChain.doFilter(request, response);
+ return;
+ }
+
+ // The raw value of an unrecognised status is not echoed: it is whatever was written
+ // into the registry by hand, and has no business in a response.
+ final String reportedStatus =
+ switch (lookup.kind()) {
+ case KNOWN -> lookup.status().name();
+ case REGISTRY_UNAVAILABLE -> "UNAVAILABLE";
+ default -> "UNRECOGNISED";
+ };
+ log.info("Refusing request for tenant [{}] in status {}", identifier, reportedStatus);
+ respondUnavailable(response, reportedStatus);
+ }
+
+ /**
+ * @return true when the request targets {@code /v1/admin/tenants}, with or without the {@code
+ * /api} prefix, matching the paths {@code TenantManagementSecurityConfiguration} claims
+ */
+ static boolean isTenantAdministration(final HttpServletRequest request) {
+ final String uri = request.getRequestURI();
+ if (uri == null) {
+ return false;
+ }
+ final String contextPath = request.getContextPath() == null ? "" : request.getContextPath();
+ final String path = uri.startsWith(contextPath) ? uri.substring(contextPath.length()) : uri;
+ return path.equals("/api/v1/admin/tenants")
+ || path.startsWith("/api/v1/admin/tenants/")
+ || path.equals("/v1/admin/tenants")
+ || path.startsWith("/v1/admin/tenants/");
+ }
+
+ /** Reads the tenant the way core does, so the two cannot disagree about a request. */
+ private static String tenantIdentifierOf(final HttpServletRequest request) {
+ final String fromHeader = request.getHeader(TENANT_ID_REQUEST_HEADER);
+ if (fromHeader != null && !fromHeader.isBlank()) {
+ return fromHeader.trim();
+ }
+ final String fromParameter = request.getParameter(TENANT_ID_REQUEST_PARAMETER);
+ return fromParameter == null || fromParameter.isBlank() ? null : fromParameter.trim();
+ }
+
+ /**
+ * Answers 503.
+ *
+ *
Not 404: the tenant exists, and pretending otherwise would send an operator hunting for a
+ * misconfiguration instead of seeing the state they themselves set. Not 401 or 403 either - the
+ * caller's credentials were never in question and have not been looked at.
+ *
+ *
The body names the status and nothing else. No credential was read and none is mentioned.
+ */
+ private static void respondUnavailable(
+ final HttpServletResponse response, final String reportedStatus) throws IOException {
+ response.setStatus(HttpServletResponse.SC_SERVICE_UNAVAILABLE);
+ response.setContentType("application/json");
+ response
+ .getWriter()
+ .write(
+ "{\"developerMessage\":\"The tenant addressed by this request is not active.\","
+ + "\"httpStatusCode\":\"503\","
+ + "\"defaultUserMessage\":\"This service is currently unavailable.\","
+ + "\"userMessageGlobalisationCode\":\"error.msg.tenant.not.active\","
+ + "\"tenantStatus\":\""
+ + reportedStatus
+ + "\"}");
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/security/TenantManagementSecurityConfiguration.java b/src/main/java/org/apache/fineract/tenant/security/TenantManagementSecurityConfiguration.java
new file mode 100644
index 00000000..820edbfe
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/security/TenantManagementSecurityConfiguration.java
@@ -0,0 +1,123 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.security;
+
+import org.apache.fineract.infrastructure.core.config.FineractProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.ProviderManager;
+import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.core.userdetails.UserDetails;
+import org.springframework.security.core.userdetails.UsernameNotFoundException;
+import org.springframework.security.crypto.factory.PasswordEncoderFactories;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
+
+/**
+ * The master security context for tenant administration.
+ *
+ *
Tenant management administers the tenants themselves, so it cannot belong to any one of them.
+ * Fineract's own chain resolves every user inside the tenant named by {@code
+ * Fineract-Platform-TenantId}; a permission granted there - even {@code ALL_FUNCTIONS} - is a
+ * tenant's permission. This chain instead authenticates {@code /v1/admin/tenants} against master
+ * users stored in the tenant store, and requires the {@code SUPER_MASTER} role. No tenant header is
+ * needed or consulted, and no tenant user can pass it.
+ *
+ *
Why {@code /v1/admin/tenants}. Core Fineract already serves {@code
+ * /v1/tenants/{tenantId}/oidc-config}. A chain claiming {@code /v1/tenants/**} would capture that
+ * core endpoint and demand master credentials for it, so tenant administration lives under a
+ * namespace core does not use.
+ *
+ *
Ordered ahead of Fineract's catch-all {@code /api/**} chain, the same way {@code
+ * SelfServiceSecurityConfiguration} claims {@code /v1/self/**}, so it needs no change to core.
+ *
+ *
The authentication manager and password encoder are built here rather than registered as
+ * beans: Fineract already defines beans of both types, and a second one could be injected where the
+ * platform expects its own.
+ */
+@Configuration
+public class TenantManagementSecurityConfiguration {
+
+ static final String[] TENANT_ADMINISTRATION_PATHS = {
+ "/api/v1/admin/tenants", "/api/v1/admin/tenants/**", "/v1/admin/tenants", "/v1/admin/tenants/**"
+ };
+
+ /**
+ * The master chain, ordered on the bean itself.
+ *
+ *
Spring Security sorts {@code SecurityFilterChain} beans by each bean's own order; an
+ * {@code @Order} on the enclosing {@code @Configuration} class does not carry over to the beans
+ * it declares. Left unordered, this chain and Fineract's catch-all {@code /api/**} chain would be
+ * tried in whatever order the beans happened to register, and requests could reach tenant
+ * authentication instead of master authentication.
+ */
+ @Bean
+ @Order(0)
+ public SecurityFilterChain tenantManagementSecurityFilterChain(
+ final HttpSecurity http,
+ final TenantMasterUserStore masterUserStore,
+ final FineractProperties fineractProperties)
+ throws Exception {
+
+ final DaoAuthenticationProvider provider =
+ new DaoAuthenticationProvider(PasswordEncoderFactories.createDelegatingPasswordEncoder());
+ provider.setUserDetailsService(username -> toUserDetails(masterUserStore, username));
+
+ final BasicAuthenticationEntryPoint entryPoint = new BasicAuthenticationEntryPoint();
+ entryPoint.setRealmName("Fineract Tenant Management");
+
+ http.securityMatcher(TENANT_ADMINISTRATION_PATHS)
+ .csrf(AbstractHttpConfigurer::disable)
+ .sessionManagement(
+ session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authenticationManager(new ProviderManager(provider))
+ .httpBasic(basic -> basic.authenticationEntryPoint(entryPoint))
+ .exceptionHandling(exceptions -> exceptions.authenticationEntryPoint(entryPoint))
+ .authorizeHttpRequests(
+ auth ->
+ auth
+ // CORS preflight carries no credentials by design.
+ .requestMatchers(HttpMethod.OPTIONS, TENANT_ADMINISTRATION_PATHS)
+ .permitAll()
+ .anyRequest()
+ .hasRole(TenantMasterAccess.SUPER_MASTER_ROLE));
+
+ // The administration UI runs in a browser on another origin, as the self-service
+ // clients do, so this chain honours the same CORS configuration.
+ if (fineractProperties.getSecurity().getCors().isEnabled()) {
+ http.cors(Customizer.withDefaults());
+ }
+
+ return http.build();
+ }
+
+ /**
+ * Adapts a stored master user for Spring Security.
+ *
+ *
An unknown name and a wrong password both surface as the same 401 through the entry point,
+ * so a caller cannot use this endpoint to discover which master usernames exist.
+ */
+ private static UserDetails toUserDetails(
+ final TenantMasterUserStore store, final String username) {
+ final TenantMasterUserStore.MasterUser user =
+ store
+ .findByUsername(username)
+ .orElseThrow(() -> new UsernameNotFoundException("Unknown master user"));
+ return User.withUsername(user.username())
+ .password(user.passwordHash())
+ .roles(user.role())
+ .disabled(!user.enabled())
+ .build();
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/security/TenantMasterAccess.java b/src/main/java/org/apache/fineract/tenant/security/TenantMasterAccess.java
new file mode 100644
index 00000000..f4fd968a
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/security/TenantMasterAccess.java
@@ -0,0 +1,54 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.security;
+
+import java.util.Optional;
+import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/**
+ * The super master role, and the check every tenant administration operation makes against it.
+ *
+ *
{@link TenantManagementSecurityConfiguration} already restricts {@code /v1/admin/tenants} to
+ * this role. The resource checks again on each call so that a change to the chain's path matching
+ * can never silently expose these operations: the endpoint fails closed on its own.
+ */
+public final class TenantMasterAccess {
+
+ /** Role held by master users. Stored without Spring's {@code ROLE_} prefix. */
+ public static final String SUPER_MASTER_ROLE = "SUPER_MASTER";
+
+ static final String SUPER_MASTER_AUTHORITY = "ROLE_" + SUPER_MASTER_ROLE;
+
+ private TenantMasterAccess() {}
+
+ /**
+ * @return the authenticated super master's username, or empty when the current request is not
+ * authenticated as one
+ */
+ public static Optional currentSuperMaster() {
+ final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !authentication.isAuthenticated()) {
+ return Optional.empty();
+ }
+ final boolean superMaster =
+ authentication.getAuthorities().stream()
+ .anyMatch(authority -> SUPER_MASTER_AUTHORITY.equals(authority.getAuthority()));
+ return superMaster ? Optional.ofNullable(authentication.getName()) : Optional.empty();
+ }
+
+ /**
+ * @return the authenticated super master's username
+ * @throws NoAuthorizationException when the request is not authenticated as a super master
+ */
+ public static String requireSuperMaster() {
+ return currentSuperMaster()
+ .orElseThrow(
+ () -> new NoAuthorizationException("Tenant administration requires a master user"));
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/security/TenantMasterUserBootstrap.java b/src/main/java/org/apache/fineract/tenant/security/TenantMasterUserBootstrap.java
new file mode 100644
index 00000000..d3b74ffd
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/security/TenantMasterUserBootstrap.java
@@ -0,0 +1,99 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.security;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.InitializingBean;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.security.crypto.factory.PasswordEncoderFactories;
+import org.springframework.stereotype.Component;
+
+/**
+ * Creates the first master user from configuration at startup.
+ *
+ *
A master context needs a way in before any master user exists, and no API can create the first
+ * one without already requiring a master. Configuration is that way in: {@code
+ * fineract.tenant-management.bootstrap-master-username} and {@code ...bootstrap-master-password},
+ * typically supplied as environment variables.
+ *
+ *
The configured user is created once. If it already exists nothing is changed - in particular
+ * its password is not reset - so a lingering or rotated environment variable can never silently
+ * overwrite a master credential. Runs after the tenant-store migration that creates the table.
+ */
+@Component
+@DependsOn("runTenantManagementTenantStoreMigrations")
+@Slf4j
+public class TenantMasterUserBootstrap implements InitializingBean {
+
+ /**
+ * Shortest bootstrap password accepted. A master user controls every tenant on the installation,
+ * so a guessable one is refused outright rather than stored.
+ */
+ static final int MINIMUM_PASSWORD_LENGTH = 12;
+
+ private final TenantMasterUserStore store;
+ private final String username;
+ private final String password;
+
+ public TenantMasterUserBootstrap(
+ final TenantMasterUserStore store,
+ @Value("${fineract.tenant-management.bootstrap-master-username:}") final String username,
+ @Value("${fineract.tenant-management.bootstrap-master-password:}") final String password) {
+ this.store = store;
+ this.username = username == null ? "" : username.trim();
+ this.password = password == null ? "" : password;
+ }
+
+ @Override
+ public void afterPropertiesSet() {
+ if (username.isEmpty() || password.isEmpty()) {
+ if (store.count() == 0) {
+ log.warn(
+ "No tenant management master user exists and none is configured; /v1/admin/tenants will"
+ + " refuse every request until fineract.tenant-management.bootstrap-master-username"
+ + " and bootstrap-master-password are set");
+ }
+ return;
+ }
+
+ if (store.findByUsername(username).isPresent()) {
+ log.info("Master user [{}] already exists; bootstrap configuration left unapplied", username);
+ return;
+ }
+
+ if (password.length() < MINIMUM_PASSWORD_LENGTH) {
+ // Refused, not stored and not logged: the application still starts, but no master
+ // user with a guessable password comes into existence.
+ log.error(
+ "Bootstrap password for master user [{}] is shorter than {} characters; no master user"
+ + " was created",
+ username,
+ MINIMUM_PASSWORD_LENGTH);
+ return;
+ }
+
+ try {
+ store.create(
+ username,
+ PasswordEncoderFactories.createDelegatingPasswordEncoder().encode(password),
+ TenantMasterAccess.SUPER_MASTER_ROLE);
+ } catch (final DuplicateKeyException e) {
+ // Nodes starting together on a first deployment can all find the user absent and all
+ // try to insert it; the unique username lets exactly one succeed. Losing that race is
+ // success once the user is confirmed to exist - failing startup over it would stop
+ // every node but the winner.
+ if (store.findByUsername(username).isEmpty()) {
+ throw e;
+ }
+ log.info("Master user [{}] was created concurrently by another node", username);
+ return;
+ }
+ log.info("Created tenant management master user [{}]", username);
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/security/TenantMasterUserStore.java b/src/main/java/org/apache/fineract/tenant/security/TenantMasterUserStore.java
new file mode 100644
index 00000000..f795c9a5
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/security/TenantMasterUserStore.java
@@ -0,0 +1,94 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.security;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Optional;
+import javax.sql.DataSource;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * Reads and creates tenant administration master users in the tenant store.
+ *
+ *
Deliberately not a Spring {@code UserDetailsService} bean. Fineract registers its own user
+ * details services and authentication manager, and a second bean of those types could be injected
+ * by type where the platform expects its own. {@link TenantManagementSecurityConfiguration} adapts
+ * this store for its chain alone.
+ */
+@Component
+public class TenantMasterUserStore {
+
+ /**
+ * A master user as stored.
+ *
+ * @param username login name, unique across the installation
+ * @param passwordHash Spring Security delegating hash, never a clear password
+ * @param role the role granted, {@link TenantMasterAccess#SUPER_MASTER_ROLE} today
+ * @param enabled whether the user may authenticate
+ */
+ public record MasterUser(String username, String passwordHash, String role, boolean enabled) {}
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public TenantMasterUserStore(
+ @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource) {
+ this.jdbcTemplate = new JdbcTemplate(tenantStoreDataSource);
+ }
+
+ /**
+ * @param username login name as supplied, possibly null
+ * @return the user, or empty when none has that name
+ */
+ public Optional findByUsername(final String username) {
+ if (username == null || username.isBlank()) {
+ return Optional.empty();
+ }
+ final List users =
+ jdbcTemplate.query(
+ "select username, password_hash, role, enabled from tenant_master_user"
+ + " where username = ?",
+ (rs, rowNum) ->
+ new MasterUser(
+ rs.getString("username"),
+ rs.getString("password_hash"),
+ rs.getString("role"),
+ rs.getBoolean("enabled")),
+ username);
+ return users.stream().findFirst();
+ }
+
+ /**
+ * @return how many master users exist
+ */
+ public long count() {
+ final Long count =
+ jdbcTemplate.queryForObject("select count(*) from tenant_master_user", Long.class);
+ return count == null ? 0 : count;
+ }
+
+ /**
+ * Stores a new master user.
+ *
+ * @param passwordHash an already-encoded hash; this method never sees a clear password
+ */
+ public void create(final String username, final String passwordHash, final String role) {
+ jdbcTemplate.update(
+ "insert into tenant_master_user (username, password_hash, role, enabled, created_at)"
+ + " values (?, ?, ?, ?, ?)",
+ username,
+ passwordHash,
+ role,
+ Boolean.TRUE,
+ // Zone-less column, written as a UTC wall-clock value like every other registry
+ // timestamp this feature writes.
+ LocalDateTime.now(ZoneOffset.UTC));
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/service/TenantAdministrationAuditService.java b/src/main/java/org/apache/fineract/tenant/service/TenantAdministrationAuditService.java
new file mode 100644
index 00000000..437d4e95
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/service/TenantAdministrationAuditService.java
@@ -0,0 +1,119 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.service;
+
+import java.time.LocalDateTime;
+import java.time.ZoneOffset;
+import javax.sql.DataSource;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.fineract.tenant.domain.TenantAdministrationAction;
+import org.apache.fineract.tenant.security.TenantMasterAccess;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+/**
+ * Records tenant administration actions to the audit trail in the tenant store.
+ *
+ *
MX-406 requires audit logging of all tenant management actions. Application logs are not that:
+ * they rotate away, and they are not queryable by an auditor. This writes a durable row per action
+ * beside the registry it describes, so the trail outlives any tenant it mentions.
+ *
+ *
Nothing secret is recorded. {@code detail} carries the names of the fields a
+ * request changed, never their values, so a password rotation is recorded as having happened while
+ * the password itself never reaches the table - SOUL_GUARDRAILS: minimise sensitive data in logs,
+ * and redact confidential fields before recording them.
+ *
+ *
A failure to write the trail never fails the action that was being recorded. Losing an audit
+ * row is bad; rolling back a completed tenant change because its bookkeeping failed, and leaving
+ * the registry inconsistent with what the caller was told, is worse.
+ */
+@Service
+@Slf4j
+public class TenantAdministrationAuditService {
+
+ /** Recorded when an action completed. */
+ private static final String OUTCOME_SUCCESS = "SUCCESS";
+
+ /** Recorded when an action was attempted and failed. */
+ private static final String OUTCOME_FAILURE = "FAILURE";
+
+ private static final int MAX_DETAIL_LENGTH = 1000;
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public TenantAdministrationAuditService(
+ @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource) {
+ this.jdbcTemplate = new JdbcTemplate(tenantStoreDataSource);
+ }
+
+ /** Records an action that completed. */
+ public void recordSuccess(
+ final TenantAdministrationAction action,
+ final String tenantIdentifier,
+ final Long tenantId,
+ final String detail) {
+ record(action, OUTCOME_SUCCESS, tenantIdentifier, tenantId, detail);
+ }
+
+ /** Records an action that was attempted and failed. */
+ public void recordFailure(
+ final TenantAdministrationAction action,
+ final String tenantIdentifier,
+ final Long tenantId,
+ final String detail) {
+ record(action, OUTCOME_FAILURE, tenantIdentifier, tenantId, detail);
+ }
+
+ private void record(
+ final TenantAdministrationAction action,
+ final String outcome,
+ final String tenantIdentifier,
+ final Long tenantId,
+ final String detail) {
+ try {
+ // created_at is zone-less; it is written as a UTC wall-clock value so every node
+ // records the same value for the same instant, whatever its JVM time zone.
+ jdbcTemplate.update(
+ "insert into tenant_administration_audit (action, outcome, tenant_identifier, tenant_id,"
+ + " performed_by, performed_by_tenant, detail, created_at)"
+ + " values (?, ?, ?, ?, ?, ?, ?, ?)",
+ action.name(),
+ outcome,
+ tenantIdentifier,
+ tenantId,
+ currentUsername(),
+ // Master users act outside every tenant, so no acting tenant is recorded.
+ null,
+ truncated(detail),
+ LocalDateTime.now(ZoneOffset.UTC));
+ } catch (final RuntimeException e) {
+ log.error(
+ "Could not record tenant administration audit for {} on tenant {}",
+ action,
+ tenantIdentifier,
+ e);
+ }
+ }
+
+ /**
+ * @return the acting master user's name, or null when the request is not authenticated as one -
+ * the row is still written, because an unattributed action is more worth recording than not
+ * recording it
+ */
+ private String currentUsername() {
+ return TenantMasterAccess.currentSuperMaster().orElse(null);
+ }
+
+ /** Keeps detail inside the column, so an oversized value cannot fail the insert. */
+ private static String truncated(final String detail) {
+ if (detail == null || detail.length() <= MAX_DETAIL_LENGTH) {
+ return detail;
+ }
+ return detail.substring(0, MAX_DETAIL_LENGTH - 3) + "...";
+ }
+}
diff --git a/src/main/java/org/apache/fineract/tenant/service/TenantManagementReadService.java b/src/main/java/org/apache/fineract/tenant/service/TenantManagementReadService.java
new file mode 100644
index 00000000..40e16571
--- /dev/null
+++ b/src/main/java/org/apache/fineract/tenant/service/TenantManagementReadService.java
@@ -0,0 +1,172 @@
+/**
+ * Copyright since 2026 Mifos Initiative
+ *
+ *
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy
+ * of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+package org.apache.fineract.tenant.service;
+
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import javax.sql.DataSource;
+import org.apache.fineract.infrastructure.core.service.Page;
+import org.apache.fineract.tenant.data.TenantData;
+import org.apache.fineract.tenant.data.TenantTemplateData;
+import org.apache.fineract.tenant.domain.TenantStatus;
+import org.apache.fineract.tenant.exception.TenantNotFoundException;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.dao.EmptyResultDataAccessException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+/**
+ * Reads the central tenant registry.
+ *
+ *
Why JDBC rather than JPA. The registry lives in the tenant store database,
+ * reached through the {@code hikariTenantDataSource} bean, which is a different datasource from the
+ * per-tenant {@code routingDataSource} this plugin's entities are mapped against. A Spring Data
+ * repository would be bound to the tenant's own schema and would not find these tables at all, so
+ * the registry is queried directly - the same approach Fineract core takes in {@code
+ * JdbcTenantDetailsService}.
+ */
+@Service
+public class TenantManagementReadService {
+
+ /**
+ * Largest page this endpoint will return.
+ *
+ *
Caps an unbounded {@code limit} so one request cannot pull an entire registry into memory,
+ * and so a client that omits the parameter gets a page rather than everything.
+ */
+ static final int MAX_PAGE_SIZE = 200;
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public TenantManagementReadService(
+ @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource) {
+ this.jdbcTemplate = new JdbcTemplate(tenantStoreDataSource);
+ }
+
+ /**
+ * Lists tenants, newest registry entries last.
+ *
+ * @param search matched case insensitively against identifier and name, ignored when blank
+ * @param status restricts to one lifecycle state, ignored when null
+ * @param offset rows to skip, treated as 0 when null or negative
+ * @param limit rows to return, clamped to {@link #MAX_PAGE_SIZE}
+ * @return the matching page together with the total number of matches
+ */
+ public Page retrieveAll(
+ final String search, final TenantStatus status, final Integer offset, final Integer limit) {
+
+ final StringBuilder where = new StringBuilder(" where 1 = 1 ");
+ final List