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: + * + *

+ * + *

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 arguments = new ArrayList<>(); + + if (search != null && !search.isBlank()) { + // Bound as a parameter rather than concatenated, so the term - which comes + // straight off a query string - cannot alter the statement. + // + // Binding alone does NOT make the term literal: LIKE still reads % and _ inside + // a bound value as wildcards, so a search for "%" would match every tenant. The + // term is therefore escaped as well, which is what makes the search mean what + // the user typed. + where.append( + " and (lower(t.identifier) like ? escape '!' or lower(t.name) like ? escape '!') "); + final String term = "%" + escapeLikeWildcards(search.trim().toLowerCase(Locale.ROOT)) + "%"; + arguments.add(term); + arguments.add(term); + } + if (status != null) { + where.append(" and t.status = ? "); + arguments.add(status.name()); + } + + final Integer total = + this.jdbcTemplate.queryForObject( + "select count(*) from tenants t " + where, Integer.class, arguments.toArray()); + + final int pageSize = + limit == null || limit <= 0 ? MAX_PAGE_SIZE : Math.min(limit, MAX_PAGE_SIZE); + final int rowsToSkip = offset == null || offset < 0 ? 0 : offset; + + final List pagedArguments = new ArrayList<>(arguments); + pagedArguments.add(pageSize); + pagedArguments.add(rowsToSkip); + + final List tenants = + this.jdbcTemplate.query( + "select " + TenantRowMapper.SELECT_SCHEMA + where + " order by t.id limit ? offset ?", + new TenantRowMapper(), + pagedArguments.toArray()); + + return new Page<>(tenants, total == null ? tenants.size() : total); + } + + /** + * Escapes the characters {@code LIKE} treats as wildcards, so a search matches literally. + * + *

{@code !} is used as the escape character rather than the more usual backslash: MySQL + * additionally treats a backslash as an escape inside string literals, so a backslash-escaped + * pattern has to be written differently there than on PostgreSQL. {@code !} has no special + * meaning to either, so one expression works on both. + * + *

The escape character itself is escaped first, or escaping {@code %} would then re-escape the + * {@code !} that had just been introduced. + */ + private static String escapeLikeWildcards(final String term) { + return term.replace("!", "!!").replace("%", "!%").replace("_", "!_"); + } + + /** + * @param id primary key in {@code tenants} + * @return the tenant + * @throws TenantNotFoundException when no tenant has that id + */ + public TenantData retrieveOne(final Long id) { + try { + return this.jdbcTemplate.queryForObject( + "select " + TenantRowMapper.SELECT_SCHEMA + " where t.id = ?", new TenantRowMapper(), id); + } catch (final EmptyResultDataAccessException e) { + throw new TenantNotFoundException(id); + } + } + + /** + * @param identifier tenant identifier, compared exactly + * @return true when a tenant already holds this identifier + */ + public boolean existsByIdentifier(final String identifier) { + final Integer count = + this.jdbcTemplate.queryForObject( + "select count(*) from tenants where identifier = ?", Integer.class, identifier); + return count != null && count > 0; + } + + /** + * @return the options an administration client needs to build its create and edit forms + */ + public TenantTemplateData retrieveTemplate() { + return new TenantTemplateData(retrieveTimezones(), TenantStatus.names()); + } + + /** + * Lists selectable time zones. + * + *

Prefers the registry's own {@code timezones} table, so an installation that has curated that + * list keeps control of what administrators may choose. That table ships empty in some + * deployments, so an empty result falls back to the zones this JVM knows - which is what the + * platform ultimately resolves a tenant's zone against anyway. Falling back beats returning an + * empty picker the UI cannot complete a form with. + */ + private List retrieveTimezones() { + final List configured = + this.jdbcTemplate.queryForList( + "select timezonename from timezones order by timezonename", String.class); + if (!configured.isEmpty()) { + return configured; + } + return ZoneId.getAvailableZoneIds().stream().sorted().toList(); + } +} diff --git a/src/main/java/org/apache/fineract/tenant/service/TenantManagementWriteService.java b/src/main/java/org/apache/fineract/tenant/service/TenantManagementWriteService.java new file mode 100644 index 00000000..55057203 --- /dev/null +++ b/src/main/java/org/apache/fineract/tenant/service/TenantManagementWriteService.java @@ -0,0 +1,716 @@ +/** + * 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.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import javax.sql.DataSource; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor; +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.TenantAdministrationAction; +import org.apache.fineract.tenant.domain.TenantStatus; +import org.apache.fineract.tenant.exception.TenantIdentifierAlreadyExistsException; +import org.apache.fineract.tenant.exception.TenantNotFoundException; +import org.apache.fineract.tenant.exception.TenantSchemaUnavailableException; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Creates and changes tenants in the central registry. + * + *

Writes go through a {@link TransactionTemplate} bound to the tenant store rather than + * {@code @Transactional}: the platform's primary transaction manager governs the per-tenant + * datasource, so an annotation here would open a transaction against the wrong database and leave + * these statements committing one by one. + * + *

Credentials are encrypted with core's {@link DatabasePasswordEncryptor} before they are + * stored, so a tenant created through this API is protected exactly like one created by hand, and + * is readable by the same platform that reads every other tenant. + */ +@Service +@Slf4j +public class TenantManagementWriteService { + + private final JdbcTemplate jdbcTemplate; + private final DataSource tenantStoreDataSource; + + /** Name of the tenant store's own database, read once on first use. */ + private volatile String tenantStoreCatalog; + + private final TransactionTemplate transactionTemplate; + private final DatabasePasswordEncryptor databasePasswordEncryptor; + private final TenantProvisioningService provisioningService; + private final TenantManagementReadService readService; + private final TenantStatusLookupService statusLookupService; + private final TenantSchemaMigrationService schemaMigrationService; + private final TenantAdministrationAuditService auditService; + private final ObjectProvider cacheManagerProvider; + + /** + * Whether a newly created tenant's schema is migrated immediately. + * + *

On by default, because a tenant whose tables only appear at the next restart is not really + * created. Operators who would rather migrate on their own schedule - a large installation where + * a migration is a planned event - can turn it off, and core's startup migration will pick the + * tenant up as it always has. + */ + private final boolean migrateOnCreate; + + public TenantManagementWriteService( + @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource, + @Qualifier("tenantStoreTransactionTemplate") final TransactionTemplate transactionTemplate, + final DatabasePasswordEncryptor databasePasswordEncryptor, + final TenantProvisioningService provisioningService, + final TenantManagementReadService readService, + final TenantStatusLookupService statusLookupService, + final TenantSchemaMigrationService schemaMigrationService, + final TenantAdministrationAuditService auditService, + final ObjectProvider cacheManagerProvider, + @Value("${fineract.tenant-management.migrate-on-create:true}") + final boolean migrateOnCreate) { + this.jdbcTemplate = new JdbcTemplate(tenantStoreDataSource); + this.tenantStoreDataSource = tenantStoreDataSource; + this.transactionTemplate = transactionTemplate; + this.databasePasswordEncryptor = databasePasswordEncryptor; + this.provisioningService = provisioningService; + this.readService = readService; + this.statusLookupService = statusLookupService; + this.schemaMigrationService = schemaMigrationService; + this.auditService = auditService; + this.cacheManagerProvider = cacheManagerProvider; + this.migrateOnCreate = migrateOnCreate; + } + + /** + * Drops every cached view of a tenant after it has been changed. + * + *

Two caches hold tenant data and both go stale on a write: + * + *

    + *
  • this plugin's status cache, consulted by the enforcement filter, and + *
  • core's {@code tenantsById}, which caches {@code JdbcTenantDetailsService.loadTenantById} + * and therefore holds the connection details the platform routes on. + *
+ * + *

Without this a suspension would not take hold, and changed connection details would keep + * routing to the old server, until the caches expired or the platform restarted. + * + *

Core's cache is reached through the {@link CacheManager} beans rather than with + * {@code @CacheEvict}: the annotation would need this class to know the cache's key layout and + * which of the platform's several managers holds it. + */ + private void evictCachedViewsOf(final String identifier) { + statusLookupService.invalidate(identifier); + + // Best effort, and never allowed to escape. This runs after the registry write has + // committed, so a throw here turns a completed change into a 500 - and on create it + // also skips the migration, stranding a registered tenant with an empty schema. That + // is exactly what happened against a real Fineract before this guard existed. A stale + // cache entry expires; a stranded tenant does not. + try { + // Every manager, not "the" manager. A running Fineract registers several + // (runtimeDelegatingCacheManager, defaultCacheManager, ehCacheManager, cacheManager), + // and asking Spring for a single one throws NoUniqueBeanDefinitionException. + // Evicting in each is a no-op where the cache is absent and correct wherever it lives. + cacheManagerProvider + .orderedStream() + .forEach(cacheManager -> evictFrom(cacheManager, identifier)); + } catch (final RuntimeException e) { + log.warn("Could not enumerate cache managers to evict tenant {}", identifier, e); + } + } + + private static void evictFrom(final CacheManager cacheManager, final String identifier) { + try { + final Cache tenantsById = cacheManager.getCache("tenantsById"); + if (tenantsById != null) { + // Keyed by the single method argument, the tenant identifier. + tenantsById.evict(identifier); + } + } catch (final RuntimeException e) { + log.warn("Could not evict tenant {} from cache manager {}", identifier, cacheManager, e); + } + } + + /** + * Registers a new tenant and provisions its schema. + * + *

Ordering is deliberate. 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. The + * reverse order would publish a tenant into the registry that the platform then fails to route to + * on its next startup. + * + * @return the tenant as stored, without credentials + * @throws TenantIdentifierAlreadyExistsException when the identifier is taken + */ + public TenantData create(final TenantCreateRequest request) { + final String identifier = + TenantManagementDataValidator.normaliseIdentifier(request.identifier()); + + if (readService.existsByIdentifier(identifier)) { + throw new TenantIdentifierAlreadyExistsException(identifier); + } + + // Before anything is created: the schema step below reuses an existing database of this + // name, so ownership has to be settled first. + assertSchemaAvailableFor( + identifier, request.schemaServer(), request.schemaServerPort(), request.schemaName(), null); + + provisioningService.createSchemaIfAbsent( + request.schemaServer(), + request.schemaServerPort(), + request.schemaName(), + request.schemaConnectionParameters(), + request.schemaUsername(), + request.schemaPassword()); + + provisioningService.verifyReachable( + request.schemaServer(), + request.schemaServerPort(), + request.schemaName(), + request.schemaConnectionParameters(), + request.schemaUsername(), + request.schemaPassword()); + + final Long tenantId = + transactionTemplate.execute( + status -> { + // Reinstating a removed tenant under its own identifier releases the retention + // record; any other identifier was refused above. + releaseRetainedSchema( + identifier, + request.schemaServer(), + request.schemaServerPort(), + request.schemaName()); + final Long connectionId = insertConnection(request); + return insertTenant(request, identifier, connectionId); + }); + + evictCachedViewsOf(identifier); + + if (migrateOnCreate) { + migrateOrUndoRegistration(identifier, tenantId); + } + + auditService.recordSuccess( + TenantAdministrationAction.CREATE, + identifier, + tenantId, + "schemaName=" + request.schemaName() + ", status=" + request.status().name()); + + log.info("Registered tenant {} with schema {}", identifier, request.schemaName()); + return readService.retrieveOne(tenantId); + } + + /** + * Inserts the connection row. + * + *

{@code master_password_hash} is stamped with the running platform's hash. Core's {@code + * TenantDataSourceFactory} refuses to build a datasource for a tenant whose hash does not match + * its own, so omitting this would produce a tenant the platform cannot open - failing only later, + * at startup, with a bare "Invalid master password". + */ + private Long insertConnection(final TenantCreateRequest request) { + final String sql = + "insert into tenant_server_connections (schema_server, schema_name, schema_server_port," + + " schema_username, schema_password, schema_connection_parameters, auto_update," + + " master_password_hash) values (?, ?, ?, ?, ?, ?, ?, ?)"; + + final KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update( + connection -> { + // The generated column is named rather than asking for all generated keys. + // PostgreSQL answers the blanket form with the whole inserted row, so + // KeyHolder sees many "keys" and getKey() throws; MySQL answers with just + // the id, so the bug would only ever have appeared on PostgreSQL. Naming + // the column returns one value on both engines. + final PreparedStatement ps = connection.prepareStatement(sql, new String[] {"id"}); + ps.setString(1, request.schemaServer()); + ps.setString(2, request.schemaName()); + ps.setString(3, request.schemaServerPort()); + ps.setString(4, request.schemaUsername()); + ps.setString(5, databasePasswordEncryptor.encrypt(request.schemaPassword())); + ps.setString(6, request.schemaConnectionParameters()); + // Bound as an int, not a boolean. `auto_update` is declared TINYINT, which + // PostgreSQL maps to smallint and which rejects a boolean parameter outright + // ("column is of type smallint but expression is of type boolean"). MySQL and + // MariaDB accept either, so an int is the form that works on both engines. + ps.setInt(7, request.autoUpdate() ? 1 : 0); + ps.setString(8, databasePasswordEncryptor.getMasterPasswordHash()); + return ps; + }, + keyHolder); + + return requireKey(keyHolder, "tenant_server_connections"); + } + + /** + * Inserts the tenant row. + * + *

{@code oltp_id} and {@code report_id} both point at the one connection just created. The + * registry allows a separate reporting connection, but MX-406's UI collects a single set of + * connection details, and a tenant whose reporting connection is its operational one is the shape + * Fineract's own default tenant ships in. + */ + private Long insertTenant( + final TenantCreateRequest request, final String identifier, final Long connectionId) { + final String sql = + "insert into tenants (identifier, name, timezone_id, status, description, contact_email," + + " joined_date, created_date, oltp_id, report_id) values (?, ?, ?, ?, ?, ?, ?, ?, ?," + + " ?)"; + + final KeyHolder keyHolder = new GeneratedKeyHolder(); + try { + jdbcTemplate.update( + connection -> { + // Named generated column, for the same portability reason as above. + final PreparedStatement ps = connection.prepareStatement(sql, new String[] {"id"}); + ps.setString(1, identifier); + ps.setString(2, request.name()); + ps.setString(3, request.timezoneId()); + ps.setString(4, request.status().name()); + ps.setString(5, request.description()); + ps.setString(6, request.contactEmail()); + // The registry has carried joined_date since before this API existed, and + // TenantData returns it. Setting it here stops a tenant created through the + // API from being the only one with the field blank. + ps.setObject(7, LocalDate.now(ZoneOffset.UTC)); + // Registry timestamps are written as zone-less UTC wall-clock values. + // setTimestamp converts through the JVM's default time zone, so nodes + // configured differently would store different values for the same instant. + ps.setObject(8, LocalDateTime.now(ZoneOffset.UTC)); + ps.setLong(9, connectionId); + ps.setLong(10, connectionId); + return ps; + }, + keyHolder); + } catch (final DuplicateKeyException e) { + // The pre-check above is not a guarantee: two administrators can create the same + // identifier concurrently, and only the unique constraint settles it. Reported as + // the same clear conflict rather than a raw constraint violation. + throw new TenantIdentifierAlreadyExistsException(identifier); + } + + return requireKey(keyHolder, "tenants"); + } + + /** + * Migrates the new tenant, and unregisters it again if that fails. + * + *

The registry rows are already committed by this point - the migration needs them, because it + * re-reads the tenant through core's own services to get the connection and its decrypted + * credentials. So the failure path compensates rather than rolls back: the tenant is removed from + * the registry, leaving the installation as it was before the request. + * + *

The schema itself is left alone, exactly as {@link #delete(Long)} leaves it. It may hold a + * partially applied migration, and an administrator who retries will have it completed rather + * than restarted - Liquibase resumes from its own changelog table. Dropping it here would mean + * this API destroying a database as part of handling an error, which is precisely the capability + * it should not have. + */ + private void migrateOrUndoRegistration(final String identifier, final Long tenantId) { + try { + schemaMigrationService.migrate(identifier); + } catch (final RuntimeException e) { + log.error( + "Migration failed for new tenant {}; removing its registry entry. " + + "The schema was left in place and was not dropped.", + identifier, + e); + auditService.recordFailure( + TenantAdministrationAction.CREATE, identifier, tenantId, "schema migration failed"); + try { + final TenantData registered = readService.retrieveOne(tenantId); + removeRegistryRows( + tenantId, registered.connection() == null ? null : registered.connection().id()); + evictCachedViewsOf(identifier); + } catch (final RuntimeException cleanupFailure) { + // Reported but not rethrown: the migration failure is the real error and + // must reach the caller. A stranded row is recoverable by hand; swapping + // the exception would hide why the request failed at all. + log.error("Could not remove the registry entry for tenant {}", identifier, cleanupFailure); + } + throw e; + } + } + + /** + * Deletes a tenant's registry rows. + * + *

The tenant row goes first: {@code oltp_id} and {@code report_id} reference the connection + * with ON DELETE RESTRICT, so removing the connection first would be refused. + */ + private void removeRegistryRows(final Long tenantId, final Long connectionId) { + transactionTemplate.executeWithoutResult( + status -> { + jdbcTemplate.update("delete from tenants where id = ?", tenantId); + if (connectionId != null) { + jdbcTemplate.update("delete from tenant_server_connections where id = ?", connectionId); + } + }); + } + + /** + * Applies a partial update. Fields left null on the request keep their stored value. + * + * @throws TenantNotFoundException when no tenant has that id + */ + public TenantData update(final Long id, final TenantUpdateRequest request) { + final TenantData existing = readService.retrieveOne(id); + + if (existing.connection() != null + && (request.schemaServer() != null || request.schemaServerPort() != null)) { + // Moving a tenant to another server or port can land it on a database that belongs to + // someone else, so the same ownership rules as create apply. + assertSchemaAvailableFor( + existing.identifier(), + request.schemaServer() != null + ? request.schemaServer() + : existing.connection().schemaServer(), + request.schemaServerPort() != null + ? request.schemaServerPort() + : existing.connection().schemaServerPort(), + existing.connection().schemaName(), + id); + } + + transactionTemplate.executeWithoutResult( + status -> { + updateTenantRow(id, request); + updateConnectionRow(existing, request); + }); + + evictCachedViewsOf(existing.identifier()); + auditService.recordSuccess( + TenantAdministrationAction.UPDATE, + existing.identifier(), + id, + "changed: " + String.join(", ", request.changedFieldNames())); + + log.info("Updated tenant {}", existing.identifier()); + return readService.retrieveOne(id); + } + + private void updateTenantRow(final Long id, final TenantUpdateRequest request) { + final List assignments = new ArrayList<>(); + final List arguments = new ArrayList<>(); + + addAssignment(assignments, arguments, "name", request.name()); + addAssignment(assignments, arguments, "timezone_id", request.timezoneId()); + addAssignment(assignments, arguments, "description", request.description()); + addAssignment(assignments, arguments, "contact_email", request.contactEmail()); + + if (assignments.isEmpty()) { + return; + } + + assignments.add("lastmodified_date = ?"); + arguments.add(LocalDateTime.now(ZoneOffset.UTC)); + arguments.add(id); + + // Column names come only from the literals above, never from the request, so the + // joined fragment carries no caller-supplied text. Every value is bound. + jdbcTemplate.update( + "update tenants set " + String.join(", ", assignments) + " where id = ?", + arguments.toArray()); + } + + private void updateConnectionRow(final TenantData existing, final TenantUpdateRequest request) { + if (existing.connection() == null) { + return; + } + + final List assignments = new ArrayList<>(); + final List arguments = new ArrayList<>(); + + addAssignment(assignments, arguments, "schema_server", request.schemaServer()); + addAssignment(assignments, arguments, "schema_server_port", request.schemaServerPort()); + addAssignment(assignments, arguments, "schema_username", request.schemaUsername()); + addAssignment( + assignments, + arguments, + "schema_connection_parameters", + request.schemaConnectionParameters()); + + if (request.schemaPassword() != null) { + // Re-encrypted on the way in, and the master hash re-stamped alongside it so the + // row stays openable by this platform. + assignments.add("schema_password = ?"); + arguments.add(databasePasswordEncryptor.encrypt(request.schemaPassword())); + assignments.add("master_password_hash = ?"); + arguments.add(databasePasswordEncryptor.getMasterPasswordHash()); + } + if (request.autoUpdate() != null) { + assignments.add("auto_update = ?"); + // An int for the same reason as on insert: the column is TINYINT, and + // PostgreSQL will not accept a boolean for it. + arguments.add(request.autoUpdate() ? 1 : 0); + } + + if (assignments.isEmpty()) { + return; + } + + arguments.add(existing.connection().id()); + jdbcTemplate.update( + "update tenant_server_connections set " + String.join(", ", assignments) + " where id = ?", + arguments.toArray()); + } + + /** + * Moves a tenant between lifecycle states. + * + * @return the tenant as stored + * @throws TenantNotFoundException when no tenant has that id + */ + public TenantData changeStatus(final Long id, final TenantStatus status) { + final TenantData existing = readService.retrieveOne(id); + + if (existing.status() == status) { + // Idempotent: re-activating an active tenant is not an error, and reporting one + // would make a retried request look like a failure. + return existing; + } + + jdbcTemplate.update( + "update tenants set status = ?, lastmodified_date = ? where id = ?", + status.name(), + LocalDateTime.now(ZoneOffset.UTC), + id); + + evictCachedViewsOf(existing.identifier()); + auditService.recordSuccess( + TenantAdministrationAction.forStatusChange(status), + existing.identifier(), + id, + "from=" + + (existing.status() == null ? "UNRECOGNISED" : existing.status().name()) + + ", to=" + + status.name()); + + log.info("Tenant {} moved from {} to {}", existing.identifier(), existing.status(), status); + return readService.retrieveOne(id); + } + + /** + * Removes a tenant from the registry. + * + *

This never drops a schema or deletes tenant data. It removes the routing + * entry only, which is the archive semantics MX-406 asks for: the tenant stops being reachable, + * and its database is left intact for retention, audit or reinstatement. Dropping a live + * financial database from an HTTP endpoint is not a capability this API should have. + * + *

Refuses to remove an active tenant. Deactivating first is one extra call, and it makes + * removal a deliberate two-step action rather than something a single mistaken request can do to + * a tenant that is currently serving users. + * + * @throws TenantNotFoundException when no tenant has that id + */ + public void delete(final Long id) { + final TenantData existing = readService.retrieveOne(id); + + if (existing.status() == TenantStatus.ACTIVE) { + throw activeTenantCannotBeRemoved(existing.identifier()); + } + + retireRegistryRows(existing); + + evictCachedViewsOf(existing.identifier()); + // tenantId is recorded as null: the row it referred to no longer exists, and the + // trail must not point at an id that could later be reused by another tenant. + auditService.recordSuccess( + TenantAdministrationAction.DELETE, + existing.identifier(), + null, + "schema left intact: " + + (existing.connection() == null ? "(unknown)" : existing.connection().schemaName())); + + log.info( + "Removed tenant {} from the registry; its schema {} was left intact", + existing.identifier(), + existing.connection() == null ? "(unknown)" : existing.connection().schemaName()); + } + + /** + * Refuses a database this tenant must not be bound to. + * + *

Creating a tenant reuses an existing database of the requested name and removing one keeps + * its database, so without these checks a new identifier could be routed to another tenant's live + * data or to a removed tenant's retained data. Database names are compared case-insensitively, as + * PostgreSQL folds unquoted names and MySQL is commonly case-insensitive; servers are compared as + * written, so {@code localhost} and {@code 127.0.0.1} count as different servers. + * + * @param excludeTenantId the tenant being updated, which may keep its own database; null on + * create + * @throws TenantSchemaUnavailableException when the database belongs elsewhere + */ + private void assertSchemaAvailableFor( + final String identifier, + final String schemaServer, + final String schemaServerPort, + final String schemaName, + final Long excludeTenantId) { + + if (schemaName.equalsIgnoreCase(tenantStoreCatalog())) { + throw TenantSchemaUnavailableException.tenantStore(schemaName); + } + + final List owners = + jdbcTemplate.queryForList( + "select t.identifier from tenants t join tenant_server_connections ts" + + " on ts.id = t.oltp_id or ts.id = t.report_id" + + " where lower(ts.schema_name) = lower(?) and lower(ts.schema_server) = lower(?)" + + " and ts.schema_server_port = ? and t.id <> ?", + String.class, + schemaName, + schemaServer, + schemaServerPort, + excludeTenantId == null ? -1L : excludeTenantId); + if (!owners.isEmpty()) { + throw TenantSchemaUnavailableException.inUse(schemaName, owners.get(0)); + } + + final List retainedFor = + jdbcTemplate.queryForList( + "select tenant_identifier from tenant_retained_schema" + + " where lower(schema_name) = lower(?) and lower(schema_server) = lower(?)" + + " and schema_server_port = ?", + String.class, + schemaName, + schemaServer, + schemaServerPort); + for (final String owner : retainedFor) { + if (!owner.equals(identifier)) { + throw TenantSchemaUnavailableException.retained(schemaName, owner); + } + } + } + + /** Drops the retention record when a removed tenant is created again under its own identifier. */ + private void releaseRetainedSchema( + final String identifier, + final String schemaServer, + final String schemaServerPort, + final String schemaName) { + jdbcTemplate.update( + "delete from tenant_retained_schema where tenant_identifier = ?" + + " and lower(schema_name) = lower(?) and lower(schema_server) = lower(?)" + + " and schema_server_port = ?", + identifier, + schemaName, + schemaServer, + schemaServerPort); + } + + /** + * Removes a tenant's registry rows and records its retained database, in one transaction. + * + *

The database is kept, so its ownership is kept with it: only this identifier may bind to it + * again. Compensation after a failed create uses {@link #removeRegistryRows} instead, which + * records nothing - that schema never held a working tenant, and a retry must be able to reuse + * it. + */ + private void retireRegistryRows(final TenantData existing) { + transactionTemplate.executeWithoutResult( + status -> { + // Conditional on status in the same statement: a concurrent activation that commits + // after delete()'s own check must still stop the removal. + final int removed = + jdbcTemplate.update( + "delete from tenants where id = ? and status <> ?", + existing.id(), + TenantStatus.ACTIVE.name()); + if (removed == 0) { + final Integer remaining = + jdbcTemplate.queryForObject( + "select count(*) from tenants where id = ?", Integer.class, existing.id()); + if (remaining == null || remaining == 0) { + throw new TenantNotFoundException(existing.id()); + } + throw activeTenantCannotBeRemoved(existing.identifier()); + } + if (existing.connection() != null) { + jdbcTemplate.update( + "delete from tenant_server_connections where id = ?", existing.connection().id()); + jdbcTemplate.update( + "insert into tenant_retained_schema (tenant_identifier, schema_server," + + " schema_server_port, schema_name, retained_at) values (?, ?, ?, ?, ?)", + existing.identifier(), + existing.connection().schemaServer(), + existing.connection().schemaServerPort(), + existing.connection().schemaName(), + LocalDateTime.now(ZoneOffset.UTC)); + } + }); + } + + /** + * @return the tenant store's own database name, or an empty string if the driver reports none + */ + private String tenantStoreCatalog() { + String catalog = this.tenantStoreCatalog; + if (catalog != null) { + return catalog; + } + try (Connection connection = tenantStoreDataSource.getConnection()) { + catalog = connection.getCatalog(); + } catch (final SQLException e) { + throw new IllegalStateException("Could not read the tenant store database name", e); + } + this.tenantStoreCatalog = catalog == null ? "" : catalog; + return this.tenantStoreCatalog; + } + + private static GeneralPlatformDomainRuleException activeTenantCannotBeRemoved( + final String identifier) { + return new GeneralPlatformDomainRuleException( + "error.msg.tenant.cannot.be.removed.while.active", + "Tenant " + identifier + " must be deactivated before it is removed", + identifier); + } + + private static void addAssignment( + final List assignments, + final List arguments, + final String column, + final Object value) { + if (value != null) { + assignments.add(column + " = ?"); + // An empty string is an explicit clear of an optional field, stored as NULL like a + // field that was never set. + arguments.add("".equals(value) ? null : value); + } + } + + private static Long requireKey(final KeyHolder keyHolder, final String table) { + final Number key = keyHolder.getKey(); + if (key == null) { + throw new IllegalStateException("No generated key returned when inserting into " + table); + } + return key.longValue(); + } +} diff --git a/src/main/java/org/apache/fineract/tenant/service/TenantProvisioningService.java b/src/main/java/org/apache/fineract/tenant/service/TenantProvisioningService.java new file mode 100644 index 00000000..04668fc3 --- /dev/null +++ b/src/main/java/org/apache/fineract/tenant/service/TenantProvisioningService.java @@ -0,0 +1,272 @@ +/** + * 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 static org.apache.fineract.infrastructure.core.domain.FineractPlatformTenantConnection.toJdbcUrl; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Locale; +import java.util.Properties; +import java.util.function.Predicate; +import javax.sql.DataSource; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.tenant.domain.TenantSchemaName; +import org.apache.fineract.tenant.exception.TenantConnectionFailedException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Service; + +/** + * Reaches the database behind a tenant: checks it is usable, and creates its schema. + * + *

Scope. MX-406 asks for "automatic or semi-automatic" provisioning. This + * service does the semi-automatic half - it creates the empty schema and verifies the credentials + * work. Populating that schema with Fineract's tables is left to core's existing {@code + * TenantDatabaseUpgradeService}, which already migrates every registered tenant with {@code + * auto_update} set on startup. Reusing that path rather than re-running the full Fineract changelog + * inline keeps one migration mechanism in the installation instead of two that can disagree, and + * means a tenant created here is provisioned exactly like every tenant created before it. + */ +@Service +@Slf4j +public class TenantProvisioningService { + + /** + * Re-asserted here even though {@code TenantManagementDataValidator} already enforces it. + * + *

A schema name cannot be bound as a JDBC parameter, so it is concatenated into DDL below. + * Checking again at the point of concatenation means this class is safe on its own terms and + * stays safe if it ever gains a second caller that forgets to validate first. + */ + private static final Predicate SAFE_SCHEMA_NAME = TenantSchemaName::isValid; + + /** Seconds to wait for a connection before calling the database unreachable. */ + private static final int CONNECTION_TIMEOUT_SECONDS = 10; + + /** + * The tenant store's datasource, used only to learn which database engine this installation runs + * on. Core derives the JDBC protocol from a datasource rather than from a driver name, and every + * tenant on an installation sits on the same engine as the registry. + */ + private final DataSource tenantStoreDataSource; + + public TenantProvisioningService( + @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource) { + this.tenantStoreDataSource = tenantStoreDataSource; + } + + /** Resolved once, on first use; every tenant sits on the registry's engine. */ + private volatile String jdbcProtocol; + + /** + * Returns the JDBC protocol of this installation, for example {@code jdbc:postgresql}. + * + *

Why not core's helper. Fineract has changed this helper between versions: + * the 1.15 artefact this plugin compiles against offers {@code toProtocol(DataSource)}, while the + * 1.16 runtime in {@code apache/fineract:develop} has only {@code resolveProtocol(String)} - so + * code linked against either fails with {@code NoSuchMethodError} on the other. Reading the + * protocol off the tenant store's own JDBC URL depends on no core API at all, and yields exactly + * the form {@code toJdbcUrl} expects: the text before {@code ://}. + */ + private String jdbcProtocol() { + String protocol = this.jdbcProtocol; + if (protocol != null) { + return protocol; + } + try (Connection connection = tenantStoreDataSource.getConnection()) { + final String url = connection.getMetaData().getURL(); + final int separator = url == null ? -1 : url.indexOf("://"); + if (separator <= 0 || !url.startsWith("jdbc:")) { + throw new IllegalStateException("Unrecognised tenant store JDBC URL shape"); + } + protocol = url.substring(0, separator); + this.jdbcProtocol = protocol; + return protocol; + } catch (final SQLException e) { + throw new IllegalStateException("Could not read the tenant store JDBC URL", e); + } + } + + /** + * Checks that a database can be reached with the supplied details. + * + * @param plainPassword the password as typed by the administrator, not the encrypted form + * @throws TenantConnectionFailedException when the database cannot be reached + */ + public void verifyReachable( + final String schemaServer, + final String schemaServerPort, + final String schemaName, + final String connectionParameters, + final String schemaUsername, + final String plainPassword) { + + final String protocol = jdbcProtocol(); + final String url = + toJdbcUrl(protocol, schemaServer, schemaServerPort, schemaName, connectionParameters); + + try (Connection connection = openConnection(url, schemaUsername, plainPassword)) { + if (!connection.isValid(CONNECTION_TIMEOUT_SECONDS)) { + throw new SQLException("Connection opened but did not validate"); + } + } catch (final SQLException e) { + // The driver's own message routinely echoes the JDBC URL and user back, so it is + // logged rather than returned. SOUL_GUARDRAILS: no infrastructure detail in + // responses. + log.warn( + "Tenant database at {}:{}/{} could not be reached", + schemaServer, + schemaServerPort, + schemaName, + e); + throw new TenantConnectionFailedException(schemaServer, schemaServerPort, schemaName, e); + } + } + + /** + * Non-throwing form of {@link #verifyReachable}, for the test-connection endpoint. + * + * @return true when the database answered + */ + public boolean isReachable( + final String schemaServer, + final String schemaServerPort, + final String schemaName, + final String connectionParameters, + final String schemaUsername, + final String plainPassword) { + try { + verifyReachable( + schemaServer, + schemaServerPort, + schemaName, + connectionParameters, + schemaUsername, + plainPassword); + return true; + } catch (final TenantConnectionFailedException e) { + return false; + } + } + + /** + * Creates the tenant's schema if it does not already exist. + * + *

An existing schema is left untouched and reported as success: an administrator who + * pre-created the schema, or who is retrying a half-finished create, should not be blocked. This + * never drops or empties anything. + * + * @param plainPassword password of a user permitted to create schemas on that server + * @throws TenantConnectionFailedException when the server cannot be reached or refuses the DDL + */ + public void createSchemaIfAbsent( + final String schemaServer, + final String schemaServerPort, + final String schemaName, + final String connectionParameters, + final String schemaUsername, + final String plainPassword) { + + if (!SAFE_SCHEMA_NAME.test(schemaName)) { + // Defensive: an unvalidated name must never reach the concatenation below. + throw new IllegalArgumentException("Unsafe schema name rejected before DDL"); + } + + final String protocol = jdbcProtocol(); + final boolean postgres = protocol.toLowerCase(Locale.ROOT).contains("postgres"); + + // CREATE DATABASE needs a connection to some *other* database on the same server. + // PostgreSQL always has `postgres`; MySQL and MariaDB accept a connection with no + // database selected at all. + final String adminUrl = + toJdbcUrl( + protocol, + schemaServer, + schemaServerPort, + postgres ? "postgres" : "", + connectionParameters); + + try (Connection connection = openConnection(adminUrl, schemaUsername, plainPassword)) { + if (schemaExists(connection, schemaName, postgres)) { + log.info("Schema {} already exists; leaving it untouched", schemaName); + return; + } + try (Statement statement = connection.createStatement()) { + // PostgreSQL has no IF NOT EXISTS for CREATE DATABASE, which is why existence is + // checked separately above rather than delegated to the database. + // + // The name is concatenated because no driver can bind an identifier as a parameter. + // That is safe only because SAFE_SCHEMA_NAME has admitted nothing but letters, + // digits and underscore, starting with a letter or underscore. + statement.executeUpdate("CREATE DATABASE " + schemaName); + log.info("Created schema {} for a new tenant", schemaName); + } catch (final SQLException ddlFailure) { + // Two creates for the same schema can both pass the existence check above; the + // database then accepts one CREATE and rejects the other as a duplicate. If the + // schema exists now, the outcome this call was asked for has happened, so the + // loser of that race is not an error. Anything else is rethrown untouched. + if (schemaExists(connection, schemaName, postgres)) { + log.info("Schema {} was created concurrently; treating it as present", schemaName); + return; + } + throw ddlFailure; + } + } catch (final SQLException e) { + log.warn( + "Could not create schema {} on {}:{}", schemaName, schemaServer, schemaServerPort, e); + throw new TenantConnectionFailedException(schemaServer, schemaServerPort, schemaName, e); + } + } + + /** + * @return true when the server already has a database of this name + */ + private boolean schemaExists( + final Connection connection, final String schemaName, final boolean postgres) + throws SQLException { + final String sql = + postgres + ? "select 1 from pg_database where datname = ?" + : "select 1 from information_schema.schemata where schema_name = ?"; + try (var statement = connection.prepareStatement(sql)) { + statement.setString(1, schemaName); + try (ResultSet rs = statement.executeQuery()) { + return rs.next(); + } + } + } + + /** + * Opens a connection with a bounded login and connect timeout. + * + *

The timeout is passed to the driver as a property of this one connection. {@link + * DriverManager#setLoginTimeout} is process-wide static state: setting and restoring it around a + * call races with every other thread in the JVM that opens a connection through {@code + * DriverManager}, and can leave them with an unintended timeout. + * + *

Units differ by driver: PostgreSQL's {@code loginTimeout} and {@code connectTimeout} are + * seconds, while MariaDB and MySQL Connector/J take {@code connectTimeout} in milliseconds. A + * timeout an administrator sets in the connection parameters is part of the URL and takes + * precedence. + */ + private Connection openConnection(final String url, final String username, final String password) + throws SQLException { + final Properties properties = new Properties(); + properties.setProperty("user", username); + properties.setProperty("password", password); + if (url.startsWith("jdbc:postgresql")) { + properties.setProperty("loginTimeout", String.valueOf(CONNECTION_TIMEOUT_SECONDS)); + properties.setProperty("connectTimeout", String.valueOf(CONNECTION_TIMEOUT_SECONDS)); + } else { + properties.setProperty("connectTimeout", String.valueOf(CONNECTION_TIMEOUT_SECONDS * 1000)); + } + return DriverManager.getConnection(url, properties); + } +} diff --git a/src/main/java/org/apache/fineract/tenant/service/TenantRowMapper.java b/src/main/java/org/apache/fineract/tenant/service/TenantRowMapper.java new file mode 100644 index 00000000..0dce3ddc --- /dev/null +++ b/src/main/java/org/apache/fineract/tenant/service/TenantRowMapper.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.service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import org.apache.fineract.tenant.data.TenantConnectionData; +import org.apache.fineract.tenant.data.TenantData; +import org.apache.fineract.tenant.domain.TenantStatus; +import org.springframework.jdbc.core.RowMapper; + +/** + * Maps a row of the tenant registry onto {@link TenantData}. + * + *

Joins to the tenant's read-write connection only. {@code tenants} carries two connection + * references, {@code oltp_id} and {@code report_id}, and Fineract's own {@code TenantMapper} + * selects between them the same way. Administration edits the operational connection, so this + * mapper follows {@code oltp_id}. + * + *

No password column is selected. Credentials are write-only across this feature, so they are + * left out of the projection entirely rather than read and then dropped - a column that is never + * fetched cannot be serialised by accident later. + */ +public final class TenantRowMapper implements RowMapper { + + /** + * Projection and joins shared by the list, count and single-tenant queries, so the three cannot + * drift apart. Callers append their own {@code WHERE} and {@code ORDER BY}. + */ + public static final String SELECT_SCHEMA = + " t.id as id, t.identifier as identifier, t.name as name, t.timezone_id as timezoneId," + + " t.status as status, t.description as description, t.contact_email as contactEmail," + + " t.joined_date as joinedDate, t.created_date as createdDate, t.lastmodified_date as" + + " lastModifiedDate, ts.id as connectionId, ts.schema_name as schemaName," + + " ts.schema_server as schemaServer, ts.schema_server_port as schemaServerPort," + + " ts.schema_username as schemaUsername, ts.schema_connection_parameters as" + + " schemaConnectionParameters, ts.auto_update as autoUpdate from tenants t left join" + + " tenant_server_connections ts on t.oltp_id = ts.id "; + + @Override + public TenantData mapRow(final ResultSet rs, final int rowNum) throws SQLException { + return new TenantData( + rs.getLong("id"), + rs.getString("identifier"), + rs.getString("name"), + rs.getString("timezoneId"), + // Null for a value this enum does not name - a hand edit or corruption. Reporting it + // as ACTIVE would show administrators the opposite of what the status filter does + // (it refuses such a tenant), so the API says plainly that the status is not + // recognised. Not thrown either: one bad row must not fail the whole listing. + TenantStatus.fromString(rs.getString("status")).orElse(null), + rs.getString("description"), + rs.getString("contactEmail"), + rs.getObject("joinedDate", LocalDate.class), + toUtc(rs.getObject("createdDate", LocalDateTime.class)), + toUtc(rs.getObject("lastModifiedDate", LocalDateTime.class)), + mapConnection(rs)); + } + + /** + * @return the tenant's connection, or null when the outer join matched no row - possible because + * the join is a left join, so a registry in an inconsistent state still lists its tenants + * instead of failing outright + */ + private TenantConnectionData mapConnection(final ResultSet rs) throws SQLException { + final long connectionId = rs.getLong("connectionId"); + if (rs.wasNull()) { + return null; + } + return new TenantConnectionData( + connectionId, + rs.getString("schemaName"), + rs.getString("schemaServer"), + rs.getString("schemaServerPort"), + rs.getString("schemaUsername"), + rs.getString("schemaConnectionParameters"), + rs.getBoolean("autoUpdate")); + } + + /** + * Attaches UTC to a zone-less registry timestamp. + * + *

Read as a {@link LocalDateTime} rather than through {@code getTimestamp}: that overload + * interprets the stored wall-clock value in the JVM's default time zone, so the same row would + * read as different instants on servers configured differently. Fineract does not force the JVM + * into UTC, and this feature writes these columns as UTC wall-clock values. + */ + private static OffsetDateTime toUtc(final LocalDateTime value) { + return value == null ? null : value.atOffset(ZoneOffset.UTC); + } +} diff --git a/src/main/java/org/apache/fineract/tenant/service/TenantSchemaMigrationService.java b/src/main/java/org/apache/fineract/tenant/service/TenantSchemaMigrationService.java new file mode 100644 index 00000000..eff84628 --- /dev/null +++ b/src/main/java/org/apache/fineract/tenant/service/TenantSchemaMigrationService.java @@ -0,0 +1,223 @@ +/** + * 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 static org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.CUSTOM_CHANGELOG_CONTEXT; +import static org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.INITIAL_SWITCH_CONTEXT; +import static org.apache.fineract.infrastructure.core.service.migration.TenantDatabaseUpgradeService.TENANT_DB_CONTEXT; + +import com.zaxxer.hikari.HikariDataSource; +import java.util.Arrays; +import java.util.List; +import liquibase.integration.spring.SpringLiquibase; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.core.domain.FineractContext; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.migration.ExtendedSpringLiquibaseFactory; +import org.apache.fineract.infrastructure.core.service.migration.TenantDataSourceFactory; +import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService; +import org.apache.fineract.tenant.exception.TenantSchemaMigrationFailedException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Service; + +/** + * Populates a newly created tenant's schema with the platform's tables, on demand. + * + *

Why this exists. Fineract migrates tenants in {@code + * TenantDatabaseUpgradeService}, which is an {@code InitializingBean} - it runs once, at startup, + * over the tenants that existed then - and every plugin that owns tables does the same through its + * own startup bean ({@code SelfServiceLiquibaseConfig}, the savings plugin's {@code + * SavingsLiquibaseConfig}). A tenant created through this API would therefore sit with an empty or + * partial schema until the next restart, which does not meet MX-406's "creating a tenant provisions + * the corresponding schema". + * + *

The goal is a new tenant whose schema is indistinguishable from one migrated at startup. So + * this reproduces the startup sequence rather than inventing one: core's changelog through core's + * own {@link ExtendedSpringLiquibaseFactory}, then each plugin changelog exactly as its startup + * bean applies it. + */ +@Service +@Slf4j +public class TenantSchemaMigrationService { + + /** + * The plugin changelogs startup applies, in the order it applies them. + * + *

A list, not classpath discovery. Scanning for every {@code + * module-changelog-master.xml} was ruled out by a running installation: {@code fineract-branch} + * ships one that neither core's master includes nor any startup bean runs, so discovery would + * give API-created tenants tables that startup-migrated tenants lack. + * + *

Order mirrors startup. Self-service runs before savings because that is the + * order the startup beans were observed to run in, so a new tenant goes through the same sequence + * as every other tenant. It is not forced by the one table the two share: both changelogs create + * {@code m_selfservice_office_service} behind a {@code not tableExists} precondition, so either + * order is safe for that table. Keeping the startup order guards against cross-plugin + * dependencies that are not guarded that way. + * + *

Strings must match the startup beans exactly. Liquibase identifies a + * changeset partly by the path it was loaded from. Using the same {@code classpath:/...} strings + * as the startup beans means the next startup finds every changeset already applied, instead of + * re-applying them under a different identity and failing on tables that already exist. + */ + static final String DEFAULT_PLUGIN_CHANGELOGS = + "classpath:/db/changelog/tenant/module/selfservice/module-changelog-master.xml," + + "classpath:/db/changelog/tenant/module/savings/module-changelog-master.xml"; + + private final TenantDetailsService tenantDetailsService; + private final TenantDataSourceFactory tenantDataSourceFactory; + private final ExtendedSpringLiquibaseFactory liquibaseFactory; + private final ResourceLoader resourceLoader; + private final List pluginChangelogs; + + public TenantSchemaMigrationService( + final TenantDetailsService tenantDetailsService, + final TenantDataSourceFactory tenantDataSourceFactory, + final ExtendedSpringLiquibaseFactory liquibaseFactory, + final ResourceLoader resourceLoader, + @Value("${fineract.tenant-management.plugin-changelogs:" + DEFAULT_PLUGIN_CHANGELOGS + "}") + final String pluginChangelogs) { + this.tenantDetailsService = tenantDetailsService; + this.tenantDataSourceFactory = tenantDataSourceFactory; + this.liquibaseFactory = liquibaseFactory; + this.resourceLoader = resourceLoader; + this.pluginChangelogs = + Arrays.stream(pluginChangelogs.split(",")) + .map(String::trim) + .filter(changelog -> !changelog.isEmpty()) + .toList(); + } + + /** + * Brings a tenant's schema up to the current version. + * + *

The tenant is re-read from the registry rather than passed in, so the connection details and + * the encrypted credentials come back through core's own mapping - including the master password + * hash that {@link TenantDataSourceFactory} checks before it will open a datasource. + * + * @param identifier identifier of a tenant already present in the registry + * @throws TenantSchemaMigrationFailedException if the schema could not be migrated + */ + public void migrate(final String identifier) { + log.info("Migrating schema for tenant {}", identifier); + + // Captured so the caller's context can be put back afterwards. This runs inside the + // administrator's own request, in the master context; the + // migration has to point the thread at the new tenant, and simply clearing it + // afterwards left the rest of that request with no tenant at all - which is how audit + // rows for CREATE came to lose the acting user and tenant. + final FineractPlatformTenant callerTenant = ThreadLocalContextUtil.getTenant(); + final FineractContext callerContext = captureCallerContext(); + + try { + final FineractPlatformTenant tenant = tenantDetailsService.loadTenantById(identifier); + + // Changesets read the current tenant from the thread context, exactly as they + // do during the startup migration. + ThreadLocalContextUtil.setTenant(tenant); + + try (HikariDataSource tenantDataSource = tenantDataSourceFactory.create(tenant)) { + applyCoreChangelog(tenantDataSource, identifier); + applyPluginChangelogs(tenantDataSource, identifier); + } + + log.info("Schema for tenant {} is up to date", identifier); + } catch (final Exception e) { + throw new TenantSchemaMigrationFailedException(identifier, e); + } finally { + restoreCallerContext(callerContext, callerTenant); + } + } + + /** + * Takes a snapshot of the whole thread context, not only the tenant, so business dates and the + * action context the request was using survive the migration too. + * + * @return the snapshot, or null if the platform refuses to build one on this thread + */ + private static FineractContext captureCallerContext() { + try { + return ThreadLocalContextUtil.getContext(); + } catch (final RuntimeException e) { + return null; + } + } + + /** + * Puts the caller's context back. Falls back to the tenant alone when no full snapshot could be + * taken, so the administrator's request never continues pointed at the tenant just migrated. + */ + private static void restoreCallerContext( + final FineractContext callerContext, final FineractPlatformTenant callerTenant) { + if (callerContext != null) { + ThreadLocalContextUtil.init(callerContext); + } else if (callerTenant != null) { + ThreadLocalContextUtil.setTenant(callerTenant); + } else { + ThreadLocalContextUtil.clearTenant(); + } + } + + /** + * Applies Fineract's own tenant changelog in the two passes core performs. + * + *

The first pass includes the {@code initial_switch} context, which carries the baseline + * schema a brand-new database needs; the second runs without it, which is how core applies + * everything layered on top. Core's third path - {@code changeLogSync} for a database still + * carrying Flyway metadata - is deliberately absent: that exists to adopt a pre-1.6 installation, + * and a schema this service creates is always empty. + * + *

The tenant identifier is passed as a context because core does the same, to keep Liquibase + * from caching one tenant's migration and reusing it for another. + */ + private void applyCoreChangelog(final HikariDataSource dataSource, final String identifier) + throws Exception { + final SpringLiquibase baseline = + liquibaseFactory.create( + dataSource, + TENANT_DB_CONTEXT, + CUSTOM_CHANGELOG_CONTEXT, + INITIAL_SWITCH_CONTEXT, + identifier); + baseline.afterPropertiesSet(); + + final SpringLiquibase remainder = + liquibaseFactory.create( + dataSource, TENANT_DB_CONTEXT, CUSTOM_CHANGELOG_CONTEXT, identifier); + remainder.afterPropertiesSet(); + } + + /** + * Applies each plugin changelog the way its startup bean does. + * + *

A changelog that is not on the classpath is skipped, not treated as an error: an + * installation without the savings plugin is a supported deployment, and must still be able to + * create tenants. The configured list therefore describes what to apply when present. + */ + private void applyPluginChangelogs(final HikariDataSource dataSource, final String identifier) + throws Exception { + for (final String changelog : pluginChangelogs) { + if (!resourceLoader.getResource(changelog).exists()) { + log.info( + "Plugin changelog {} is not installed; skipping it for tenant {}", + changelog, + identifier); + continue; + } + // Configured exactly like the startup beans - data source, changelog, shouldRun and + // nothing else - so the recorded changeset identities match theirs. + final SpringLiquibase pluginLiquibase = new SpringLiquibase(); + pluginLiquibase.setDataSource(dataSource); + pluginLiquibase.setChangeLog(changelog); + pluginLiquibase.setShouldRun(true); + pluginLiquibase.afterPropertiesSet(); + log.info("Applied plugin changelog {} to tenant {}", changelog, identifier); + } + } +} diff --git a/src/main/java/org/apache/fineract/tenant/service/TenantStatusLookupService.java b/src/main/java/org/apache/fineract/tenant/service/TenantStatusLookupService.java new file mode 100644 index 00000000..f391fda8 --- /dev/null +++ b/src/main/java/org/apache/fineract/tenant/service/TenantStatusLookupService.java @@ -0,0 +1,280 @@ +/** + * 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.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import javax.sql.DataSource; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.tenant.domain.TenantStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +/** + * Resolves a tenant's lifecycle status by identifier, with a short-lived cache. + * + *

Consulted by {@code TenantStatusEnforcementFilter} on every request, so an uncached lookup + * would add a query to the tenant store to each one. + * + *

Why a local cache and not Spring's. This runs in a servlet filter ahead of + * the security chain, on a datasource the platform's cache configuration knows nothing about. A + * hand-rolled map keeps the invalidation explicit and the behaviour identical regardless of how the + * host installation has configured caching. + * + *

Entries are invalidated directly when this node changes a tenant, and expire on a timer so a + * change made by another node - or by hand in the database - still takes effect without a + * restart. The TTL is therefore the worst-case delay before a suspension takes hold across a + * cluster. + * + *

What is cached. Only answers about tenants that exist. The identifier comes + * straight from a request header, so caching "no such tenant" would let anyone grow the cache + * without bound by inventing identifiers. The number of real tenants bounds the cache, and {@link + * #MAX_CACHED_TENANTS} backstops even that. + */ +@Service +@Slf4j +public class TenantStatusLookupService { + + /** The kinds of answer a lookup can give. */ + public enum Kind { + /** No tenant holds the identifier. */ + NO_SUCH_TENANT, + /** The registry could not be read, and no earlier status for the tenant is known. */ + REGISTRY_UNAVAILABLE, + /** The tenant exists and its status is one this plugin understands. */ + KNOWN, + /** The tenant exists but its stored status names nothing this plugin understands. */ + UNRECOGNISED + } + + /** + * The answer to one lookup. + * + * @param kind what kind of answer this is + * @param status the tenant's status when {@code kind} is {@link Kind#KNOWN}, otherwise null + */ + public record Lookup(Kind kind, TenantStatus status) { + + public static Lookup known(final TenantStatus status) { + return new Lookup(Kind.KNOWN, status); + } + + public static Lookup noSuchTenant() { + return new Lookup(Kind.NO_SUCH_TENANT, null); + } + + public static Lookup registryUnavailable() { + return new Lookup(Kind.REGISTRY_UNAVAILABLE, null); + } + + public static Lookup unrecognised() { + return new Lookup(Kind.UNRECOGNISED, null); + } + + /** + * Whether requests to this tenant must be refused. + * + *

An unrecognised stored status refuses service: the column is only ever written with a + * valid value by this plugin, so anything else is a hand edit or corruption, and + * SOUL_GUARDRAILS requires deny-by-default when context is unclear. + * + *

A registry that cannot be read refuses too, but only when no earlier status is known for + * the tenant - {@link TenantStatusLookupService#statusOf} falls back to that first. Core keeps + * resolving tenants from its own {@code tenantsById} cache while the tenant store is down, so + * letting an unverifiable request through could serve a suspended tenant. + * + *

A missing tenant does not refuse here; the platform's own tenant resolution runs next and + * produces the proper error. + * + * @return true when the request must be refused + */ + public boolean refusesService() { + return kind == Kind.UNRECOGNISED + || kind == Kind.REGISTRY_UNAVAILABLE + || (kind == Kind.KNOWN && status != TenantStatus.ACTIVE); + } + } + + /** Backstop on cache size. Real tenant counts sit far below it. */ + static final int MAX_CACHED_TENANTS = 10_000; + + private record CachedStatus(Lookup lookup, Instant readAt) {} + + private final JdbcTemplate jdbcTemplate; + private final Duration timeToLive; + + /** + * How long past its cache expiry an ACTIVE status is still trusted while the tenant store cannot + * be read. Bounded because another node may have suspended the tenant in the meantime. + */ + private final Duration staleGrace; + + private final Map cache = new ConcurrentHashMap<>(); + + /** + * How many times each tenant has been invalidated on this node. + * + *

Grows only with identifiers an administrator has changed through this API, so it stays + * small. + */ + private final Map invalidations = new ConcurrentHashMap<>(); + + /** Bumped by {@link #invalidateAll()}, so a lookup in flight across it cannot cache its read. */ + private final AtomicLong epoch = new AtomicLong(); + + /** Default {@link #staleGrace}: five minutes. */ + static final Duration DEFAULT_STALE_GRACE = Duration.ofMinutes(5); + + @Autowired + public TenantStatusLookupService( + @Qualifier("hikariTenantDataSource") final DataSource tenantStoreDataSource, + @Value("${fineract.tenant-management.status-cache-seconds:30}") final long cacheSeconds, + @Value("${fineract.tenant-management.status-stale-grace-seconds:300}") + final long staleGraceSeconds) { + this( + new JdbcTemplate(tenantStoreDataSource), + Duration.ofSeconds(cacheSeconds), + Duration.ofSeconds(staleGraceSeconds)); + } + + TenantStatusLookupService(final JdbcTemplate jdbcTemplate, final Duration timeToLive) { + this(jdbcTemplate, timeToLive, DEFAULT_STALE_GRACE); + } + + TenantStatusLookupService( + final JdbcTemplate jdbcTemplate, final Duration timeToLive, final Duration staleGrace) { + this.jdbcTemplate = jdbcTemplate; + this.timeToLive = timeToLive; + this.staleGrace = staleGrace; + } + + /** + * @param identifier tenant identifier as supplied by the caller + * @return the answer, never null + */ + public Lookup statusOf(final String identifier) { + if (identifier == null || identifier.isBlank()) { + return Lookup.noSuchTenant(); + } + + final CachedStatus cached = cache.get(identifier); + if (cached != null && isFresh(cached)) { + return cached.lookup(); + } + + // Snapshot the invalidation state before reading, and cache the read only if nothing + // was invalidated while it was in flight. Without this a lookup could read ACTIVE, + // lose the race to a suspension that commits and invalidates, and then cache that + // stale ACTIVE - letting the suspended tenant through until the entry expires. + final long epochBefore = epoch.get(); + final long generationBefore = invalidations.getOrDefault(identifier, 0L); + + final Lookup lookup = readStatus(identifier); + + if (lookup.kind() == Kind.REGISTRY_UNAVAILABLE) { + // Stale-if-error, bounded. An expired status still counts for a while during a tenant + // store outage, so a tenant last seen ACTIVE keeps working through a registry blip - + // but only for staleGrace past its expiry, because another node may have suspended it + // since. After that the unverifiable lookup is returned and refuses service. A status + // that already refuses service stays refused however old it is; that is always safe. + if (cached != null && (cached.lookup().refusesService() || isWithinStaleGrace(cached))) { + return cached.lookup(); + } + return lookup; + } + + if (lookup.kind() == Kind.KNOWN || lookup.kind() == Kind.UNRECOGNISED) { + // compute() is serialised with invalidate()'s remove() on the same key, and the + // generation is bumped before that remove, so a concurrent invalidation is always + // seen here - either before the put (skipped) or after it (removed). + cache.compute( + identifier, + (key, existing) -> + epoch.get() == epochBefore && invalidations.getOrDefault(key, 0L) == generationBefore + ? new CachedStatus(lookup, Instant.now()) + : existing); + evictIfOversized(); + } + return lookup; + } + + private Lookup readStatus(final String identifier) { + final List rows; + try { + rows = + jdbcTemplate.queryForList( + "select status from tenants where identifier = ?", String.class, identifier); + } catch (final RuntimeException e) { + // Not answered here: statusOf falls back to the last status it saw for this tenant, + // and refuses when it has none. + log.warn("Could not read status for tenant [{}] from the tenant store", identifier, e); + return Lookup.registryUnavailable(); + } + + if (rows.isEmpty()) { + return Lookup.noSuchTenant(); + } + + return TenantStatus.fromString(rows.get(0)) + .map(Lookup::known) + .orElseGet( + () -> { + log.warn( + "Tenant [{}] has an unrecognised status; refusing its requests until corrected", + identifier); + return Lookup.unrecognised(); + }); + } + + private boolean isFresh(final CachedStatus entry) { + return Instant.now().isBefore(entry.readAt().plus(timeToLive)); + } + + private boolean isWithinStaleGrace(final CachedStatus entry) { + return Instant.now().isBefore(entry.readAt().plus(timeToLive).plus(staleGrace)); + } + + /** + * Keeps the cache inside {@link #MAX_CACHED_TENANTS}. + * + *

Expired entries are not removed on read, so they are swept here first. Clearing everything + * is the last resort: it costs one query per tenant on the next requests, which is harmless, + * whereas an unbounded map is not. + */ + private void evictIfOversized() { + if (cache.size() <= MAX_CACHED_TENANTS) { + return; + } + cache.values().removeIf(entry -> !isFresh(entry)); + if (cache.size() > MAX_CACHED_TENANTS) { + cache.clear(); + } + } + + /** Drops one tenant's cached status, so a change made here takes effect immediately. */ + public void invalidate(final String identifier) { + if (identifier == null) { + return; + } + // Generation first, then removal: the order statusOf relies on. + invalidations.merge(identifier, 1L, Long::sum); + cache.remove(identifier); + } + + /** Drops everything. Used by tests and available for operational recovery. */ + public void invalidateAll() { + epoch.incrementAndGet(); + cache.clear(); + } +} diff --git a/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/module-changelog-master.xml b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/module-changelog-master.xml new file mode 100644 index 00000000..067d336d --- /dev/null +++ b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/module-changelog-master.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/001-add-tenant-status-and-metadata.xml b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/001-add-tenant-status-and-metadata.xml new file mode 100644 index 00000000..1116ea0d --- /dev/null +++ b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/001-add-tenant-status-and-metadata.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + Add lifecycle status to the tenant registry, defaulting existing tenants to ACTIVE. + + + + + + + + + + + + + + + + + Add the optional free-text description from MX-406's tenant metadata. + + + + + + + + + + + + + + + Add the optional administrative contact from MX-406's tenant metadata. + + + + + + + + + + + + + + + Index status so filtering the tenant list by status does not scan the registry. + + + + + + + diff --git a/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/002-create-tenant-administration-audit.xml b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/002-create-tenant-administration-audit.xml new file mode 100644 index 00000000..43a74889 --- /dev/null +++ b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/002-create-tenant-administration-audit.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + Create the tenant administration audit trail. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Index the tenant identifier so one tenant's history can be pulled without a scan. + + + + + + + diff --git a/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/003-create-tenant-master-user.xml b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/003-create-tenant-master-user.xml new file mode 100644 index 00000000..0a404149 --- /dev/null +++ b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/003-create-tenant-master-user.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + Create the master user store for tenant administration. + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/004-create-tenant-retained-schema.xml b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/004-create-tenant-retained-schema.xml new file mode 100644 index 00000000..ab8c796c --- /dev/null +++ b/src/main/resources/db/changelog/tenantstore/module/tenantmanagement/parts/004-create-tenant-retained-schema.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + Record databases retained after a tenant is removed, and the identifier that owns them. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Index the database name, which every tenant creation looks up. + + + + + + + diff --git a/src/test/java/org/apache/fineract/selfservice/testing/support/SelfServiceIntegrationTestBase.java b/src/test/java/org/apache/fineract/selfservice/testing/support/SelfServiceIntegrationTestBase.java index c16806b6..6531e753 100644 --- a/src/test/java/org/apache/fineract/selfservice/testing/support/SelfServiceIntegrationTestBase.java +++ b/src/test/java/org/apache/fineract/selfservice/testing/support/SelfServiceIntegrationTestBase.java @@ -60,7 +60,8 @@ public abstract class SelfServiceIntegrationTestBase { String testPluginsPath = buildDir + "/test-plugins"; java.io.File testPluginsDir = new java.io.File(testPluginsPath); if (!testPluginsDir.exists() && !testPluginsDir.mkdirs()) { - throw new IllegalStateException("Failed to create test-plugins directory at: " + testPluginsPath); + throw new IllegalStateException( + "Failed to create test-plugins directory at: " + testPluginsPath); } if (!testPluginsDir.isDirectory()) { throw new IllegalStateException("Path exists but is not a directory: " + testPluginsPath); @@ -82,6 +83,10 @@ public abstract class SelfServiceIntegrationTestBase { .withEnv("FINERACT_MODULE_SELFSERVICE_ENABLED", "true") .withEnv("SPRING_MAIN_ALLOW_BEAN_DEFINITION_OVERRIDING", "true") .withEnv("FINERACT_MODULES_SELFSERVICE_RUNREPORTS_ALLOWLIST", "Client Details") + // Master user for tenant management (MX-406); see TenantMasterUserBootstrap. + .withEnv("FINERACT_TENANT_MANAGEMENT_BOOTSTRAP_MASTER_USERNAME", "master") + .withEnv( + "FINERACT_TENANT_MANAGEMENT_BOOTSTRAP_MASTER_PASSWORD", "master-password-for-tests") .withEnv("TZ", "UTC") .withEnv("JAVA_TOOL_OPTIONS", "-Xmx2G") .withEnv("FINERACT_SERVER_SSL_ENABLED", "true") @@ -96,10 +101,10 @@ public abstract class SelfServiceIntegrationTestBase { cmd.withEntrypoint( "sh", "-c", - "CLASSPATH=$(cat /app/jib-classpath-file) && " - + "exec java $JAVA_TOOL_OPTIONS " - + "-Duser.home=/tmp -Dfile.encoding=UTF-8 -Duser.timezone=UTC -Djava.security.egd=file:/dev/./urandom " - + "-cp /app/plugins/selfservice-plugin.jar:/app/test-plugins/*:$CLASSPATH " + "CLASSPATH=$(cat /app/jib-classpath-file) && exec java $JAVA_TOOL_OPTIONS" + + " -Duser.home=/tmp -Dfile.encoding=UTF-8 -Duser.timezone=UTC" + + " -Djava.security.egd=file:/dev/./urandom -cp" + + " /app/plugins/selfservice-plugin.jar:/app/test-plugins/*:$CLASSPATH " + "org.apache.fineract.ServerApplication"); cmd.withCmd(); }) diff --git a/src/test/java/org/apache/fineract/tenant/TenantManagementPostgresIntegrationTest.java b/src/test/java/org/apache/fineract/tenant/TenantManagementPostgresIntegrationTest.java new file mode 100644 index 00000000..ccb53521 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/TenantManagementPostgresIntegrationTest.java @@ -0,0 +1,953 @@ +/** + * 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; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TimeZone; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import javax.sql.DataSource; +import liquibase.integration.spring.SpringLiquibase; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor; +import org.apache.fineract.tenant.data.TenantCreateRequest; +import org.apache.fineract.tenant.data.TenantData; +import org.apache.fineract.tenant.data.TenantUpdateRequest; +import org.apache.fineract.tenant.domain.TenantStatus; +import org.apache.fineract.tenant.exception.TenantIdentifierAlreadyExistsException; +import org.apache.fineract.tenant.exception.TenantNotFoundException; +import org.apache.fineract.tenant.exception.TenantSchemaUnavailableException; +import org.apache.fineract.tenant.security.TenantMasterAccess; +import org.apache.fineract.tenant.security.TenantMasterUserBootstrap; +import org.apache.fineract.tenant.security.TenantMasterUserStore; +import org.apache.fineract.tenant.service.TenantAdministrationAuditService; +import org.apache.fineract.tenant.service.TenantManagementReadService; +import org.apache.fineract.tenant.service.TenantManagementWriteService; +import org.apache.fineract.tenant.service.TenantProvisioningService; +import org.apache.fineract.tenant.service.TenantSchemaMigrationService; +import org.apache.fineract.tenant.service.TenantStatusLookupService; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Exercises tenant administration against a real PostgreSQL tenant store. + * + *

Unit tests cover the rules; this covers the things only a database can answer - that the + * Liquibase changesets actually apply, that the SQL is valid PostgreSQL, that the unique constraint + * behaves, and that reads see what writes produced. + * + *

Scope. Only the tenant store is real. Schema provisioning and migration are + * stubbed, because exercising those means standing up a second database and a whole Fineract; they + * are covered by {@code SelfServiceIntegrationTestBase}-style tests that boot the platform. The + * password encryptor is stubbed too, so a failure here points at this feature's SQL rather than at + * core's cryptography. + */ +@Testcontainers +class TenantManagementPostgresIntegrationTest { + + private static PostgreSQLContainer postgres; + private static HikariDataSource dataSource; + + private TenantManagementReadService readService; + private TenantManagementWriteService writeService; + private JdbcTemplate jdbcTemplate; + + @BeforeAll + static void startDatabase() throws Exception { + postgres = + new PostgreSQLContainer<>("postgres:15-alpine") + .withDatabaseName("fineract_tenants") + .withUsername("postgres") + .withPassword("postgres"); + postgres.start(); + + final HikariConfig config = new HikariConfig(); + config.setJdbcUrl(postgres.getJdbcUrl()); + config.setUsername(postgres.getUsername()); + config.setPassword(postgres.getPassword()); + config.setMaximumPoolSize(4); + dataSource = new HikariDataSource(config); + + applyRegistryBaseline(dataSource); + applyTenantManagementChangelog(dataSource); + } + + /** + * Builds the registry the way Fineract ships it. + * + *

Runs core's own initial-switch changelog straight out of the {@code fineract-provider} jar, + * so the {@code tenants} and {@code tenant_server_connections} tables under test are genuinely + * core's and not a hand-written approximation that could drift. + */ + private static void applyRegistryBaseline(final DataSource dataSource) throws Exception { + final SpringLiquibase liquibase = new SpringLiquibase(); + liquibase.setDataSource(dataSource); + liquibase.setChangeLog( + "classpath:/db/changelog/tenant-store/initial-switch-changelog-tenant-store.xml"); + liquibase.setContexts("initial_switch,tenant_store_db"); + // Core's initial-data changeset seeds the 'default' tenant from these placeholders, + // which the platform normally supplies from fineract.tenant.* configuration. Left + // unset they are inserted literally and overflow the column. + liquibase.setChangeLogParameters( + Map.ofEntries( + Map.entry("fineract.tenant.identifier", "default"), + Map.entry("fineract.tenant.description", "Default Demo Tenant"), + Map.entry("fineract.tenant.timezone", "Asia/Kolkata"), + Map.entry("fineract.tenant.host", "localhost"), + Map.entry("fineract.tenant.port", "5432"), + Map.entry("fineract.tenant.schema-name", "fineract_default"), + Map.entry("fineract.tenant.username", "postgres"), + Map.entry("fineract.tenant.password", "postgres"), + Map.entry("fineract.tenant.parameters", ""))); + liquibase.setShouldRun(true); + liquibase.afterPropertiesSet(); + + // Core seeds the default tenant with explicit ids, which leaves PostgreSQL's + // identity sequences pointing at 1. Core corrects that in part 0003; without it + // the first generated id collides with the seeded row. Run separately because the + // master changelog that normally carries it also pulls in Spring-injected + // customChange tasks (parts 0007-0009) that cannot run standalone. + final SpringLiquibase sequences = new SpringLiquibase(); + sequences.setDataSource(dataSource); + sequences.setChangeLog( + "classpath:/db/changelog/tenant-store/parts/0003_reset_postgresql_sequences.xml"); + sequences.setContexts("postgresql,tenant_store_db"); + sequences.setShouldRun(true); + sequences.afterPropertiesSet(); + + // Stands in for core changeset 0007, which adds this column. That changelog part + // cannot run here: it also carries Spring-injected customChange tasks that encrypt + // existing passwords, which need a Fineract application context. The column itself + // is all this feature needs, and the write service sets it. + new JdbcTemplate(dataSource) + .execute( + "alter table tenant_server_connections add column if not exists master_password_hash" + + " varchar(255)"); + } + + /** Applies the changelog this feature adds - the thing actually under test. */ + private static void applyTenantManagementChangelog(final DataSource dataSource) throws Exception { + final SpringLiquibase liquibase = new SpringLiquibase(); + liquibase.setDataSource(dataSource); + liquibase.setChangeLog( + "classpath:/db/changelog/tenantstore/module/tenantmanagement/module-changelog-master.xml"); + liquibase.setShouldRun(true); + liquibase.afterPropertiesSet(); + } + + @AfterAll + static void stopDatabase() { + if (dataSource != null) { + dataSource.close(); + } + if (postgres != null) { + postgres.stop(); + } + } + + @BeforeEach + void setUp() { + jdbcTemplate = new JdbcTemplate(dataSource); + // Start each test from a known registry. The baseline inserts a 'default' tenant. + jdbcTemplate.update("delete from tenant_administration_audit"); + jdbcTemplate.update("delete from tenant_retained_schema"); + jdbcTemplate.update("delete from tenants where identifier <> 'default'"); + jdbcTemplate.update( + "delete from tenant_server_connections where id not in (select oltp_id from tenants)"); + + final DatabasePasswordEncryptor encryptor = mock(DatabasePasswordEncryptor.class); + // Identity "encryption": this test is about the SQL, not core's cipher. + when(encryptor.encrypt(anyString())).thenAnswer(i -> "enc:" + i.getArgument(0)); + when(encryptor.getMasterPasswordHash()).thenReturn("test-master-hash"); + + readService = new TenantManagementReadService(dataSource); + + final TransactionTemplate transactionTemplate = + new TransactionTemplate(new DataSourceTransactionManager(dataSource)); + + @SuppressWarnings("unchecked") + final ObjectProvider noCacheManager = + mock(ObjectProvider.class); + + writeService = + new TenantManagementWriteService( + dataSource, + transactionTemplate, + encryptor, + mock(TenantProvisioningService.class), + readService, + mock(TenantStatusLookupService.class), + mock(TenantSchemaMigrationService.class), + new TenantAdministrationAuditService(dataSource), + noCacheManager, + // Migration is stubbed, so creating must not try to run it. + false); + } + + private static TenantCreateRequest requestFor(final String identifier) { + return new TenantCreateRequest( + identifier, + "Acme Microfinance", + "Asia/Kolkata", + TenantStatus.ACTIVE, + "a description", + "ops@example.org", + "mifostenant_" + identifier, + "db.example.org", + "5432", + "fineract", + "s3cret", + null, + true); + } + + // --------------------------------------------------------------- + // The changesets themselves + // --------------------------------------------------------------- + + @Test + void theStatusColumnIsAddedAndExistingTenantsDefaultToActive() { + // The migration must not change what an existing installation means: every + // tenant already in the registry is by definition live. + final String status = + jdbcTemplate.queryForObject( + "select status from tenants where identifier = 'default'", String.class); + + assertEquals("ACTIVE", status); + } + + @Test + void theAuditTableIsCreated() { + assertEquals( + 0, + jdbcTemplate.queryForObject( + "select count(*) from tenant_administration_audit", Integer.class)); + } + + // --------------------------------------------------------------- + // Create + // --------------------------------------------------------------- + + @Test + void createWritesBothRowsAndReadsBack() { + final TenantData created = writeService.create(requestFor("acme")); + + assertNotNull(created.id()); + assertEquals("acme", created.identifier()); + assertEquals(TenantStatus.ACTIVE, created.status()); + assertEquals("Asia/Kolkata", created.timezoneId()); + assertEquals("ops@example.org", created.contactEmail()); + assertNotNull(created.connection()); + assertEquals("mifostenant_acme", created.connection().schemaName()); + // Populated so a tenant created through the API is not the only one in the + // registry with a blank joined date. + assertNotNull(created.joinedDate()); + assertNotNull(created.createdDate()); + } + + @Test + void createStoresTheEncryptedPasswordAndTheMasterHash() { + // Without the master hash, core's TenantDataSourceFactory refuses to open the + // tenant at all - failing later, at startup, with "Invalid master password". + writeService.create(requestFor("acme")); + + final var row = + jdbcTemplate.queryForMap( + "select ts.schema_password, ts.master_password_hash from tenants t" + + " join tenant_server_connections ts on t.oltp_id = ts.id" + + " where t.identifier = 'acme'"); + + assertEquals("enc:s3cret", row.get("schema_password")); + assertEquals("test-master-hash", row.get("master_password_hash")); + } + + @Test + void theStoredPasswordIsNeverReturnedByAnyRead() { + writeService.create(requestFor("acme")); + + final TenantData read = readService.retrieveOne(writeService.create(requestFor("beta")).id()); + final Page listed = readService.retrieveAll(null, null, null, null); + + // The projection has no password column at all, so there is nothing to leak. + assertFalse(read.toString().contains("s3cret")); + assertFalse(listed.getPageItems().toString().contains("s3cret")); + assertFalse(listed.getPageItems().toString().contains("enc:")); + } + + @Test + void theIdentifierMustBeUnique() { + writeService.create(requestFor("acme")); + + assertThrows( + TenantIdentifierAlreadyExistsException.class, + () -> writeService.create(requestFor("acme"))); + } + + @Test + void createIsRecordedInTheAuditTrailWithoutTheCredential() { + writeService.create(requestFor("acme")); + + final var audit = + jdbcTemplate.queryForMap( + "select action, outcome, tenant_identifier, detail from tenant_administration_audit" + + " where tenant_identifier = 'acme'"); + + assertEquals("CREATE", audit.get("action")); + assertEquals("SUCCESS", audit.get("outcome")); + assertFalse(String.valueOf(audit.get("detail")).contains("s3cret")); + } + + // --------------------------------------------------------------- + // Read: search, filter, paging + // --------------------------------------------------------------- + + @Test + void listingFiltersByStatus() { + final TenantData acme = writeService.create(requestFor("acme")); + writeService.create(requestFor("beta")); + writeService.changeStatus(acme.id(), TenantStatus.SUSPENDED); + + final Page suspended = + readService.retrieveAll(null, TenantStatus.SUSPENDED, null, null); + + assertEquals(1, suspended.getTotalFilteredRecords()); + assertEquals("acme", suspended.getPageItems().get(0).identifier()); + } + + @Test + void searchMatchesIdentifierAndNameCaseInsensitively() { + writeService.create(requestFor("acme")); + + assertEquals(1, readService.retrieveAll("ACME", null, null, null).getTotalFilteredRecords()); + assertEquals( + 1, readService.retrieveAll("microfinance", null, null, null).getTotalFilteredRecords()); + assertEquals( + 0, readService.retrieveAll("nothing-matches", null, null, null).getTotalFilteredRecords()); + } + + @Test + void aSearchTermContainingWildcardsIsMatchedLiterally() { + // Bound as a parameter, so % does not become "match everything". + writeService.create(requestFor("acme")); + + assertEquals(0, readService.retrieveAll("%", null, null, null).getTotalFilteredRecords()); + } + + @Test + void pagingReturnsAPageAndTheUnpagedTotal() { + writeService.create(requestFor("acme")); + writeService.create(requestFor("beta")); + writeService.create(requestFor("gamma")); + + final Page firstPage = readService.retrieveAll(null, null, 0, 2); + + assertEquals(2, firstPage.getPageItems().size()); + // 3 created plus the baseline 'default' tenant. + assertEquals(4, firstPage.getTotalFilteredRecords()); + } + + @Test + void retrievingAnUnknownTenantIsReportedAsNotFound() { + assertThrows(TenantNotFoundException.class, () -> readService.retrieveOne(999_999L)); + } + + @Test + void theTemplateOffersTimezonesAndStatuses() { + final var template = readService.retrieveTemplate(); + + assertFalse(template.timezones().isEmpty()); + assertEquals(List.of("ACTIVE", "INACTIVE", "SUSPENDED"), template.statuses()); + } + + // --------------------------------------------------------------- + // Update + // --------------------------------------------------------------- + + @Test + void updateChangesOnlyWhatWasSupplied() { + final TenantData created = writeService.create(requestFor("acme")); + + final TenantData updated = + writeService.update( + created.id(), + new TenantUpdateRequest( + "Renamed", null, null, null, null, null, null, null, null, null)); + + assertEquals("Renamed", updated.name()); + // Untouched fields survive. + assertEquals("Asia/Kolkata", updated.timezoneId()); + assertEquals("ops@example.org", updated.contactEmail()); + assertEquals("db.example.org", updated.connection().schemaServer()); + } + + @Test + void omittingThePasswordKeepsTheStoredOne() { + final TenantData created = writeService.create(requestFor("acme")); + + writeService.update( + created.id(), + new TenantUpdateRequest("Renamed", null, null, null, null, null, null, null, null, null)); + + assertEquals( + "enc:s3cret", + jdbcTemplate.queryForObject( + "select ts.schema_password from tenants t join tenant_server_connections ts" + + " on t.oltp_id = ts.id where t.identifier = 'acme'", + String.class)); + } + + @Test + void rotatingThePasswordReEncryptsItAndReStampsTheMasterHash() { + final TenantData created = writeService.create(requestFor("acme")); + + writeService.update( + created.id(), + new TenantUpdateRequest(null, null, null, null, null, null, null, "rotated", null, null)); + + final var row = + jdbcTemplate.queryForMap( + "select ts.schema_password, ts.master_password_hash from tenants t" + + " join tenant_server_connections ts on t.oltp_id = ts.id" + + " where t.identifier = 'acme'"); + + assertEquals("enc:rotated", row.get("schema_password")); + assertEquals("test-master-hash", row.get("master_password_hash")); + } + + @Test + void anUpdateRecordsChangedFieldNamesButNotValues() { + final TenantData created = writeService.create(requestFor("acme")); + + writeService.update( + created.id(), + new TenantUpdateRequest(null, null, null, null, null, null, null, "rotated", null, null)); + + final String detail = + jdbcTemplate.queryForObject( + "select detail from tenant_administration_audit where action = 'UPDATE'", String.class); + + assertTrue(detail.contains("schemaPassword")); + assertFalse(detail.contains("rotated")); + } + + // --------------------------------------------------------------- + // Status and removal + // --------------------------------------------------------------- + + @Test + void statusChangesArePersistedAndIdempotent() { + final TenantData created = writeService.create(requestFor("acme")); + + assertEquals( + TenantStatus.SUSPENDED, + writeService.changeStatus(created.id(), TenantStatus.SUSPENDED).status()); + // Re-issuing the same command succeeds rather than erroring, so a retry does + // not look like a failure. + assertEquals( + TenantStatus.SUSPENDED, + writeService.changeStatus(created.id(), TenantStatus.SUSPENDED).status()); + } + + @Test + void anActiveTenantCannotBeRemoved() { + final TenantData created = writeService.create(requestFor("acme")); + + assertThrows(GeneralPlatformDomainRuleException.class, () -> writeService.delete(created.id())); + assertNotNull(readService.retrieveOne(created.id())); + } + + @Test + void aDeactivatedTenantIsRemovedFromTheRegistry() { + final TenantData created = writeService.create(requestFor("acme")); + writeService.changeStatus(created.id(), TenantStatus.INACTIVE); + + writeService.delete(created.id()); + + assertThrows(TenantNotFoundException.class, () -> readService.retrieveOne(created.id())); + // The connection row goes too, so the registry keeps no orphan. + assertEquals( + 0, + jdbcTemplate.queryForObject( + "select count(*) from tenant_server_connections where schema_name = 'mifostenant_acme'", + Integer.class)); + } + + @Test + void theAuditTrailOutlivesTheTenantItDescribes() { + // An audit row that vanished with the tenant whose deletion it recorded would + // be worthless, which is why the trail lives in the registry database and + // carries the identifier rather than a foreign key. + final TenantData created = writeService.create(requestFor("acme")); + writeService.changeStatus(created.id(), TenantStatus.INACTIVE); + writeService.delete(created.id()); + + final var audit = + jdbcTemplate.queryForMap( + "select action, tenant_id from tenant_administration_audit where action = 'DELETE'"); + + assertEquals("DELETE", audit.get("action")); + // Null so the trail cannot point at an id another tenant may later reuse. + assertNull(audit.get("tenant_id")); + } + + @Test + void everyMutationLeavesATrail() { + final TenantData created = writeService.create(requestFor("acme")); + writeService.update( + created.id(), + new TenantUpdateRequest("Renamed", null, null, null, null, null, null, null, null, null)); + writeService.changeStatus(created.id(), TenantStatus.SUSPENDED); + writeService.changeStatus(created.id(), TenantStatus.INACTIVE); + writeService.delete(created.id()); + + final List actions = + jdbcTemplate.queryForList( + "select action from tenant_administration_audit where tenant_identifier = 'acme'" + + " order by id", + String.class); + + assertEquals(List.of("CREATE", "UPDATE", "SUSPEND", "DEACTIVATE", "DELETE"), actions); + } + + // --------------------------------------------------------------- + // Cache eviction against several managers - reproduces a real-Fineract failure + // --------------------------------------------------------------- + + private TenantManagementWriteService writeServiceWithCacheManagers( + final CacheManager... managers) { + final DatabasePasswordEncryptor encryptor = mock(DatabasePasswordEncryptor.class); + when(encryptor.encrypt(anyString())).thenAnswer(i -> "enc:" + i.getArgument(0)); + when(encryptor.getMasterPasswordHash()).thenReturn("test-master-hash"); + + @SuppressWarnings("unchecked") + final ObjectProvider provider = mock(ObjectProvider.class); + when(provider.orderedStream()).thenAnswer(i -> Stream.of(managers)); + + return new TenantManagementWriteService( + dataSource, + new TransactionTemplate(new DataSourceTransactionManager(dataSource)), + encryptor, + mock(TenantProvisioningService.class), + readService, + mock(TenantStatusLookupService.class), + mock(TenantSchemaMigrationService.class), + new TenantAdministrationAuditService(dataSource), + provider, + false); + } + + @Test + void aChangeEvictsTheTenantFromEveryCacheManagerThatHoldsTheCache() { + // A running Fineract registers four CacheManagers. Asking Spring for "the" one threw + // NoUniqueBeanDefinitionException after create had committed, which stranded a + // registered tenant with an empty schema. Eviction must reach every manager. + final ConcurrentMapCacheManager first = new ConcurrentMapCacheManager("tenantsById"); + final ConcurrentMapCacheManager second = new ConcurrentMapCacheManager("tenantsById"); + final ConcurrentMapCacheManager unrelated = new ConcurrentMapCacheManager("somethingElse"); + final TenantManagementWriteService service = + writeServiceWithCacheManagers(first, second, unrelated); + + final TenantData created = service.create(requestFor("acme")); + first.getCache("tenantsById").put("acme", "stale"); + second.getCache("tenantsById").put("acme", "stale"); + + service.changeStatus(created.id(), TenantStatus.SUSPENDED); + + assertNull(first.getCache("tenantsById").get("acme")); + assertNull(second.getCache("tenantsById").get("acme")); + } + + @Test + void aFailingCacheManagerDoesNotFailACommittedChange() { + // The registry write has already committed when eviction runs, so a cache problem + // must never surface as a failed request or strand a half-created tenant. + final CacheManager broken = mock(CacheManager.class); + when(broken.getCache(anyString())).thenThrow(new IllegalStateException("cache down")); + final TenantManagementWriteService service = writeServiceWithCacheManagers(broken); + + final TenantData created = service.create(requestFor("acme")); + + assertEquals( + TenantStatus.SUSPENDED, + service.changeStatus(created.id(), TenantStatus.SUSPENDED).status()); + assertEquals( + 1, + jdbcTemplate.queryForObject( + "select count(*) from tenant_administration_audit where action = 'CREATE'", + Integer.class)); + } + + // --------------------------------------------------------------- + // Review follow-ups against a real database + // --------------------------------------------------------------- + + @Test + void concurrentCreationOfTheSameSchemaNeverFails() throws Exception { + // Two creates racing past the existence check make PostgreSQL reject one CREATE + // DATABASE as a duplicate; the loser must treat the now-existing schema as success. + // Threads do not guarantee the collision on every run, so several rounds are tried. + // The assertion - no caller ever fails, and exactly one database results - holds + // whether or not a given round actually collided. + final TenantProvisioningService provisioning = new TenantProvisioningService(dataSource); + final ExecutorService pool = Executors.newFixedThreadPool(4); + try { + for (int round = 0; round < 5; round++) { + final String schema = "race_schema_" + round; + final CountDownLatch start = new CountDownLatch(1); + final List> attempts = new ArrayList<>(); + for (int caller = 0; caller < 4; caller++) { + attempts.add( + pool.submit( + () -> { + start.await(); + provisioning.createSchemaIfAbsent( + postgres.getHost(), + String.valueOf(postgres.getFirstMappedPort()), + schema, + null, + postgres.getUsername(), + postgres.getPassword()); + return null; + })); + } + start.countDown(); + for (final Future attempt : attempts) { + attempt.get(60, TimeUnit.SECONDS); + } + assertEquals( + 1, + jdbcTemplate.queryForObject( + "select count(*) from pg_database where datname = ?", Integer.class, schema)); + } + } finally { + pool.shutdownNow(); + for (int round = 0; round < 5; round++) { + jdbcTemplate.execute("drop database if exists race_schema_" + round); + } + } + } + + @Test + void timestampsAreStoredAndReadAsUtcWhateverTheJvmTimeZone() { + // Registry and audit timestamp columns carry no zone. Binding or reading them through + // the JVM's default time zone stores and reports different instants on nodes + // configured differently, so this writes under one zone and reads under another. + final DateTimeFormatter wallClock = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + final TimeZone original = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Kolkata")); + final TenantData created = writeService.create(requestFor("acme")); + + TimeZone.setDefault(TimeZone.getTimeZone("America/New_York")); + final TenantData read = readService.retrieveOne(created.id()); + + final OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + assertTrue( + Duration.between(read.createdDate(), now).abs().toMinutes() < 2, + "createdDate " + read.createdDate() + " should be close to " + now); + + final String storedTenant = + jdbcTemplate.queryForObject( + "select to_char(created_date, 'YYYY-MM-DD HH24:MI:SS') from tenants" + + " where identifier = 'acme'", + String.class); + assertTrue( + Duration.between( + LocalDateTime.parse(storedTenant, wallClock), + LocalDateTime.now(ZoneOffset.UTC)) + .abs() + .toMinutes() + < 2, + "tenants.created_date " + storedTenant + " should be a UTC wall-clock value"); + + final String storedAudit = + jdbcTemplate.queryForObject( + "select to_char(created_at, 'YYYY-MM-DD HH24:MI:SS') from tenant_administration_audit" + + " where tenant_identifier = 'acme' and action = 'CREATE'", + String.class); + assertTrue( + Duration.between( + LocalDateTime.parse(storedAudit, wallClock), + LocalDateTime.now(ZoneOffset.UTC)) + .abs() + .toMinutes() + < 2, + "audit created_at " + storedAudit + " should be a UTC wall-clock value"); + } finally { + TimeZone.setDefault(original); + } + } + + // --------------------------------------------------------------- + // Master users - the super master context + // --------------------------------------------------------------- + + @Test + void aBootstrappedMasterUserIsStoredHashedAndCreatedOnlyOnce() { + jdbcTemplate.update("delete from tenant_master_user"); + final TenantMasterUserStore store = new TenantMasterUserStore(dataSource); + + new TenantMasterUserBootstrap(store, "master", "a-long-enough-password").afterPropertiesSet(); + // A changed configured password must not silently reset an existing master user. + new TenantMasterUserBootstrap(store, "master", "a-different-long-password") + .afterPropertiesSet(); + + assertEquals(1, store.count()); + final TenantMasterUserStore.MasterUser user = store.findByUsername("master").orElseThrow(); + assertEquals("SUPER_MASTER", user.role()); + assertTrue(user.enabled()); + assertFalse(user.passwordHash().contains("a-long-enough-password")); + assertTrue( + PasswordEncoderFactories.createDelegatingPasswordEncoder() + .matches("a-long-enough-password", user.passwordHash())); + } + + @Test + void aBootstrapPasswordThatIsTooShortCreatesNoMasterUser() { + jdbcTemplate.update("delete from tenant_master_user"); + final TenantMasterUserStore store = new TenantMasterUserStore(dataSource); + + new TenantMasterUserBootstrap(store, "master", "short").afterPropertiesSet(); + + assertEquals(0, store.count()); + } + + @Test + void noBootstrapConfigurationCreatesNoMasterUser() { + jdbcTemplate.update("delete from tenant_master_user"); + final TenantMasterUserStore store = new TenantMasterUserStore(dataSource); + + new TenantMasterUserBootstrap(store, "", "").afterPropertiesSet(); + + assertEquals(0, store.count()); + assertTrue(store.findByUsername("master").isEmpty()); + } + + // --------------------------------------------------------------- + // Review round 2: who a database belongs to, and clearing optional fields + // --------------------------------------------------------------- + + private static TenantCreateRequest requestFor(final String identifier, final String schemaName) { + return new TenantCreateRequest( + identifier, + "Another Microfinance", + "Asia/Kolkata", + TenantStatus.ACTIVE, + null, + null, + schemaName, + "db.example.org", + "5432", + "fineract", + "s3cret", + null, + true); + } + + @Test + void aDatabaseAlreadyUsedByAnotherTenantIsRefused() { + writeService.create(requestFor("acme")); + + assertThrows( + TenantSchemaUnavailableException.class, + () -> writeService.create(requestFor("intruder", "mifostenant_acme"))); + assertEquals( + 0, + jdbcTemplate.queryForObject( + "select count(*) from tenants where identifier = 'intruder'", Integer.class)); + } + + @Test + void databaseNamesAreMatchedIgnoringCase() { + // PostgreSQL folds an unquoted CREATE DATABASE name to lower case, so these are the + // same database. + writeService.create(requestFor("acme")); + + assertThrows( + TenantSchemaUnavailableException.class, + () -> writeService.create(requestFor("intruder", "MIFOSTENANT_ACME"))); + } + + @Test + void aRemovedTenantsRetainedDatabaseCannotBeClaimedByAnotherIdentifier() { + final TenantData acme = writeService.create(requestFor("acme")); + writeService.changeStatus(acme.id(), TenantStatus.INACTIVE); + writeService.delete(acme.id()); + + assertThrows( + TenantSchemaUnavailableException.class, + () -> writeService.create(requestFor("intruder", "mifostenant_acme"))); + } + + @Test + void aRemovedTenantCanBeReinstatedUnderItsOwnIdentifier() { + final TenantData acme = writeService.create(requestFor("acme")); + writeService.changeStatus(acme.id(), TenantStatus.INACTIVE); + writeService.delete(acme.id()); + + final TenantData reinstated = writeService.create(requestFor("acme")); + + assertEquals("acme", reinstated.identifier()); + assertEquals( + 0, + jdbcTemplate.queryForObject( + "select count(*) from tenant_retained_schema where tenant_identifier = 'acme'", + Integer.class)); + } + + @Test + void theTenantStoresOwnDatabaseIsRefused() { + assertThrows( + TenantSchemaUnavailableException.class, + () -> writeService.create(requestFor("store", postgres.getDatabaseName()))); + } + + @Test + void anUpdateCanClearAnOptionalField() { + final TenantData created = writeService.create(requestFor("acme")); + + writeService.update( + created.id(), + new TenantUpdateRequest(null, null, "", "", null, null, null, null, null, null)); + + final TenantData read = readService.retrieveOne(created.id()); + assertNull(read.description()); + assertNull(read.contactEmail()); + assertEquals("Acme Microfinance", read.name()); + } + + // --------------------------------------------------------------- + // Review round 3: unrecognised status, delete race, bootstrap race + // --------------------------------------------------------------- + + private TenantManagementWriteService writeServiceReading( + final TenantManagementReadService reads) { + final DatabasePasswordEncryptor encryptor = mock(DatabasePasswordEncryptor.class); + when(encryptor.encrypt(anyString())).thenAnswer(i -> "enc:" + i.getArgument(0)); + when(encryptor.getMasterPasswordHash()).thenReturn("test-master-hash"); + + @SuppressWarnings("unchecked") + final ObjectProvider noCacheManagers = mock(ObjectProvider.class); + when(noCacheManagers.orderedStream()).thenAnswer(i -> Stream.empty()); + + return new TenantManagementWriteService( + dataSource, + new TransactionTemplate(new DataSourceTransactionManager(dataSource)), + encryptor, + mock(TenantProvisioningService.class), + reads, + mock(TenantStatusLookupService.class), + mock(TenantSchemaMigrationService.class), + new TenantAdministrationAuditService(dataSource), + noCacheManagers, + false); + } + + @Test + void anUnrecognisedStoredStatusIsReportedAsNullNotActive() { + // The status filter refuses such a tenant; showing it as ACTIVE would tell + // administrators the opposite of what the platform does. + final TenantData created = writeService.create(requestFor("acme")); + jdbcTemplate.update("update tenants set status = 'DELETED' where id = ?", created.id()); + + assertNull(readService.retrieveOne(created.id()).status()); + // ...and it can still be corrected through the API. + assertEquals( + TenantStatus.ACTIVE, writeService.changeStatus(created.id(), TenantStatus.ACTIVE).status()); + } + + @Test + void aTenantActivatedAfterTheDeleteCheckIsNotRemoved() { + final TenantData created = writeService.create(requestFor("acme")); + writeService.changeStatus(created.id(), TenantStatus.INACTIVE); + final TenantData seenInactive = readService.retrieveOne(created.id()); + + // A concurrent activation commits after delete() has already read the tenant as inactive. + jdbcTemplate.update("update tenants set status = 'ACTIVE' where id = ?", created.id()); + final TenantManagementReadService staleRead = spy(readService); + doReturn(seenInactive).when(staleRead).retrieveOne(created.id()); + + assertThrows( + GeneralPlatformDomainRuleException.class, + () -> writeServiceReading(staleRead).delete(created.id())); + assertEquals( + 1, + jdbcTemplate.queryForObject( + "select count(*) from tenants where id = ?", Integer.class, created.id())); + assertEquals( + 0, + jdbcTemplate.queryForObject( + "select count(*) from tenant_retained_schema where tenant_identifier = 'acme'", + Integer.class)); + } + + @Test + void losingTheFirstDeploymentRaceForTheMasterUserDoesNotFailStartup() { + jdbcTemplate.update("delete from tenant_master_user"); + final TenantMasterUserStore store = new TenantMasterUserStore(dataSource); + // Another node inserts the user after this node has found it absent. + final TenantMasterUserStore racing = + new TenantMasterUserStore(dataSource) { + private boolean firstLookup = true; + + @Override + public Optional findByUsername(final String username) { + if (firstLookup) { + firstLookup = false; + store.create(username, "{noop}other-node", TenantMasterAccess.SUPER_MASTER_ROLE); + return Optional.empty(); + } + return super.findByUsername(username); + } + }; + + assertDoesNotThrow( + () -> + new TenantMasterUserBootstrap(racing, "master", "a-long-enough-password") + .afterPropertiesSet()); + assertEquals(1, store.count()); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/api/TenantManagementApiIntegrationTest.java b/src/test/java/org/apache/fineract/tenant/api/TenantManagementApiIntegrationTest.java new file mode 100644 index 00000000..b29e048a --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/api/TenantManagementApiIntegrationTest.java @@ -0,0 +1,342 @@ +/** + * 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 static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasItem; + +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import io.restassured.specification.RequestSpecification; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.apache.fineract.selfservice.testing.support.SelfServiceIntegrationTestBase; +import org.apache.fineract.selfservice.testing.support.SelfServiceTestUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Exercises {@code /v1/admin/tenants} over HTTP against a real Fineract with this plugin loaded. + * + *

Tenant management runs in the master context: requests authenticate as a master user (created + * at container start by {@code TenantMasterUserBootstrap} from the test base's environment) and + * carry no tenant header. Tenant users - including a tenant's own {@code mifos} super user - are + * refused. + * + *

Creating a tenant runs a complete schema migration, so the lifecycle is one test rather than a + * chain of order-dependent ones. The shared {@code default} tenant is never mutated: other + * integration test classes run against it in the same containers. + */ +class TenantManagementApiIntegrationTest extends SelfServiceIntegrationTestBase { + + private static final String TENANTS_PATH = + SelfServiceTestUtils.CONTEXT_PATH + "/api/v1/admin/tenants"; + private static final String OFFICES_PATH = SelfServiceTestUtils.CONTEXT_PATH + "/api/v1/offices"; + + private static final String MASTER_USERNAME = "master"; + private static final String MASTER_PASSWORD = "master-password-for-tests"; + + private static RequestSpecification base() { + return given() + .relaxedHTTPSValidation() + .baseUri("https://localhost") + .port(getFineractPort()) + .contentType(ContentType.JSON) + .accept(ContentType.JSON); + } + + /** A master user's request: no tenant header, because the master context has none. */ + private static RequestSpecification asMaster() { + return base() + .header( + "Authorization", + SelfServiceTestUtils.basicAuthHeader(MASTER_USERNAME, MASTER_PASSWORD)); + } + + /** + * A tenant user's request. + * + *

Built directly rather than from {@link SelfServiceTestUtils#requestSpec}, which already + * carries the {@code default} tenant header; adding a second would send both. + */ + private static RequestSpecification asTenantUser( + final String tenant, final String username, final String password) { + return base() + .header("Fineract-Platform-TenantId", tenant) + .header("Authorization", SelfServiceTestUtils.basicAuthHeader(username, password)); + } + + private static Map createBody(final String identifier, final String schemaName) { + final Map body = new HashMap<>(); + body.put("identifier", identifier); + body.put("name", "Integration " + identifier); + body.put("timezoneId", "Asia/Kolkata"); + body.put("schemaName", schemaName); + // Fineract reaches Postgres on the shared test network under this alias. + body.put("schemaServer", "db"); + body.put("schemaServerPort", "5432"); + body.put("schemaUsername", "postgres"); + body.put("schemaPassword", "postgres"); + return body; + } + + // --------------------------------------------------------------- + // The master context + // --------------------------------------------------------------- + + @Test + @DisplayName("GET /v1/admin/tenants without credentials is rejected with 401") + void listWithoutCredentials_isRejected() { + base().when().get(TENANTS_PATH).then().statusCode(401); + } + + @Test + @DisplayName("A tenant's own super user is not a master user and is rejected with 401") + void listAsTenantSuperUser_isRejected() { + // mifos holds ALL_FUNCTIONS inside the default tenant. That is a tenant's permission, not + // the super master role, so the master context does not recognise the user at all. + asTenantUser(SelfServiceTestUtils.DEFAULT_TENANT, "mifos", "password") + .when() + .get(TENANTS_PATH) + .then() + .statusCode(401); + } + + @Test + @DisplayName("A master user with a wrong password is rejected with 401") + void listWithAWrongMasterPassword_isRejected() { + base() + .header("Authorization", SelfServiceTestUtils.basicAuthHeader(MASTER_USERNAME, "wrong")) + .when() + .get(TENANTS_PATH) + .then() + .statusCode(401); + } + + @Test + @DisplayName("GET /v1/admin/tenants as a master user lists tenants without credentials") + void listAsMaster_returnsTenantsWithoutCredentials() { + final Response response = + asMaster().when().get(TENANTS_PATH).then().statusCode(200).extract().response(); + + assertThat(response.jsonPath().getList("pageItems.identifier", String.class)) + .contains("default"); + assertThat(response.asString()).doesNotContainIgnoringCase("password"); + } + + @Test + @DisplayName("GET /v1/admin/tenants/template lists the lifecycle statuses") + void template_listsStatuses() { + asMaster() + .when() + .get(TENANTS_PATH + "/template") + .then() + .statusCode(200) + .body("statuses", equalTo(List.of("ACTIVE", "INACTIVE", "SUSPENDED"))); + } + + @Test + @DisplayName("Core's /v1/tenants/{tenantId}/oidc-config stays on Fineract's own security chain") + void coreOidcConfigEndpoint_isNotCapturedByTenantAdministration() { + // Core Fineract serves this path. While tenant administration claimed /v1/tenants/**, + // the master chain answered this tenant user's request with 401 before core saw it. + // Reaching core's resource is proved by its own 404 for a tenant without OIDC setup. + asTenantUser(SelfServiceTestUtils.DEFAULT_TENANT, "mifos", "password") + .when() + .get(SelfServiceTestUtils.CONTEXT_PATH + "/api/v1/tenants/default/oidc-config") + .then() + .statusCode(404) + .body("errors[0].developerMessage", containsString("No OIDC configuration found")); + } + + // --------------------------------------------------------------- + // Validation and error mapping + // --------------------------------------------------------------- + + @Test + @DisplayName("POST /v1/admin/tenants rejects a schema name PostgreSQL cannot create") + void create_withANumericLeadingSchemaName_returns400() { + asMaster() + .body(createBody("numericschema", "123tenant")) + .when() + .post(TENANTS_PATH) + .then() + .statusCode(400) + .body("errors.parameterName", hasItem("schemaName")); + } + + @Test + @DisplayName("POST /v1/admin/tenants rejects DDL injection in the schema name") + void create_withAnInjectionAttempt_returns400() { + asMaster() + .body(createBody("injection", "x; DROP DATABASE fineract_default; --")) + .when() + .post(TENANTS_PATH) + .then() + .statusCode(400) + .body("errors.parameterName", hasItem("schemaName")); + + asMaster() + .when() + .get(TENANTS_PATH + "?search=injection") + .then() + .statusCode(200) + .body("totalFilteredRecords", equalTo(0)); + } + + @Test + @DisplayName("POST /v1/admin/tenants rejects a port outside 1-65535") + void create_withAPortOutOfRange_returns400() { + final Map body = createBody("badport", "mifostenant_badport"); + body.put("schemaServerPort", "70000"); + + asMaster() + .body(body) + .when() + .post(TENANTS_PATH) + .then() + .statusCode(400) + .body("errors.parameterName", hasItem("schemaServerPort")); + } + + @Test + @DisplayName("GET /v1/admin/tenants/{id} for an unknown tenant returns 404") + void retrieveUnknownTenant_returns404() { + asMaster().when().get(TENANTS_PATH + "/999999").then().statusCode(404); + } + + @Test + @DisplayName("POST /v1/admin/tenants/{id} with an unknown command returns 400") + void changeStatus_withAnUnknownCommand_returns400() { + asMaster() + .body("{}") + .when() + .post(TENANTS_PATH + "/1?command=obliterate") + .then() + .statusCode(400); + } + + @Test + @DisplayName("GET /v1/admin/tenants with an unknown status filter returns 400") + void list_withAnUnknownStatusFilter_returns400() { + asMaster().when().get(TENANTS_PATH + "?status=DELETED").then().statusCode(400); + } + + @Test + @DisplayName("POST /v1/admin/tenants/test-connection with a wrong password reports unreachable") + void testConnection_withAWrongPassword_reportsUnreachable() { + final Map body = new HashMap<>(); + body.put("schemaName", "fineract_default"); + body.put("schemaServer", "db"); + body.put("schemaServerPort", "5432"); + body.put("schemaUsername", "postgres"); + body.put("schemaPassword", "definitely-wrong"); + + final Response response = + asMaster() + .body(body) + .when() + .post(TENANTS_PATH + "/test-connection") + .then() + .statusCode(200) + .body("reachable", equalTo(false)) + .extract() + .response(); + + assertThat(response.asString()).doesNotContainIgnoringCase("authentication failed"); + } + + // --------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------- + + @Test + @DisplayName("A tenant can be created, used, suspended, reactivated and removed over HTTP") + void fullLifecycle_createUseSuspendReactivateAndRemove() { + final String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8); + final String identifier = "it" + suffix; + final String schemaName = "mifostenant_it" + suffix; + + final Response created = + asMaster() + .body(createBody(identifier, schemaName)) + .when() + .post(TENANTS_PATH) + .then() + .statusCode(200) + .body("identifier", equalTo(identifier)) + .body("status", equalTo("ACTIVE")) + .extract() + .response(); + assertThat(created.asString()).doesNotContainIgnoringCase("password"); + final int id = created.jsonPath().getInt("id"); + + asMaster().when().get(TENANTS_PATH + "/" + id).then().statusCode(200); + + // Migrated and usable at once, by the administrator seeded into the new tenant... + asTenantUser(identifier, "mifos", "password").when().get(OFFICES_PATH).then().statusCode(200); + + // ...who is a tenant user, and so cannot administer tenants. + asTenantUser(identifier, "mifos", "password").when().get(TENANTS_PATH).then().statusCode(401); + + asMaster() + .body("{}") + .when() + .post(TENANTS_PATH + "/" + id + "?command=suspend") + .then() + .statusCode(200) + .body("status", equalTo("SUSPENDED")); + + asTenantUser(identifier, "mifos", "password") + .when() + .get(OFFICES_PATH) + .then() + .statusCode(503) + .body("tenantStatus", equalTo("SUSPENDED")); + + // A browser client sends its tenant header on every call. Addressing the master + // endpoints with the suspended tenant's header must not lock administration out. + asMaster() + .header("Fineract-Platform-TenantId", identifier) + .when() + .get(TENANTS_PATH + "/" + id) + .then() + .statusCode(200); + + asMaster() + .body("{}") + .when() + .post(TENANTS_PATH + "/" + id + "?command=activate") + .then() + .statusCode(200); + asTenantUser(identifier, "mifos", "password").when().get(OFFICES_PATH).then().statusCode(200); + + asMaster() + .body(Map.of("identifier", "renamed")) + .when() + .put(TENANTS_PATH + "/" + id) + .then() + .statusCode(400); + + // An active tenant cannot be removed in one step. + asMaster().when().delete(TENANTS_PATH + "/" + id).then().statusCode(403); + + asMaster() + .body("{}") + .when() + .post(TENANTS_PATH + "/" + id + "?command=deactivate") + .then() + .statusCode(200); + asMaster().when().delete(TENANTS_PATH + "/" + id).then().statusCode(200); + asMaster().when().get(TENANTS_PATH + "/" + id).then().statusCode(404); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/api/TenantManagementApiResourceTest.java b/src/test/java/org/apache/fineract/tenant/api/TenantManagementApiResourceTest.java new file mode 100644 index 00000000..dc56e4fc --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/api/TenantManagementApiResourceTest.java @@ -0,0 +1,214 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Arrays; +import java.util.List; +import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.Page; +import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException; +import org.apache.fineract.tenant.data.TenantData; +import org.apache.fineract.tenant.data.TenantManagementDataValidator; +import org.apache.fineract.tenant.domain.TenantStatus; +import org.apache.fineract.tenant.service.TenantManagementReadService; +import org.apache.fineract.tenant.service.TenantManagementWriteService; +import org.apache.fineract.tenant.service.TenantProvisioningService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +class TenantManagementApiResourceTest { + + private static final TenantData A_TENANT = + new TenantData( + 1L, + "acme", + "Acme", + "Asia/Kolkata", + TenantStatus.ACTIVE, + null, + null, + null, + null, + null, + null); + + private static final String CREATE_JSON = + """ + { + "identifier": "acme", "name": "Acme", "timezoneId": "Asia/Kolkata", + "schemaName": "acme", "schemaServer": "db", "schemaServerPort": "5432", + "schemaUsername": "u", "schemaPassword": "p" + } + """; + + private static final String CONNECTION_JSON = + """ + { + "schemaName": "acme", "schemaServer": "db", "schemaServerPort": "5432", + "schemaUsername": "u", "schemaPassword": "p" + } + """; + + private TenantManagementReadService readService; + private TenantManagementWriteService writeService; + private TenantProvisioningService provisioningService; + private TenantManagementApiResource resource; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + readService = mock(TenantManagementReadService.class); + writeService = mock(TenantManagementWriteService.class); + provisioningService = mock(TenantProvisioningService.class); + when(readService.retrieveAll(any(), any(), any(), any())) + .thenReturn(new Page<>(List.of(A_TENANT), 1)); + when(readService.retrieveOne(anyLong())).thenReturn(A_TENANT); + when(writeService.create(any())).thenReturn(A_TENANT); + when(writeService.update(anyLong(), any())).thenReturn(A_TENANT); + when(writeService.changeStatus(anyLong(), any())).thenReturn(A_TENANT); + + resource = + new TenantManagementApiResource( + readService, + writeService, + provisioningService, + // A real validator, so the resource's own parsing is exercised rather + // than stubbed away. + new TenantManagementDataValidator(new FromJsonHelper()), + mock(DefaultToApiJsonSerializer.class)); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private static void authenticateAs(final String username, final String... roles) { + SecurityContextHolder.getContext() + .setAuthentication( + new UsernamePasswordAuthenticationToken( + username, + null, + Arrays.stream(roles) + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList())); + } + + // --------------------------------------------------------------- + // Every endpoint requires the super master role, independently of the chain + // --------------------------------------------------------------- + + @Test + void everyEndpointServesASuperMaster() { + authenticateAs("master", "SUPER_MASTER"); + + resource.retrieveAll(null, null, null, null); + resource.retrieveTemplate(); + resource.retrieveOne(1L); + resource.create(CREATE_JSON); + resource.update(1L, "{\"name\": \"Renamed\"}"); + resource.changeStatus(1L, "suspend"); + resource.delete(1L); + resource.testConnection(CONNECTION_JSON); + + verify(writeService).create(any()); + verify(writeService).delete(1L); + } + + @Test + void anUnauthenticatedCallIsRefusedBeforeAnyWork() { + assertThrows( + NoAuthorizationException.class, () -> resource.retrieveAll(null, null, null, null)); + assertThrows(NoAuthorizationException.class, () -> resource.create(CREATE_JSON)); + + verify(readService, never()).retrieveAll(any(), any(), any(), any()); + verify(writeService, never()).create(any()); + } + + @Test + void anAuthenticatedUserWithoutTheSuperMasterRoleIsRefused() { + // A tenant user who reached this code by any route - even one holding every + // permission inside their own tenant - is not a master user. + authenticateAs("mifos", "ALL_FUNCTIONS"); + + assertThrows(NoAuthorizationException.class, () -> resource.retrieveTemplate()); + assertThrows(NoAuthorizationException.class, () -> resource.retrieveOne(1L)); + assertThrows(NoAuthorizationException.class, () -> resource.update(1L, "{\"name\": \"x\"}")); + assertThrows(NoAuthorizationException.class, () -> resource.changeStatus(1L, "suspend")); + assertThrows(NoAuthorizationException.class, () -> resource.delete(1L)); + assertThrows(NoAuthorizationException.class, () -> resource.testConnection(CONNECTION_JSON)); + + verify(writeService, never()).delete(anyLong()); + verify(writeService, never()).changeStatus(anyLong(), any()); + } + + // --------------------------------------------------------------- + // Status commands and filters + // --------------------------------------------------------------- + + @Test + void changeStatus_mapsEachCommandToItsStatus() { + authenticateAs("master", "SUPER_MASTER"); + + resource.changeStatus(1L, "activate"); + resource.changeStatus(1L, "deactivate"); + resource.changeStatus(1L, "suspend"); + + verify(writeService).changeStatus(1L, TenantStatus.ACTIVE); + verify(writeService).changeStatus(1L, TenantStatus.INACTIVE); + verify(writeService).changeStatus(1L, TenantStatus.SUSPENDED); + } + + @Test + void changeStatus_rejectsAnUnknownCommand() { + authenticateAs("master", "SUPER_MASTER"); + + assertThrows( + UnrecognizedQueryParamException.class, () -> resource.changeStatus(1L, "obliterate")); + + verify(writeService, never()).changeStatus(anyLong(), any()); + } + + @Test + void changeStatus_rejectsAMissingCommandRatherThanGuessing() { + authenticateAs("master", "SUPER_MASTER"); + + assertThrows(UnrecognizedQueryParamException.class, () -> resource.changeStatus(1L, null)); + } + + @Test + void list_rejectsAnUnknownStatusFilter() { + authenticateAs("master", "SUPER_MASTER"); + + assertThrows( + UnrecognizedQueryParamException.class, + () -> resource.retrieveAll(null, "DELETED", null, null)); + } + + @Test + void list_treatsABlankStatusFilterAsNoFilter() { + authenticateAs("master", "SUPER_MASTER"); + + resource.retrieveAll(null, " ", null, null); + + verify(readService).retrieveAll(null, null, null, null); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/api/TenantManagementOpenApiSpec.java b/src/test/java/org/apache/fineract/tenant/api/TenantManagementOpenApiSpec.java new file mode 100644 index 00000000..4f81c68d --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/api/TenantManagementOpenApiSpec.java @@ -0,0 +1,68 @@ +/** + * 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 com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import io.swagger.v3.core.util.Yaml; +import io.swagger.v3.jaxrs2.Reader; +import io.swagger.v3.oas.integration.SwaggerConfiguration; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import java.util.Set; + +/** + * Generates the OpenAPI description of {@link TenantManagementApiResource} from its annotations. + * + *

Output is key-sorted so it is identical on every run, whatever order reflection happens to + * return the resource's methods in - otherwise the committed file would churn and the drift test + * would flake. + */ +final class TenantManagementOpenApiSpec { + + static final String SECURITY_SCHEME = "masterBasicAuth"; + + private TenantManagementOpenApiSpec() {} + + static String generate() throws Exception { + final OpenAPI base = + new OpenAPI() + .info( + new Info() + .title("Fineract Tenant Management API") + .version("1.0") + .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.")) + .addServersItem( + new Server() + .url("/fineract-provider/api") + .description("Apache Fineract with the self-service plugin loaded")) + .components( + new Components() + .addSecuritySchemes( + SECURITY_SCHEME, + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("basic") + .description("A master user holding the SUPER_MASTER role"))) + .addSecurityItem(new SecurityRequirement().addList(SECURITY_SCHEME)); + + final OpenAPI spec = + new Reader(new SwaggerConfiguration().openAPI(base)) + .read(Set.of(TenantManagementApiResource.class)); + + final ObjectMapper mapper = + Yaml.mapper().copy().enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS); + return mapper.writeValueAsString(spec); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/api/TenantManagementOpenApiSpecTest.java b/src/test/java/org/apache/fineract/tenant/api/TenantManagementOpenApiSpecTest.java new file mode 100644 index 00000000..3ca02c92 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/api/TenantManagementOpenApiSpecTest.java @@ -0,0 +1,81 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +/** + * Keeps {@code api-reference/openapi/tenant-management.yaml} in step with the resource annotations. + * + *

Regenerate after changing the API with {@code ./mvnw test + * -Dtest=TenantManagementOpenApiSpecTest -Dopenapi.update=true}. + */ +class TenantManagementOpenApiSpecTest { + + private static final Path COMMITTED_SPEC = + Path.of("api-reference/openapi/tenant-management.yaml"); + + @Test + void theCommittedSpecMatchesTheAnnotations() throws Exception { + final String generated = TenantManagementOpenApiSpec.generate(); + + if (Boolean.getBoolean("openapi.update")) { + Files.createDirectories(COMMITTED_SPEC.getParent()); + Files.writeString(COMMITTED_SPEC, generated); + } + + assertTrue( + Files.exists(COMMITTED_SPEC), + COMMITTED_SPEC + + " is missing. Generate it with: ./mvnw test" + + " -Dtest=TenantManagementOpenApiSpecTest -Dopenapi.update=true"); + assertEquals( + generated, + Files.readString(COMMITTED_SPEC).replace("\r\n", "\n"), + COMMITTED_SPEC + + " is out of date with TenantManagementApiResource. Regenerate it with: ./mvnw test" + + " -Dtest=TenantManagementOpenApiSpecTest -Dopenapi.update=true"); + } + + @Test + void everyEndpointIsDocumentedUnderTheAdminNamespace() throws Exception { + final String spec = TenantManagementOpenApiSpec.generate(); + + for (final String path : + new String[] { + "/v1/admin/tenants:", + "/v1/admin/tenants/template:", + "/v1/admin/tenants/{id}:", + "/v1/admin/tenants/test-connection:" + }) { + assertTrue(spec.contains(path), "missing path " + path); + } + // Core owns /v1/tenants/{tenantId}/oidc-config; nothing here may claim that namespace. + assertFalse(spec.contains("\n /v1/tenants"), "tenant administration must not use /v1/tenants"); + } + + @Test + void noResponseSchemaCarriesAPassword() throws Exception { + final String spec = TenantManagementOpenApiSpec.generate(); + + final int responses = spec.indexOf("GetTenantConnectionResponse:"); + assertTrue(responses >= 0, "connection response schema missing"); + final String connectionSchema = + spec.substring( + responses, + spec.indexOf("\n Get", responses + 1) > 0 + ? spec.indexOf("\n Get", responses + 1) + : spec.length()); + assertFalse(connectionSchema.contains("Password"), "a response schema exposes a password"); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/data/TenantManagementDataValidatorTest.java b/src/test/java/org/apache/fineract/tenant/data/TenantManagementDataValidatorTest.java new file mode 100644 index 00000000..ab14b291 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/data/TenantManagementDataValidatorTest.java @@ -0,0 +1,482 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Locale; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +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.TenantStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class TenantManagementDataValidatorTest { + + private final TenantManagementDataValidator validator = + new TenantManagementDataValidator(new FromJsonHelper()); + + /** A create payload with every required field present, so a test can vary one at a time. */ + private static String createJson(final String schemaName, final String identifier) { + return + """ + { + "identifier": "%s", + "name": "Acme Microfinance", + "timezoneId": "Asia/Kolkata", + "schemaName": "%s", + "schemaServer": "db.example.org", + "schemaServerPort": "5432", + "schemaUsername": "fineract", + "schemaPassword": "s3cret" + } + """ + .formatted(jsonEscape(identifier), jsonEscape(schemaName)); + } + + /** + * Escapes a value so it survives into the JSON body intact. + * + *

Without this a hostile value containing a quote would break the payload and be rejected by + * the JSON parser, so the test would pass without ever reaching the validator - proving nothing + * about the rule it claims to check. + */ + private static String jsonEscape(final String value) { + final StringBuilder escaped = new StringBuilder(); + for (final char c : value.toCharArray()) { + switch (c) { + case '"' -> escaped.append("\\\""); + case '\\' -> escaped.append("\\\\"); + case '\n' -> escaped.append("\\n"); + case '\r' -> escaped.append("\\r"); + case '\t' -> escaped.append("\\t"); + default -> escaped.append(c); + } + } + return escaped.toString(); + } + + private static List parametersInError(final PlatformApiDataValidationException e) { + return e.getErrors().stream().map(ApiParameterError::getParameterName).toList(); + } + + // --------------------------------------------------------------- + // Schema name: concatenated into DDL, so the pattern is the defence + // --------------------------------------------------------------- + + @ParameterizedTest + @ValueSource( + strings = { + "tenants; DROP TABLE tenants", + "acme\"; DROP DATABASE x; --", + "acme`", + "acme'", + "acme bar", + "acme-bar", + "acme.bar", + "acme$bar", + "acme\\bar", + "acme/bar", + "acme\nbar" + }) + void create_rejectsASchemaNameThatCouldEscapeIntoDdl(final String schemaName) { + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForCreate(createJson(schemaName, "acme"))); + + assertTrue(parametersInError(thrown).contains("schemaName")); + } + + @Test + void create_rejectsASchemaNameLongerThanThePortableIdentifierLimit() { + // 63 is PostgreSQL's limit and the shortest across supported engines, so a + // longer name would be creatable on one database and not another. + final String tooLong = "a".repeat(64); + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForCreate(createJson(tooLong, "acme"))); + + assertTrue(parametersInError(thrown).contains("schemaName")); + } + + @ParameterizedTest + @ValueSource(strings = {"mifostenant_acme", "ACME", "a", "a_1"}) + void create_acceptsAPlainSchemaName(final String schemaName) { + assertEquals( + schemaName.toLowerCase(Locale.ROOT), + validator.validateForCreate(createJson(schemaName, "acme")).schemaName()); + } + + @Test + void create_acceptsASchemaNameAtExactlyThePortableIdentifierLimit() { + // Boundary: 63 is allowed, 64 is not - see the test above. + final String atLimit = "a".repeat(63); + + assertEquals(atLimit, validator.validateForCreate(createJson(atLimit, "acme")).schemaName()); + } + + // --------------------------------------------------------------- + // Identifier: travels in an HTTP header, matched on every request + // --------------------------------------------------------------- + + @ParameterizedTest + @ValueSource( + strings = {"Acme", "-acme", "_acme", "acme tenant", "acme\ttenant", "acme:1", "ácme"}) + void create_rejectsAnIdentifierThatIsNotTheNarrowShape(final String identifier) { + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForCreate(createJson("acme", identifier))); + + assertTrue(parametersInError(thrown).contains("identifier")); + } + + @ParameterizedTest + @ValueSource(strings = {"acme", "acme-1", "acme_1", "default", "0acme"}) + void create_acceptsAnIdentifierInTheNarrowShape(final String identifier) { + assertEquals( + identifier, validator.validateForCreate(createJson("acme", identifier)).identifier()); + } + + // --------------------------------------------------------------- + // Required fields and defaults + // --------------------------------------------------------------- + + @Test + void create_requiresAPasswordSoATenantIsNeverProvisionedWithAGuessableOne() { + final String json = + """ + { + "identifier": "acme", "name": "Acme", "timezoneId": "Asia/Kolkata", + "schemaName": "acme", "schemaServer": "db", "schemaServerPort": "5432", + "schemaUsername": "fineract" + } + """; + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); + + assertTrue(parametersInError(thrown).contains("schemaPassword")); + } + + @Test + void create_defaultsAutoUpdateToTrueMatchingTheColumnDefault() { + assertTrue(validator.validateForCreate(createJson("acme", "acme")).autoUpdate()); + } + + @Test + void create_defaultsStatusToActive() { + assertEquals( + TenantStatus.ACTIVE, validator.validateForCreate(createJson("acme", "acme")).status()); + } + + @Test + void create_rejectsAStatusThatIsNotOneOfTheThree() { + final String json = + createJson("acme", "acme").replace("\"name\":", "\"status\": \"DELETED\", \"name\":"); + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); + + assertTrue(parametersInError(thrown).contains("status")); + } + + @Test + void create_rejectsATimezoneThisJvmCannotResolve() { + final String json = createJson("acme", "acme").replace("Asia/Kolkata", "Mars/Olympus_Mons"); + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); + + assertTrue(parametersInError(thrown).contains("timezoneId")); + } + + @Test + void create_rejectsAMalformedContactEmail() { + final String json = + createJson("acme", "acme") + .replace("\"name\":", "\"contactEmail\": \"not-an-email\", \"name\":"); + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); + + assertTrue(parametersInError(thrown).contains("contactEmail")); + } + + @Test + void create_rejectsAnAbsentBody() { + assertThrows(InvalidJsonException.class, () -> validator.validateForCreate(" ")); + } + + // --------------------------------------------------------------- + // Update + // --------------------------------------------------------------- + + @Test + void update_rejectsAnAttemptToRenameTheIdentifierRatherThanIgnoringIt() { + // Silently dropping it would leave the caller believing the rename happened. + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForUpdate("{\"identifier\": \"renamed\"}")); + + assertTrue(parametersInError(thrown).contains("identifier")); + } + + @Test + void update_rejectsABodyThatWouldChangeNothing() { + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForUpdate("{}")); + } + + @Test + void update_leavesOmittedFieldsNullSoTheyAreNotOverwritten() { + final TenantUpdateRequest request = validator.validateForUpdate("{\"name\": \"Renamed\"}"); + + assertEquals("Renamed", request.name()); + assertNull(request.schemaPassword()); + assertNull(request.timezoneId()); + assertNull(request.autoUpdate()); + } + + @Test + void update_treatsAWhitespaceOnlyValueAsAbsent() { + // Otherwise a stray space would blank a tenant's name. + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForUpdate("{\"name\": \" \"}")); + } + + // --------------------------------------------------------------- + // Connection test + // --------------------------------------------------------------- + + @Test + void connectionTest_holdsTheSameSchemaNameRuleAsCreate() { + // Otherwise the probe could be pointed at a target create itself would refuse. + final String json = + """ + { + "schemaName": "acme; DROP TABLE tenants", "schemaServer": "db", + "schemaServerPort": "5432", "schemaUsername": "u", "schemaPassword": "p" + } + """; + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForConnectionTest(json)); + + assertTrue(parametersInError(thrown).contains("schemaName")); + } + + @Test + void connectionTest_rejectsANonNumericPort() { + final String json = + """ + { + "schemaName": "acme", "schemaServer": "db", "schemaServerPort": "5432; evil", + "schemaUsername": "u", "schemaPassword": "p" + } + """; + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForConnectionTest(json)); + + assertTrue(parametersInError(thrown).contains("schemaServerPort")); + } + + // --------------------------------------------------------------- + // Review follow-ups: a schema name PostgreSQL accepts, and the port range + // --------------------------------------------------------------- + + @ParameterizedTest + @ValueSource(strings = {"123tenant", "9acme", "0"}) + void create_rejectsASchemaNameStartingWithADigit(final String schemaName) { + // PostgreSQL rejects an unquoted identifier that starts with a digit: + // CREATE DATABASE 123tenant is a syntax error, so this must fail at validation. + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForCreate(createJson(schemaName, "acme"))); + + assertTrue(parametersInError(thrown).contains("schemaName")); + } + + @Test + void create_acceptsASchemaNameStartingWithAnUnderscore() { + assertEquals("_acme", validator.validateForCreate(createJson("_acme", "acme")).schemaName()); + } + + @ParameterizedTest + @ValueSource(strings = {"0", "65536", "99999"}) + void create_rejectsAPortOutsideTheValidRange(final String port) { + final String json = createJson("acme", "acme").replace("\"5432\"", "\"" + port + "\""); + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); + + assertTrue(parametersInError(thrown).contains("schemaServerPort")); + } + + @ParameterizedTest + @ValueSource(strings = {"1", "65535"}) + void create_acceptsAPortAtEitherEndOfTheRange(final String port) { + final String json = createJson("acme", "acme").replace("\"5432\"", "\"" + port + "\""); + + assertEquals(port, validator.validateForCreate(json).schemaServerPort()); + } + + @Test + void update_rejectsAPortOutsideTheValidRange() { + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForUpdate("{\"schemaServerPort\": \"70000\"}")); + + assertTrue(parametersInError(thrown).contains("schemaServerPort")); + } + + @Test + void connectionTest_rejectsAPortOutsideTheValidRange() { + final String json = + """ + { + "schemaName": "acme", "schemaServer": "db", "schemaServerPort": "70000", + "schemaUsername": "u", "schemaPassword": "p" + } + """; + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForConnectionTest(json)); + + assertTrue(parametersInError(thrown).contains("schemaServerPort")); + } + + // --------------------------------------------------------------- + // Review round 2: system databases, and blank values on update + // --------------------------------------------------------------- + + @ParameterizedTest + @ValueSource(strings = {"postgres", "TEMPLATE1", "template0", "mysql", "information_schema"}) + void create_rejectsASystemDatabase(final String schemaName) { + // Create reuses an existing database of the requested name, so a system database + // would have Fineract's migrations run inside it. + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForCreate(createJson(schemaName, "acme"))); + + assertTrue(parametersInError(thrown).contains("schemaName")); + } + + @Test + void update_refusesABlankRequiredFieldInsteadOfApplyingTheRestOfTheRequest() { + // {"name":" ","description":"updated"} used to update the description and silently + // skip the name. + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForUpdate("{\"name\": \" \", \"description\": \"updated\"}")); + + assertTrue(parametersInError(thrown).contains("name")); + } + + @ParameterizedTest + @ValueSource(strings = {"timezoneId", "schemaServer", "schemaServerPort", "schemaUsername"}) + void update_refusesABlankValueForEveryRequiredField(final String field) { + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, + () -> validator.validateForUpdate("{\"" + field + "\": \" \"}")); + + assertTrue(parametersInError(thrown).contains(field)); + } + + @ParameterizedTest + @ValueSource(strings = {"\"\"", "\" \"", "null"}) + void update_clearsAnOptionalFieldSentBlankOrNull(final String value) { + final TenantUpdateRequest request = + validator.validateForUpdate( + "{\"description\": " + + value + + ", \"contactEmail\": " + + value + + ", \"schemaConnectionParameters\": " + + value + + "}"); + + assertEquals("", request.description()); + assertEquals("", request.contactEmail()); + assertEquals("", request.schemaConnectionParameters()); + assertFalse(request.isEmpty()); + } + + @Test + void update_leavesAnOmittedOptionalFieldUnchanged() { + final TenantUpdateRequest request = validator.validateForUpdate("{\"name\": \"Renamed\"}"); + + assertNull(request.description()); + assertNull(request.contactEmail()); + } + + // --------------------------------------------------------------- + // Review round 3: canonical schema names, blank status + // --------------------------------------------------------------- + + @Test + void create_lowerCasesTheSchemaNameSoPostgresAndTheConnectionAgree() { + // PostgreSQL creates CREATE DATABASE ACME as "acme"; connecting to "ACME" then fails. + assertEquals( + "mifostenant_acme", + validator.validateForCreate(createJson("MifosTenant_ACME", "acme")).schemaName()); + } + + @Test + void connectionTest_lowerCasesTheSchemaNameToo() { + final String json = + """ + { + "schemaName": "ACME", "schemaServer": "db", "schemaServerPort": "5432", + "schemaUsername": "u", "schemaPassword": "p" + } + """; + + assertEquals("acme", validator.validateForConnectionTest(json).schemaName()); + } + + @Test + void create_refusesABlankStatusInsteadOfDefaultingToActive() { + final String json = + createJson("acme", "acme").replace("\"name\":", "\"status\": \" \", \"name\":"); + + final PlatformApiDataValidationException thrown = + assertThrows( + PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); + + assertTrue(parametersInError(thrown).contains("status")); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/data/TenantUpdateRequestTest.java b/src/test/java/org/apache/fineract/tenant/data/TenantUpdateRequestTest.java new file mode 100644 index 00000000..b2542d95 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/data/TenantUpdateRequestTest.java @@ -0,0 +1,66 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class TenantUpdateRequestTest { + + private static TenantUpdateRequest empty() { + return new TenantUpdateRequest(null, null, null, null, null, null, null, null, null, null); + } + + @Test + void aRequestWithNothingSetChangesNothing() { + assertTrue(empty().isEmpty()); + assertEquals(List.of(), empty().changedFieldNames()); + } + + @Test + void anyOneFieldMakesTheRequestNonEmpty() { + assertFalse( + new TenantUpdateRequest(null, null, null, null, null, null, null, null, null, true) + .isEmpty()); + } + + @Test + void changedFieldNamesRecordsAPasswordRotationWithoutThePassword() { + // The audit trail is built from this, so the name must appear and the value + // must not. + final TenantUpdateRequest request = + new TenantUpdateRequest(null, null, null, null, null, null, null, "s3cret", null, null); + + assertEquals(List.of("schemaPassword"), request.changedFieldNames()); + assertFalse(request.changedFieldNames().toString().contains("s3cret")); + } + + @Test + void changedFieldNamesListsEveryChangedFieldInAStableOrder() { + final TenantUpdateRequest request = + new TenantUpdateRequest( + "n", "Asia/Kolkata", "d", "e@x.org", "host", "5432", "user", "pw", "params", true); + + assertEquals( + List.of( + "name", + "timezoneId", + "description", + "contactEmail", + "schemaServer", + "schemaServerPort", + "schemaUsername", + "schemaPassword", + "schemaConnectionParameters", + "autoUpdate"), + request.changedFieldNames()); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/domain/TenantAdministrationActionTest.java b/src/test/java/org/apache/fineract/tenant/domain/TenantAdministrationActionTest.java new file mode 100644 index 00000000..20a98d34 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/domain/TenantAdministrationActionTest.java @@ -0,0 +1,38 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class TenantAdministrationActionTest { + + @Test + void eachStatusMapsToItsOwnAction() { + assertEquals( + TenantAdministrationAction.ACTIVATE, + TenantAdministrationAction.forStatusChange(TenantStatus.ACTIVE)); + assertEquals( + TenantAdministrationAction.DEACTIVATE, + TenantAdministrationAction.forStatusChange(TenantStatus.INACTIVE)); + assertEquals( + TenantAdministrationAction.SUSPEND, + TenantAdministrationAction.forStatusChange(TenantStatus.SUSPENDED)); + } + + @ParameterizedTest + @EnumSource(TenantStatus.class) + void everyStatusIsMapped(final TenantStatus status) { + // The switch is exhaustive over the enum, so a status added later fails to + // compile here rather than slipping through unaudited. + org.junit.jupiter.api.Assertions.assertNotNull( + TenantAdministrationAction.forStatusChange(status)); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/domain/TenantStatusTest.java b/src/test/java/org/apache/fineract/tenant/domain/TenantStatusTest.java new file mode 100644 index 00000000..b721f6ae --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/domain/TenantStatusTest.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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Locale; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class TenantStatusTest { + + private final Locale originalLocale = Locale.getDefault(); + + @AfterEach + void restoreLocale() { + Locale.setDefault(originalLocale); + } + + @ParameterizedTest + @ValueSource(strings = {"ACTIVE", "active", "Active", " active "}) + void fromString_acceptsAnyCaseAndSurroundingWhitespace(final String value) { + assertEquals(Optional.of(TenantStatus.ACTIVE), TenantStatus.fromString(value)); + } + + @Test + void fromString_isNotAffectedByTheDefaultLocale() { + // Turkish folds a dotted I to a dotless i. If the parser used the default + // locale, INACTIVE would stop being recognised on a Turkish server - the + // same class of bug the branding module documents for colour names. + Locale.setDefault(Locale.forLanguageTag("tr")); + + assertEquals(Optional.of(TenantStatus.INACTIVE), TenantStatus.fromString("INACTIVE")); + assertEquals(Optional.of(TenantStatus.INACTIVE), TenantStatus.fromString("inactive")); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "UNKNOWN", "ACTIVE_", "deleted"}) + void fromString_rejectsAnythingThatIsNotAStatus(final String value) { + assertTrue(TenantStatus.fromString(value).isEmpty()); + } + + @Test + void fromString_handlesNull() { + assertTrue(TenantStatus.fromString(null).isEmpty()); + } + + @Test + void names_listsEveryStatusInDeclarationOrder() { + assertEquals(java.util.List.of("ACTIVE", "INACTIVE", "SUSPENDED"), TenantStatus.names()); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/exception/TenantExceptionCauseTest.java b/src/test/java/org/apache/fineract/tenant/exception/TenantExceptionCauseTest.java new file mode 100644 index 00000000..d6460e60 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/exception/TenantExceptionCauseTest.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 static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.sql.SQLException; +import org.junit.jupiter.api.Test; + +class TenantExceptionCauseTest { + + @Test + void aConnectionFailureCarriesTheDriverErrorAsItsCause() { + // Constructing must not throw. The previous initCause-based version threw + // "Can't overwrite cause" on every Fineract version, so a wrong password became a 500. + final SQLException driverError = new SQLException("password authentication failed"); + + final TenantConnectionFailedException e = + new TenantConnectionFailedException("db", "5432", "acme", driverError); + + assertSame(driverError, e.getCause()); + } + + @Test + void aMigrationFailureCarriesTheLiquibaseErrorAsItsCause() { + final IllegalStateException liquibaseError = new IllegalStateException("changeset failed"); + + final TenantSchemaMigrationFailedException e = + new TenantSchemaMigrationFailedException("acme", liquibaseError); + + assertSame(liquibaseError, e.getCause()); + } + + @Test + void theDriverErrorStaysOutOfTheUserFacingMessage() { + // Driver messages echo users and connection details; they belong in the log only. + final TenantConnectionFailedException e = + new TenantConnectionFailedException( + "db", + "5432", + "acme", + new SQLException("FATAL: password authentication failed for user \"postgres\"")); + + assertFalse(e.getMessage().contains("password authentication")); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/filter/TenantStatusEnforcementFilterTest.java b/src/test/java/org/apache/fineract/tenant/filter/TenantStatusEnforcementFilterTest.java new file mode 100644 index 00000000..a298ede3 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/filter/TenantStatusEnforcementFilterTest.java @@ -0,0 +1,227 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.FilterChain; +import org.apache.fineract.tenant.domain.TenantStatus; +import org.apache.fineract.tenant.service.TenantStatusLookupService; +import org.apache.fineract.tenant.service.TenantStatusLookupService.Lookup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +class TenantStatusEnforcementFilterTest { + + private TenantStatusLookupService lookupService; + private TenantStatusEnforcementFilter filter; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + private FilterChain chain; + + @BeforeEach + void setUp() { + lookupService = mock(TenantStatusLookupService.class); + filter = new TenantStatusEnforcementFilter(lookupService); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + chain = mock(FilterChain.class); + } + + private void addressedTo(final String tenant) { + request.addHeader(TenantStatusEnforcementFilter.TENANT_ID_REQUEST_HEADER, tenant); + } + + @Test + void anActiveTenantPassesThrough() throws Exception { + addressedTo("acme"); + when(lookupService.statusOf("acme")).thenReturn(Lookup.known(TenantStatus.ACTIVE)); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + assertEquals(200, response.getStatus()); + } + + @ParameterizedTest + @EnumSource( + value = TenantStatus.class, + names = {"INACTIVE", "SUSPENDED"}) + void aTenantThatIsNotActiveIsRefused(final TenantStatus status) throws Exception { + // Without this the status column would be decorative: core resolves a tenant + // with no status predicate, so a suspended tenant would keep being served. + addressedTo("acme"); + when(lookupService.statusOf("acme")).thenReturn(Lookup.known(status)); + + filter.doFilter(request, response, chain); + + verify(chain, never()).doFilter(any(), any()); + assertEquals(503, response.getStatus()); + assertTrue(response.getContentAsString().contains(status.name())); + } + + @Test + void aTenantWithAnUnrecognisedStoredStatusIsRefused() throws Exception { + // Fails closed: an unrecognised value is a hand edit or corruption, and must + // not be treated as ACTIVE. + addressedTo("acme"); + when(lookupService.statusOf("acme")).thenReturn(Lookup.unrecognised()); + + filter.doFilter(request, response, chain); + + verify(chain, never()).doFilter(any(), any()); + assertEquals(503, response.getStatus()); + assertTrue(response.getContentAsString().contains("UNRECOGNISED")); + } + + @Test + void aRefusalHappensBeforeAnyCredentialIsRead() throws Exception { + // The filter sits ahead of the security chain, so a suspended tenant is turned + // away without the request ever reaching authentication. + addressedTo("acme"); + request.addHeader("Authorization", "Basic bWlmb3M6cGFzc3dvcmQ="); + when(lookupService.statusOf("acme")).thenReturn(Lookup.known(TenantStatus.SUSPENDED)); + + filter.doFilter(request, response, chain); + + verify(chain, never()).doFilter(any(), any()); + assertFalse(response.getContentAsString().contains("bWlmb3M6cGFzc3dvcmQ=")); + assertFalse(response.getContentAsString().toLowerCase().contains("authorization")); + } + + @Test + void theDefaultTenantIsRefusedWhenSuspendedLikeAnyOther() throws Exception { + // No tenant is exempt any more: administrators work in the master context, so + // suspending "default" cannot lock them out of reinstating it. + addressedTo("default"); + when(lookupService.statusOf("default")).thenReturn(Lookup.known(TenantStatus.SUSPENDED)); + + filter.doFilter(request, response, chain); + + verify(chain, never()).doFilter(any(), any()); + assertEquals(503, response.getStatus()); + } + + @Test + void tenantAdministrationIsNeverBlockedEvenWithASuspendedTenantHeader() throws Exception { + // A browser client attaches its tenant header to every request. Tenant management + // is not addressed to that tenant, so a suspension must not reach it. + request.setContextPath("/fineract-provider"); + request.setRequestURI("/fineract-provider/api/v1/admin/tenants/7"); + addressedTo("default"); + when(lookupService.statusOf("default")).thenReturn(Lookup.known(TenantStatus.SUSPENDED)); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(lookupService, never()).statusOf(any()); + } + + @Test + void onlyTheTenantAdministrationPathsAreTreatedAsAdministration() { + final org.springframework.mock.web.MockHttpServletRequest lookalike = + new org.springframework.mock.web.MockHttpServletRequest(); + lookalike.setContextPath("/fineract-provider"); + lookalike.setRequestURI("/fineract-provider/api/v1/admin/tenantsx"); + assertFalse(TenantStatusEnforcementFilter.isTenantAdministration(lookalike)); + + lookalike.setRequestURI("/fineract-provider/api/v1/admin/tenants"); + assertTrue(TenantStatusEnforcementFilter.isTenantAdministration(lookalike)); + + lookalike.setRequestURI("/fineract-provider/v1/admin/tenants/template"); + assertTrue(TenantStatusEnforcementFilter.isTenantAdministration(lookalike)); + + // Core's own OIDC configuration lives under the /v1/tenants namespace and belongs to + // Fineract's chain, not to tenant administration. + lookalike.setRequestURI("/fineract-provider/api/v1/tenants/default/oidc-config"); + assertFalse(TenantStatusEnforcementFilter.isTenantAdministration(lookalike)); + + lookalike.setRequestURI("/fineract-provider/api/v1/offices"); + assertFalse(TenantStatusEnforcementFilter.isTenantAdministration(lookalike)); + } + + @Test + void anUnknownTenantIsLeftToThePlatformToReject() throws Exception { + // Producing our own error here would pre-empt - and disagree with - the + // platform's InvalidTenantIdentifierException. + addressedTo("never-existed"); + when(lookupService.statusOf("never-existed")).thenReturn(Lookup.noSuchTenant()); + + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + } + + @Test + void anUnverifiableTenantIsRefusedWhileTheRegistryIsDown() throws Exception { + // With no earlier status to fall back on, the request cannot be proved allowed. + addressedTo("acme"); + when(lookupService.statusOf("acme")).thenReturn(Lookup.registryUnavailable()); + + filter.doFilter(request, response, chain); + + verify(chain, never()).doFilter(any(), any()); + assertEquals(503, response.getStatus()); + assertTrue(response.getContentAsString().contains("\"tenantStatus\":\"UNAVAILABLE\"")); + } + + @Test + void aRequestWithNoTenantIsLeftAlone() throws Exception { + filter.doFilter(request, response, chain); + + verify(chain).doFilter(request, response); + verify(lookupService, never()).statusOf(any()); + } + + @Test + void theTenantIsAlsoAcceptedFromTheQueryParameterAsCoreDoes() throws Exception { + // TenantAwareBasicAuthenticationFilter falls back to this parameter, so a + // request that reaches a tenant that way must be checked the same. + request.setParameter(TenantStatusEnforcementFilter.TENANT_ID_REQUEST_PARAMETER, "acme"); + when(lookupService.statusOf("acme")).thenReturn(Lookup.known(TenantStatus.SUSPENDED)); + + filter.doFilter(request, response, chain); + + verify(chain, never()).doFilter(any(), any()); + assertEquals(503, response.getStatus()); + } + + @Test + void theHeaderWinsOverTheQueryParameter() throws Exception { + // Matching core's precedence, so the filter and the platform can never be + // looking at two different tenants for the same request. + addressedTo("acme"); + request.setParameter(TenantStatusEnforcementFilter.TENANT_ID_REQUEST_PARAMETER, "other"); + when(lookupService.statusOf("acme")).thenReturn(Lookup.known(TenantStatus.ACTIVE)); + + filter.doFilter(request, response, chain); + + verify(lookupService).statusOf("acme"); + verify(lookupService, never()).statusOf("other"); + } + + @Test + void surroundingWhitespaceInTheHeaderIsIgnored() throws Exception { + addressedTo(" acme "); + when(lookupService.statusOf("acme")).thenReturn(Lookup.known(TenantStatus.ACTIVE)); + + filter.doFilter(request, response, chain); + + verify(lookupService).statusOf("acme"); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/service/TenantSchemaMigrationServiceTest.java b/src/test/java/org/apache/fineract/tenant/service/TenantSchemaMigrationServiceTest.java new file mode 100644 index 00000000..5c14d32c --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/service/TenantSchemaMigrationServiceTest.java @@ -0,0 +1,141 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.zaxxer.hikari.HikariDataSource; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.infrastructure.core.service.migration.ExtendedSpringLiquibase; +import org.apache.fineract.infrastructure.core.service.migration.ExtendedSpringLiquibaseFactory; +import org.apache.fineract.infrastructure.core.service.migration.TenantDataSourceFactory; +import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService; +import org.apache.fineract.tenant.exception.TenantSchemaMigrationFailedException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; + +class TenantSchemaMigrationServiceTest { + + private TenantDetailsService tenantDetailsService; + private TenantDataSourceFactory tenantDataSourceFactory; + private ExtendedSpringLiquibaseFactory liquibaseFactory; + private ResourceLoader resourceLoader; + + @BeforeEach + void setUp() throws Exception { + tenantDetailsService = mock(TenantDetailsService.class); + tenantDataSourceFactory = mock(TenantDataSourceFactory.class); + liquibaseFactory = mock(ExtendedSpringLiquibaseFactory.class); + resourceLoader = mock(ResourceLoader.class); + + when(tenantDetailsService.loadTenantById("beta")).thenReturn(tenant("beta")); + when(tenantDataSourceFactory.create(any())).thenReturn(mock(HikariDataSource.class)); + when(liquibaseFactory.create(any(), any(String[].class))) + .thenReturn(mock(ExtendedSpringLiquibase.class)); + + // No plugin changelog "installed", so the test never opens a real database. + final Resource absent = mock(Resource.class); + when(absent.exists()).thenReturn(false); + when(resourceLoader.getResource(anyString())).thenReturn(absent); + } + + @AfterEach + void tearDown() { + ThreadLocalContextUtil.reset(); + } + + private static FineractPlatformTenant tenant(final String identifier) { + return FineractPlatformTenant.builder().id(1L).tenantIdentifier(identifier).build(); + } + + private TenantSchemaMigrationService serviceWithChangelogs(final String changelogs) { + return new TenantSchemaMigrationService( + tenantDetailsService, + tenantDataSourceFactory, + liquibaseFactory, + resourceLoader, + changelogs); + } + + @Test + void theCallersTenantIsRestoredAfterASuccessfulMigration() { + // The migration points the thread at the new tenant. Leaving it cleared afterwards + // made the rest of the administrator's request - including the CREATE audit row - + // run with no tenant and no acting user. + ThreadLocalContextUtil.setTenant(tenant("default")); + + serviceWithChangelogs(TenantSchemaMigrationService.DEFAULT_PLUGIN_CHANGELOGS).migrate("beta"); + + assertEquals("default", ThreadLocalContextUtil.getTenant().getTenantIdentifier()); + } + + @Test + void theCallersTenantIsRestoredAfterAFailedMigration() { + ThreadLocalContextUtil.setTenant(tenant("default")); + when(tenantDetailsService.loadTenantById("beta")) + .thenThrow(new IllegalStateException("registry down")); + + assertThrows( + TenantSchemaMigrationFailedException.class, + () -> + serviceWithChangelogs(TenantSchemaMigrationService.DEFAULT_PLUGIN_CHANGELOGS) + .migrate("beta")); + + assertEquals("default", ThreadLocalContextUtil.getTenant().getTenantIdentifier()); + } + + @Test + void coreIsMigratedInItsTwoPasses() { + serviceWithChangelogs(TenantSchemaMigrationService.DEFAULT_PLUGIN_CHANGELOGS).migrate("beta"); + + verify(liquibaseFactory, times(2)).create(any(), any(String[].class)); + } + + @Test + void byDefaultBothStartupPluginChangelogsAreConsideredInStartupOrder() { + // Self-service then savings, matching the order the startup beans run in. + serviceWithChangelogs(TenantSchemaMigrationService.DEFAULT_PLUGIN_CHANGELOGS).migrate("beta"); + + final var inOrder = org.mockito.Mockito.inOrder(resourceLoader); + inOrder + .verify(resourceLoader) + .getResource( + "classpath:/db/changelog/tenant/module/selfservice/module-changelog-master.xml"); + inOrder + .verify(resourceLoader) + .getResource("classpath:/db/changelog/tenant/module/savings/module-changelog-master.xml"); + } + + @Test + void aConfiguredListIsTrimmedAndBlankEntriesIgnored() { + serviceWithChangelogs(" classpath:/a.xml , ,classpath:/b.xml ").migrate("beta"); + + verify(resourceLoader).getResource("classpath:/a.xml"); + verify(resourceLoader).getResource("classpath:/b.xml"); + verify(resourceLoader, never()).getResource(""); + } + + @Test + void aPluginThatIsNotInstalledIsSkippedRatherThanFailingTheCreate() { + // An installation without the savings plugin must still be able to create tenants. + serviceWithChangelogs(TenantSchemaMigrationService.DEFAULT_PLUGIN_CHANGELOGS).migrate("beta"); + + verify(tenantDataSourceFactory).create(any()); + } +} diff --git a/src/test/java/org/apache/fineract/tenant/service/TenantStatusLookupServiceTest.java b/src/test/java/org/apache/fineract/tenant/service/TenantStatusLookupServiceTest.java new file mode 100644 index 00000000..529240e5 --- /dev/null +++ b/src/test/java/org/apache/fineract/tenant/service/TenantStatusLookupServiceTest.java @@ -0,0 +1,245 @@ +/** + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.fineract.tenant.domain.TenantStatus; +import org.apache.fineract.tenant.service.TenantStatusLookupService.Kind; +import org.apache.fineract.tenant.service.TenantStatusLookupService.Lookup; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; + +class TenantStatusLookupServiceTest { + + private JdbcTemplate jdbcTemplate; + private TenantStatusLookupService service; + + @BeforeEach + void setUp() { + jdbcTemplate = mock(JdbcTemplate.class); + service = new TenantStatusLookupService(jdbcTemplate, Duration.ofMinutes(5)); + } + + private void registryHolds(final String identifier, final List rows) { + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq(identifier))).thenReturn(rows); + } + + private void verifyReads(final String identifier, final int times) { + verify(jdbcTemplate, times(times)).queryForList(anyString(), eq(String.class), eq(identifier)); + } + + @Test + void aKnownStatusIsCached() { + registryHolds("acme", List.of("ACTIVE")); + + final Lookup first = service.statusOf("acme"); + service.statusOf("acme"); + + assertEquals(Kind.KNOWN, first.kind()); + assertEquals(TenantStatus.ACTIVE, first.status()); + assertFalse(first.refusesService()); + verifyReads("acme", 1); + } + + @Test + void aSuspendedTenantRefusesService() { + registryHolds("acme", List.of("SUSPENDED")); + + assertTrue(service.statusOf("acme").refusesService()); + } + + @Test + void aNonexistentTenantIsNeverCached() { + // The identifier comes from a request header. Caching "no such tenant" would let + // anyone grow the cache without bound by inventing identifiers. + registryHolds("invented", List.of()); + + final Lookup first = service.statusOf("invented"); + service.statusOf("invented"); + + assertEquals(Kind.NO_SUCH_TENANT, first.kind()); + assertFalse(first.refusesService()); + verifyReads("invented", 2); + } + + @Test + void anUnrecognisedStoredStatusFailsClosed() { + registryHolds("acme", List.of("DELETED")); + + final Lookup lookup = service.statusOf("acme"); + + assertEquals(Kind.UNRECOGNISED, lookup.kind()); + assertTrue(lookup.refusesService()); + } + + @Test + void anUnreadableRegistryWithNoEarlierStatusRefusesService() { + // Core keeps resolving tenants from its own cache while the tenant store is down, so + // letting an unverifiable request through could serve a suspended tenant. + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq("acme"))) + .thenThrow(new DataAccessResourceFailureException("tenant store down")); + + final Lookup first = service.statusOf("acme"); + service.statusOf("acme"); + + assertEquals(Kind.REGISTRY_UNAVAILABLE, first.kind()); + assertTrue(first.refusesService()); + verifyReads("acme", 2); + } + + @Test + void anUnreadableRegistryFallsBackToTheLastKnownActiveStatus() { + // An expired entry is still the last thing known; a registry blip must not take a + // tenant last seen ACTIVE offline. + final TenantStatusLookupService expiring = + new TenantStatusLookupService(jdbcTemplate, Duration.ZERO); + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq("acme"))) + .thenReturn(List.of("ACTIVE")) + .thenThrow(new DataAccessResourceFailureException("tenant store down")); + + expiring.statusOf("acme"); + final Lookup duringOutage = expiring.statusOf("acme"); + + assertEquals(TenantStatus.ACTIVE, duringOutage.status()); + assertFalse(duringOutage.refusesService()); + } + + @Test + void anUnreadableRegistryKeepsASuspendedTenantRefused() { + final TenantStatusLookupService expiring = + new TenantStatusLookupService(jdbcTemplate, Duration.ZERO); + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq("acme"))) + .thenReturn(List.of("SUSPENDED")) + .thenThrow(new DataAccessResourceFailureException("tenant store down")); + + expiring.statusOf("acme"); + final Lookup duringOutage = expiring.statusOf("acme"); + + assertEquals(TenantStatus.SUSPENDED, duringOutage.status()); + assertTrue(duringOutage.refusesService()); + } + + @Test + void anInvalidationDuringTheReadIsNotOverwrittenByTheStaleValue() { + // The race: a lookup reads ACTIVE, a suspension commits and invalidates while that + // read is in flight, and the lookup then caches its stale ACTIVE - letting the + // suspended tenant through until the entry expires. + final AtomicInteger reads = new AtomicInteger(); + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq("acme"))) + .thenAnswer( + invocation -> { + if (reads.getAndIncrement() == 0) { + service.invalidate("acme"); + return List.of("ACTIVE"); + } + return List.of("SUSPENDED"); + }); + + assertEquals(TenantStatus.ACTIVE, service.statusOf("acme").status()); + assertEquals(TenantStatus.SUSPENDED, service.statusOf("acme").status()); + verifyReads("acme", 2); + } + + @Test + void anInvalidateAllDuringTheReadIsNotOverwrittenByTheStaleValue() { + final AtomicInteger reads = new AtomicInteger(); + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq("acme"))) + .thenAnswer( + invocation -> { + if (reads.getAndIncrement() == 0) { + service.invalidateAll(); + return List.of("ACTIVE"); + } + return List.of("SUSPENDED"); + }); + + service.statusOf("acme"); + + assertEquals(TenantStatus.SUSPENDED, service.statusOf("acme").status()); + } + + @Test + void invalidatingATenantForcesAFreshRead() { + registryHolds("acme", List.of("ACTIVE")); + + service.statusOf("acme"); + service.invalidate("acme"); + service.statusOf("acme"); + + verifyReads("acme", 2); + } + + @Test + void anExpiredEntryIsReadAgain() { + final TenantStatusLookupService expiring = + new TenantStatusLookupService(jdbcTemplate, Duration.ZERO); + registryHolds("acme", List.of("ACTIVE")); + + expiring.statusOf("acme"); + expiring.statusOf("acme"); + + verifyReads("acme", 2); + } + + @Test + void aBlankIdentifierNeverReachesTheRegistry() { + assertEquals(Kind.NO_SUCH_TENANT, service.statusOf(" ").kind()); + verify(jdbcTemplate, never()).queryForList(anyString(), eq(String.class), anyString()); + } + + @Test + void anExpiredActiveStatusIsNotTrustedPastTheStaleGrace() { + // Another node may have suspended the tenant since. Past the grace, an unverifiable + // ACTIVE refuses service instead of being trusted for the whole outage. + final TenantStatusLookupService noGrace = + new TenantStatusLookupService(jdbcTemplate, Duration.ZERO, Duration.ZERO); + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq("acme"))) + .thenReturn(List.of("ACTIVE")) + .thenThrow(new DataAccessResourceFailureException("tenant store down")); + + noGrace.statusOf("acme"); + final Lookup duringLongOutage = noGrace.statusOf("acme"); + + assertEquals(Kind.REGISTRY_UNAVAILABLE, duringLongOutage.kind()); + assertTrue(duringLongOutage.refusesService()); + } + + @Test + void anExpiredSuspendedStatusStaysRefusedPastTheStaleGrace() { + final TenantStatusLookupService noGrace = + new TenantStatusLookupService(jdbcTemplate, Duration.ZERO, Duration.ZERO); + when(jdbcTemplate.queryForList(anyString(), eq(String.class), eq("acme"))) + .thenReturn(List.of("SUSPENDED")) + .thenThrow(new DataAccessResourceFailureException("tenant store down")); + + noGrace.statusOf("acme"); + final Lookup duringLongOutage = noGrace.statusOf("acme"); + + assertEquals(TenantStatus.SUSPENDED, duringLongOutage.status()); + assertTrue(duringLongOutage.refusesService()); + } + + @Test + void theDefaultStaleGraceIsFiveMinutes() { + assertEquals(Duration.ofMinutes(5), TenantStatusLookupService.DEFAULT_STALE_GRACE); + } +}