diff --git a/build.gradle b/build.gradle index b8e851e..693499e 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.74' +version = '0.0.75' description = 'Flextuma App' java { diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java index 7bb9c5b..8d9fcaf 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WhatsAppTemplateRepository.java @@ -19,10 +19,13 @@ public interface WhatsAppTemplateRepository extends BaseRepository findByNameAndLanguageAndConnectorAndCreatedBy(String name, String language, SmsConnector connector, User createdBy); - /** Used by the webhook's message_template_status_update handler, which has no tenant context - * of its own -- metaTemplateId is unique per (id, creator), so findFirst is safe here. */ - Optional findFirstByMetaTemplateId(String metaTemplateId); + /** Used by the webhook's message_template_status_update handler, scoped to the webhook + * config's owner -- metaTemplateId is only unique per (id, creator), so an unscoped lookup + * could match a different tenant's row of the same Meta template id. */ + Optional findFirstByMetaTemplateIdAndCreatedBy(String metaTemplateId, User createdBy); - /** Fallback lookup for a status update payload that omits message_template_id. */ - Optional findFirstByNameAndLanguage(String name, String language); + /** Fallback lookup for a status update payload that omits message_template_id, scoped to the + * webhook config's owner so two tenants sharing a common template name/language (e.g. + * "otp_verification") can't have their status cross-contaminated. */ + Optional findFirstByNameAndLanguageAndCreatedBy(String name, String language, User createdBy); } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java index 9c5644c..e63e0b3 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookController.java @@ -114,7 +114,7 @@ private ResponseEntity handle(Map payload, String rawPaylo if (config.isEmpty()) { log.warn("Ignoring WhatsApp webhook with no active configuration"); return ResponseEntity.ok().build(); } if (!validMetaSignature(config.get(), rawPayload, signature)) { log.warn("Rejecting WhatsApp webhook with an invalid Meta signature for config [{}]", config.get().getId()); return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } markEventReceived(config.get()); - updateDeliveryStatus(payload); ingestInboundMessages(config.get(), payload); updateTemplateStatus(payload); relay(config.get(), payload); + updateDeliveryStatus(payload); ingestInboundMessages(config.get(), payload); updateTemplateStatus(config.get(), payload); relay(config.get(), payload); log.info("Processed WhatsApp webhook for config [{}]: {} change(s)", config.get().getId(), changes(payload).size()); return ResponseEntity.ok().build(); } @@ -141,9 +141,12 @@ private void updateDeliveryStatus(Map payload) { * WhatsAppTemplate.status (by metaTemplateId, falling back to name+language) so an * approval/rejection shows up in GET /api/whatsappTemplates immediately instead of waiting * for the next manual sync. Runs before relay() so the tenant's own callbackUrl still gets - * the raw event either way. */ + * the raw event either way. Both lookups are scoped to config's owner -- metaTemplateId and + * name+language are each only unique per (value, creator), so an unscoped lookup could match + * a different tenant's template of the same id or the same common name (e.g. + * "otp_verification"), corrupting their approval status instead. */ @SuppressWarnings("unchecked") - private void updateTemplateStatus(Map payload) { + private void updateTemplateStatus(WhatsAppWebhookConfig config, Map payload) { for (Map change : changes(payload)) { if (!"message_template_status_update".equals(change.get("field"))) continue; Map value = nestedMap(change, "value"); @@ -152,13 +155,14 @@ private void updateTemplateStatus(Map payload) { Object templateId = value.get("message_template_id"); Optional template = templateId != null - ? templateRepository.findFirstByMetaTemplateId(templateId.toString()) + ? templateRepository.findFirstByMetaTemplateIdAndCreatedBy(templateId.toString(), config.getCreatedBy()) : Optional.empty(); if (template.isEmpty()) { Object name = value.get("message_template_name"); Object language = value.get("message_template_language"); if (name != null && language != null) { - template = templateRepository.findFirstByNameAndLanguage(name.toString(), language.toString()); + template = templateRepository.findFirstByNameAndLanguageAndCreatedBy(name.toString(), + language.toString(), config.getCreatedBy()); } } template.ifPresent(t -> { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateService.java b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateService.java index 1cce8c0..c0b2153 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/whatsapp/services/WhatsAppTemplateService.java @@ -41,6 +41,17 @@ protected JpaSpecificationExecutor getRepositoryAsExecutor() { return repository; } + /** Closes BaseService#checkPermission's generic "ALL" bypass for ADD/UPDATE/DELETE -- without + * this override (the same one User/Role/Organisation/Privilege/Wallet/TenantFeature already + * use) any tenant user holding the common "ALL" authority could write template rows, e.g. + * self-declare status: APPROVED for a template Meta never approved. READ is unaffected: its + * permission constant is the literal "ALL" sentinel, satisfied by checkPermission's second + * clause regardless of this override. */ + @Override + protected boolean isAdminEntity() { + return true; + } + @Override protected String getReadPermission() { return WhatsAppTemplate.READ; diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java index 2e492da..1055dca 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/whatsapp/controllers/WhatsAppWebhookControllerTest.java @@ -159,7 +159,8 @@ void receiveGeneratedCallback_shouldUpdateTemplateStatusAndStillRelay_whenEventI new com.flexcodelabs.flextuma.core.entities.whatsapp.WhatsAppTemplate(); template.setMetaTemplateId("META_TEMPLATE_ID"); template.setStatus("PENDING"); - when(templateRepository.findFirstByMetaTemplateId("META_TEMPLATE_ID")).thenReturn(Optional.of(template)); + when(templateRepository.findFirstByMetaTemplateIdAndCreatedBy("META_TEMPLATE_ID", config.getCreatedBy())) + .thenReturn(Optional.of(template)); String payload = "{\"entry\":[{\"changes\":[{\"field\":\"message_template_status_update\",\"value\":{" + "\"event\":\"APPROVED\",\"message_template_id\":\"META_TEMPLATE_ID\"," @@ -188,7 +189,8 @@ void receiveGeneratedCallback_shouldFallBackToNameAndLanguage_whenTemplateIdMiss template.setName("farm_alert"); template.setLanguage("en"); template.setStatus("PENDING"); - when(templateRepository.findFirstByNameAndLanguage("farm_alert", "en")).thenReturn(Optional.of(template)); + when(templateRepository.findFirstByNameAndLanguageAndCreatedBy("farm_alert", "en", config.getCreatedBy())) + .thenReturn(Optional.of(template)); String payload = "{\"entry\":[{\"changes\":[{\"field\":\"message_template_status_update\",\"value\":{" + "\"event\":\"REJECTED\",\"message_template_name\":\"farm_alert\",\"message_template_language\":\"en\"}}]}]}"; @@ -199,6 +201,28 @@ void receiveGeneratedCallback_shouldFallBackToNameAndLanguage_whenTemplateIdMiss verify(templateRepository).save(template); } + @Test + void receiveGeneratedCallback_shouldNotUpdateAnotherTenantsTemplate_whenNameAndLanguageCollide() { + // Two tenants can each sync a template with the same common name/language (e.g. + // "otp_verification"/"en"). A status update for tenant A's webhook config must not be + // able to touch tenant B's row of the same name+language -- the lookup is scoped to + // config.getCreatedBy(), so stubbing only the caller's own owner leaves tenant B + // untouched without needing a second mock to prove it. + WhatsAppWebhookConfig config = activeConfig(); + when(configRepository.findByCallbackTokenAndActiveTrue("callback-token")).thenReturn(Optional.of(config)); + when(templateRepository.findFirstByNameAndLanguageAndCreatedBy(any(), any(), any())).thenReturn(Optional.empty()); + + // No message_template_id in this payload, matching real Meta events that omit it -- + // exercises the name+language fallback path this test is actually about. + String payload = "{\"entry\":[{\"changes\":[{\"field\":\"message_template_status_update\",\"value\":{" + + "\"event\":\"APPROVED\",\"message_template_name\":\"otp_verification\",\"message_template_language\":\"en\"}}]}]}"; + + controller().receiveGeneratedCallback("callback-token", payload, null); + + verify(templateRepository).findFirstByNameAndLanguageAndCreatedBy("otp_verification", "en", config.getCreatedBy()); + verify(templateRepository, never()).save(any()); + } + @Test void receiveGeneratedCallback_shouldAccept_whenPhoneNumberIdDiffersFromConfig() { WhatsAppWebhookConfig config = activeConfig();