Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.36'
version = '0.0.37'
description = 'Flextuma App'

java {
Expand Down
73 changes: 73 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Flextuma deployment guide

This guide describes a production deployment of the Flextuma Spring Boot service. Flextuma is stateful only through PostgreSQL and Redis: the application containers can be replicated once those services are shared and durable.

## Production topology

Place a TLS-terminating reverse proxy or load balancer in front of one or more application containers. PostgreSQL must have backups and a tested restore procedure. Redis is required for HTTP sessions and rate-limit state; run it with persistence and high availability appropriate to the availability target.

```
Internet -> TLS proxy / WAF -> Flextuma app replicas -> PostgreSQL
| -> Redis
-> SMS providers / tenant APIs
```

The proxy must pass `Host`, `X-Forwarded-For`, and `X-Forwarded-Proto`. Make the app reachable only from the proxy, and make PostgreSQL/Redis private to the application network. Permit egress only to approved SMS providers and customer APIs.

## Required configuration

Provide secrets through the platform secret manager, never in the image, repository, or `compose.yaml`. These values are consumed by the current application configuration.

| Setting | Required | Production guidance |
| --- | --- | --- |
| `SPRING_DATASOURCE_URL` | Yes | PostgreSQL JDBC URL, with TLS when supported by the provider. |
| `SPRING_DATASOURCE_USERNAME` / `SPRING_DATASOURCE_PASSWORD` | Yes | Dedicated least-privilege database account. |
| `SPRING_DATA_REDIS_HOST` / `SPRING_DATA_REDIS_PORT` | Yes | Shared Redis service; protect with network controls and authentication/TLS where available. |
| `SPRING_JPA_HIBERNATE_DDL_AUTO` | Yes | Set to `validate`; do not use the repository default `update` in production. |
| `HIKARI_MAX_POOL`, `HIKARI_MIN_IDLE` | Recommended | Size across all replicas below PostgreSQL’s connection limit. |
| `SESSION_TIMEOUT` | Recommended | Session lifetime, e.g. `30m`. |
| `SMS_PRICE_PER_SEGMENT` | Yes | Decimal cost used for wallet accounting; confirm the business unit and currency. |
| `FLEXTUMA_WEBHOOKS_SMS_SHARED_SECRET` | Yes, for DLRs | Shared callback secret supplied by providers in `X-Flextuma-Webhook-Secret`; use a high-entropy secret from the secret manager. |
| `APP_FRONTEND_DIRECTORY` | If serving UI | Read-only directory containing `index.html` and assets. |
| `APP_UPLOAD_DIRECTORY` | If app uploads are enabled | Durable, access-controlled storage; `/tmp` loses uploads on restart. |
| `LOG_MIN_LEVEL`, `LOG_RETENTION_DAYS` | Recommended | Tune for operating requirements; database log retention has storage impact. |
| `flextuma.auth.*`, `flextuma.verification.*` | Recommended | Explicitly configure authentication attempt, verification, and expiry policy. |

For secure session cookies behind HTTPS, explicitly configure the session cookie domain/path and secure attributes at the deployment layer and test cross-site login behavior. The `AuthCookieProperties` record exists, but the active cookie serializer currently hard-codes an HttpOnly, `SameSite=Lax` cookie and does not apply all of those properties.

## Build and run

Build the immutable production image using the `prod` target:

```bash
docker build --target prod -t registry.example.com/flextuma:VERSION .
docker push registry.example.com/flextuma:VERSION
```

Deploy that image with the required environment above. The committed `compose.yaml` is explicitly a development setup: it selects the `dev` target, enables DevTools/restart behavior, bind-mounts source/classes/client files, uses Hibernate `update`, and depends on an external `local-docker-network`. Do not promote it unchanged.

## Release procedure

1. Run the Gradle test suite and build the WAR/image in CI.
2. Scan the image and dependency tree; record the image digest.
3. Take/verify a PostgreSQL backup and apply versioned database migrations before rollout. The repository currently has no migration tool configured; introduce Flyway or Liquibase before the first managed production release.
4. Deploy one canary replica, validate login, a PAT-authenticated API call, Redis session continuity, a test SMS, and provider callback receipt.
5. Roll out remaining replicas only after the canary passes. Monitor 5xx responses, database connections, Redis availability, pending/failed SMS logs, and callback mismatch warnings.
6. Keep the preceding image digest available for rollback. A rollback must be compatible with the database schema; this is why versioned, backward-compatible migrations are essential.

## Health, backups, and operations

No Actuator health endpoint is currently included. Until one is added, use a platform TCP check plus a protected, lightweight authenticated API check; do not treat `GET /` as an application dependency check. Add Spring Boot Actuator and separate liveness/readiness probes before relying on automated replacement or horizontal scaling.

Back up PostgreSQL (including point-in-time recovery if available), test restores regularly, and monitor storage growth for `smslog`, wallet transactions, and database-backed system logs. Redis loss invalidates active sessions and rate-limit buckets, so document that operational effect and choose persistence accordingly.

Rotate database, Redis, provider, tenant-API, and PAT credentials. Provider connector secrets are stored in the application database; database encryption/backups and access controls therefore fall within the secret-management boundary.

## Pre-launch checklist

- HTTPS, HSTS, proxy headers, WAF/rate limits, and restricted network paths are in place.
- Production configuration does not enable DevTools or Hibernate schema update.
- Database migrations, backup/restore, monitoring, alerting, and on-call ownership are verified.
- Frontend/upload storage is durable where required and has a retention policy.
- SMS sender IDs, provider account limits, wallet price, and DLR callbacks have been tested in the production-like environment.
- The high-priority items in [integration and gap analysis](third-party-integration.md#implementation-gaps-and-recommendations) are resolved or formally accepted.
97 changes: 97 additions & 0 deletions docs/third-party-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Flextuma third-party integration guide and gap analysis

## Integration surfaces

Flextuma currently integrates with:

| Party | Direction | Mechanism |
| --- | --- | --- |
| BEEM SMS | Outbound SMS; intended inbound delivery reports | JSON over HTTPS using provider credentials. |
| NextSMS | Outbound SMS; intended inbound delivery reports | JSON over HTTPS using Basic authentication. |
| Tenant/customer data API | Outbound recipient lookup and member hydration | Configurable GET requests plus JSONPath field mapping. |
| Client/automation | Inbound API requests | Session cookie or personal access token (PAT) in `X-API-KEY`. |

There is no OpenAPI document or API version prefix. Treat the current API as an internal integration contract and pin integrations to an application release until a versioning policy is introduced.

## API authentication for clients

Create a Personal Access Token through the authenticated CRUD endpoint `POST /api/tokens`. A newly generated raw token is exposed through the entity’s transient `rawToken` field, so capture it at creation time; only its SHA-256 hash is persisted. Send it on every automation request:

```http
X-API-KEY: ft_<token-value>
```

PATs act as the owning user and inherit that user’s privileges. Use one token per integration, give it an expiry, rotate it, and disable/delete it when no longer needed. Do not put PATs in browser code, query strings, logs, or support tickets.

All API paths other than login, registration, and frontend assets require authentication under the active security configuration. In particular, there is no separate, functioning API-key scheme for anonymous webhooks despite the `flextuma.auth.api-key-endpoints` configuration property.

## SMS provider setup

Create an SMS connector using `POST /api/connectors` with the provider string, provider endpoint, credentials, and sender ID. The provider names implemented by the service are `BEEM` and `NEXT` (case-insensitive at send time). Connector key and secret fields are write-only/masked in API responses; retain the original values in your secret manager.

### BEEM

The BEEM adapter sends a JSON request with `source_addr`, `message`, `schedule_time`, `encoding`, and a one-item `recipients` list. It supplies credentials both as `api_key`/`secret_key` headers and HTTP Basic authentication. Configure the provider endpoint and sender ID supplied by BEEM. Confirm the expected response contains an actual provider message ID before enabling delivery reports.

### NextSMS

The NextSMS adapter sends `{ "from", "to", "text" }` as JSON and uses HTTP Basic authentication (`key:secret`). It records `messages[0].messageId` when provided. Configure the exact NextSMS endpoint and approved sender ID from the provider account.

### Delivery reports (DLRs)

The implemented route is:

```http
POST https://<public-host>/api/webhooks/BEEM
POST https://<public-host>/api/webhooks/NEXT
Content-Type: application/json
```

BEEM parser expects `messageID` and `status`; Next parser expects `message_id` (or `messageId`) and `status`. The documented source comment incorrectly describes `/api/webhooks/sms/{provider}/dlr`; that route does not exist.

DLR routes are public only for the one-segment callback path and require the `X-Flextuma-Webhook-Secret` header. Set `FLEXTUMA_WEBHOOKS_SMS_SHARED_SECRET` from the deployment secret manager and configure the same high-entropy value at the provider. Requests with a missing/incorrect secret are rejected. The callback now correlates using `providerMessageId`; verify the provider’s response/callback message-ID shape in staging before enabling it.

## Tenant/customer data API

An administrator can create a `ConnectorConfig` via `/api/connectorConfigs`. It supports `NONE`, `BASIC`, `BEARER`, and `API_KEY` authentication and maps response fields with JSONPath expressions.

- `url` is the remote service base URL.
- `endpoint` is used for a member lookup, replacing `{id}` with the member ID.
- `search` is used for recipient lookup; Flextuma appends caller-supplied query parameters.
- `mappings` maps JSONPath source values to Flextuma keys such as `phoneNumber`, `name`, and template variables.

The search response must be a top-level JSON array. Example mapping:

```json
[
{"systemKey":"phoneNumber", "jsonPath":"$.phone"},
{"systemKey":"firstName", "jsonPath":"$.profile.first_name"}
]
```

The trigger endpoint is `POST /api/webhooks/{connector-config-uuid}/sms`; it fetches recipients from the configured tenant API and queues SMS. It requires normal Flextuma authentication. Send either a template code or a raw message body, together with the provider and optional search filter, according to the controller’s `DispatchRequest` contract.

## Implementation gaps and recommendations

These are code-observed findings as of this repository revision, ordered by impact.

| Priority | Finding | Impact and recommended action |
| --- | --- | --- |
| Resolved | DLR endpoint authentication and lookup were incompatible with provider callbacks. | The callback path is now narrowly public, requires a constant-time compared shared secret, and correlates with `providerMessageId`. Upgrade next to provider-specific HMAC signatures, timestamp/replay controls, and IP allowlists where supported. |
| Resolved | BEEM did not retain its response message ID. | The adapter now reads `message_id` and saves it as `providerMessageId`; validate the exact live provider schema during staging. |
| Resolved | Generic single-record read, update, and delete skipped the tenant specification. | These operations now use the tenant-scoped specification. Maintain cross-tenant authorization tests as new endpoints are added. |
| Resolved | Raw dispatch used `content` while the queue required `message`. | The trigger now maps its request content to the required queue field. |
| Resolved | PAT authentication ignored a token’s `active` flag. | Inactive tokens are now rejected. |
| Resolved | Tenant API-key authentication sent the wrong stored value. | `API_KEY` connector authentication now sends `apiKey` in `X-API-KEY`. |
| High | API-driven recipient hydration accepts arbitrary stored URLs and caller-controlled query filters without egress controls, timeouts, size limits, or pagination. | Creates SSRF, resource exhaustion, and unintended data-exposure risk. Enforce HTTPS/host allowlists, block private/link-local ranges, set connect/read timeouts and response limits, validate filters, paginate, and audit access. |
| Partially resolved | SMS and campaign workers now use atomic conditional status updates to claim work. | This prevents concurrent replicas from claiming the same PENDING/SCHEDULED row. Add provider idempotency keys and a lease/recovery policy for rows left `PROCESSING` after process failure. |
| High | Campaign dispatch catches errors but can leave campaigns in `PROCESSING`; it also completes after per-recipient debit failures without an explicit partial-failure result. | Operators cannot reliably recover or reconcile campaigns. Model failed/partial states, persist per-recipient outcomes, and alert on stuck campaigns. |
| Medium | Production deployment defaults are unsafe: development Compose, Hibernate `update`, DevTools, mutable bind mounts, `/tmp` uploads, and no health endpoint/migration framework. | Releases are not reproducible or safely observable. Follow [the deployment guide](deployment.md) and add Actuator plus Flyway/Liquibase. |
| Medium | Global CSRF is disabled while cookie sessions are used. | Browser-authenticated write endpoints are exposed to CSRF risk. Enable CSRF protection for session flows, or separate browser/session and token API security models. |
| Medium | Security/operability controls are incomplete: no request timeout for legacy `RestTemplate`, no circuit breaker, no outbound provider rate/concurrency control, no OpenAPI contract, and limited metrics. | Failures are harder to contain, diagnose, and integrate against. Add timeouts, retries with jitter, circuit breaking, metrics/alerts, and a versioned OpenAPI specification. |
| Medium | Sensitive values are stored in database connector records and masked only at JSON serialization. | Database readers/backups may expose third-party credentials. Encrypt at rest with managed keys or reference a secret manager; define rotation and audit procedures. |
| Low | `POST /api/webhooks/{id}/sms` is named as a webhook but is an authenticated dispatch command. | The naming invites unsafe exposure/misconfiguration. Move it under an authenticated integrations/dispatch namespace and document its authorization scope. |

## Minimum acceptance tests

Before enabling any external party in production, automate these tests: valid and invalid PAT authentication; provider credential rejection; one successful send and one provider failure; idempotent retry behavior; a signed DLR that changes the correct SMS log; malformed/duplicate/out-of-order DLRs; tenant API timeout/5xx/oversize response; recipient pagination; wallet debit/refund reconciliation; and authorization isolation between organisations.
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,14 @@ private void applyFetch(Root<T> root, String path) {

for (String part : parts) {
Attribute<?, ?> attribute = getAttribute(currentType, part);
if (attribute != null && attribute.isAssociation()) {
current = SafeFetch.fetch(current, part);
currentType = getTargetType(attribute);
} else {
currentType = null;
}

if (currentType == null) {
// Skip if attribute doesn't exist, is not an association, or is a collection
// (collections are skipped to avoid in-memory pagination issues)
// Also break if we can't continue processing further after fetch
if (attribute == null || !attribute.isAssociation() ||
attribute instanceof jakarta.persistence.metamodel.PluralAttribute ||
(current = SafeFetch.fetch(current, part)) == null ||
(currentType = getTargetType(attribute)) == null) {
break;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
/**
* A JPA Specification that mirrors the Node.js getWhere pattern:
*
* - SUPER_ADMIN / ALL authority → no restriction (sees everything)
* - SUPER_ADMIN authority → no restriction (sees everything)
* - User with an organisation → sees resources created by anyone in the same
* org OR by themselves
* - User without an organisation → sees only resources they created
Expand All @@ -30,7 +30,7 @@ public class TenantAwareSpecification<T extends BaseEntity> implements Specifica

private static final String CREATED_BY = "createdBy";
private static final String ORGANISATION = "organisation";
private static final Set<String> BYPASS_AUTHORITIES = Set.of("ALL", "SUPER_ADMIN");
private static final Set<String> BYPASS_AUTHORITIES = Set.of("SUPER_ADMIN");

private final transient User currentUser;
private final transient Set<String> userAuthorities;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

Expand All @@ -23,6 +24,12 @@ List<SmsCampaign> findDueCampaigns(
@Param("now") LocalDateTime now,
Pageable pageable);

@Modifying
@Query("UPDATE SmsCampaign c SET c.status = :processing WHERE c.id = :id AND c.status = :scheduled")
int claimScheduledCampaign(@Param("id") UUID id,
@Param("scheduled") SmsCampaignStatus scheduled,
@Param("processing") SmsCampaignStatus processing);

long countByCreatedByAndStatusIn(User user, Collection<SmsCampaignStatus> statuses);

long countByStatusIn(Collection<SmsCampaignStatus> statuses);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import org.springframework.data.jpa.repository.Modifying;

import com.flexcodelabs.flextuma.core.entities.auth.User;
import com.flexcodelabs.flextuma.core.entities.sms.SmsLog;
Expand All @@ -27,7 +28,13 @@ List<SmsLog> findDueMessages(
@org.springframework.data.repository.query.Param("now") java.time.LocalDateTime now,
org.springframework.data.domain.Pageable pageable);

Optional<SmsLog> findByProviderResponse(String providerResponse);
Optional<SmsLog> findByProviderMessageId(String providerMessageId);

@Modifying
@org.springframework.data.jpa.repository.Query("UPDATE SmsLog s SET s.status = :processing WHERE s.id = :id AND s.status = :pending")
int claimPendingMessage(@org.springframework.data.repository.query.Param("id") UUID id,
@org.springframework.data.repository.query.Param("pending") SmsLogStatus pending,
@org.springframework.data.repository.query.Param("processing") SmsLogStatus processing);

Page<SmsLog> findByCreatedByOrderByCreatedDesc(User user, Pageable pageable);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
if (patOpt.isPresent()) {
PersonalAccessToken pat = patOpt.get();

if (pat.getExpiresAt() == null || pat.getExpiresAt().isAfter(LocalDateTime.now())) {
if (Boolean.TRUE.equals(pat.getActive())
&& (pat.getExpiresAt() == null || pat.getExpiresAt().isAfter(LocalDateTime.now()))) {
User user = pat.getUser();

Set<SimpleGrantedAuthority> authorities = user.getRoles().stream()
Expand Down
Loading