Saltar al contenido principal
Versión: v0.1.0

core-config

Qué problema resuelve

Hay valores que un cliente necesita cambiar sin esperar un redeploy: el nombre que se muestra de su organización, un texto de bienvenida, un color de marca. Meterlos en application.yml obliga a redesplegar; traer un CMS externo tipo Strapi es traer otro servicio, otra base y otro punto de falla sólo para guardar strings.

core-config resuelve eso con una tabla, una cache en memoria y un CRUD protegido: cada clave tiene un valor por defecto de plataforma y, opcionalmente, un override por organización. Las lecturas pasan por una cache Caffeine de TTL corto, y las escrituras invalidan la entrada que tocaron, así que el cambio se ve casi inmediato.

La regla para no confundirlo con i18n: el copy que cambia junto con el código (labels, mensajes de error) va en archivos versionados en git; sólo lo que un cliente necesita ajustar en runtime va acá.

Cómo se agrega

build.gradle.kts
dependencies {
implementation("dev.echotechs.core:core-config:0.1.0-SNAPSHOT")
}

Arrastra core-auth, core-authz, core-persistence y Caffeine.

db/changelog/app/changelog-master.xml
<include file="db/changelog/core-config/changelog-master.xml"/>

Crea la tabla core.config_entry.

Configuración

application.yml
echotechs:
config:
cache-ttl: 3m
PropiedadDefaultNotas
echotechs.config.cache-ttl3mTTL de la cache Caffeine (expireAfterWrite)

La autoconfiguración se activa sin condiciones — no hay servicio externo que configurar. Pero el ConfigController usa @RequiresPermission, así que para que las escrituras funcionen necesitás core-authz configurado (echotechs.authz.openfga.api-url).

API pública

ConfigService

public class ConfigService {

/** Override de la organización si existe; si no, el default de plataforma. Pasa por cache. */
public Optional<String> getValue(String configKey, UUID organizationId);

public List<ConfigEntry> listPlatformDefaults();

public List<ConfigEntry> listOverridesForOrganization(UUID organizationId);

/** Find-or-create: actualiza la fila existente para ese (key, org), o crea una. Invalida la cache. */
public ConfigEntry setValue(String configKey, UUID organizationId, String value);

/** Elimina el override de una organización, volviendo al default de plataforma. */
public void clearOverride(String configKey, UUID organizationId);
}

organizationId == null significa el default de toda la plataforma. Pasarle null a setValue escribe ese default.

La resolución en getValue es: si hay organizationId, buscar override; si existe, devolverlo; si no, caer al default. La clave de cache es configKey + ":" + (organizationId == null ? "default" : organizationId).

:::caution La cache no se invalida entre instancias Caffeine es una cache en memoria, por JVM. Una escritura invalida la entrada sólo en la instancia que la recibió. Con varias réplicas, las demás siguen sirviendo el valor viejo hasta que expire el TTL (3 min por defecto). Es un trade-off asumido del diseño; si necesitás propagación inmediata, hace falta invalidación distribuida, que este módulo no trae. :::

ConfigEntry

@Entity
@Table(name = "config_entry", schema = "core")
public class ConfigEntry extends CoreEntity {
// configKey, organizationId (nullable), value, createdAt, updatedAt

public void setValue(String value); // también actualiza updatedAt
public String getConfigKey();
public UUID getOrganizationId();
public String getValue();
public Instant getCreatedAt();
public Instant getUpdatedAt();
}
public interface ConfigEntryRepository extends JpaRepository<ConfigEntry, UUID> {
Optional<ConfigEntry> findByConfigKeyAndOrganizationId(String configKey, UUID organizationId);
Optional<ConfigEntry> findByConfigKeyAndOrganizationIdIsNull(String configKey);
List<ConfigEntry> findAllByOrganizationIdIsNull();
List<ConfigEntry> findAllByOrganizationId(UUID organizationId);
}

Endpoints

ConfigController, montado en /api/organizations/{organizationId}/config:

MétodoRutaPermisoQué hace
GET/api/organizations/{organizationId}/configautenticadoCatálogo efectivo (defaults + overrides mezclados)
PUT/api/organizations/{organizationId}/config/{key}admin sobre la organizaciónEscribe el override
DELETE/api/organizations/{organizationId}/config/{key}admin sobre la organizaciónBorra el override
public record ConfigEntryResponse(String key, String value, boolean organizationSpecific) {}
public record UpdateConfigValueRequest(@NotBlank String value) {}

El GET mezcla: primero carga todos los defaults de plataforma, después los pisa con los overrides de esa organización, marcando organizationSpecific = true en los pisados.

La protección usa la relación admin sobre organization que el modelo base de core-authz ya define — no hace falta esquema FGA nuevo:

@PutMapping("/{key}")
@RequiresPermission(relation = "admin", objectType = "organization", objectId = "#organizationId")
public ConfigEntryResponse set(@PathVariable UUID organizationId, @PathVariable String key,
@Valid @RequestBody UpdateConfigValueRequest request) { ... }

:::note La lectura no está protegida por organización El GET sólo requiere estar autenticado — no verifica que seas miembro de esa organización. El razonamiento del código es que es configuración de branding/display, no datos sensibles. Si en tu proyecto no lo es, envolvé la lectura en tu propio endpoint con @RequiresPermission(relation = "member", ...). :::

Ejemplo de uso

Leer un valor en el código de dominio

@Service
public class BrandingService {

private final ConfigService configService;

public String appName() {
return configService.getValue("branding.app_name", TenantContext.getCurrentOrg())
.orElse("EchoTechs");
}
}

Sembrar los defaults de plataforma

Típicamente en un ApplicationRunner o una migración de datos:

@Component
public class ConfigDefaultsSeeder implements ApplicationRunner {

private final ConfigService configService;

@Override
public void run(ApplicationArguments args) {
configService.setValue("branding.app_name", null, "EchoTechs");
configService.setValue("branding.support_email", null, "soporte@echotechs.net");
configService.setValue("branding.theme_color", null, "#0B5FFF");
}
}

setValue es find-or-create, así que correrlo en cada arranque es idempotente — actualiza la fila existente en vez de duplicarla.

El comportamiento de override, verificado

De ConfigModuleIntegrationTest:

@Test
void organizationOverrideTakesPrecedenceOverPlatformDefault() {
UUID orgId = UUID.randomUUID();
configService.setValue("branding.app_name", null, "EchoTechs");
configService.setValue("branding.app_name", orgId, "Acme Corp");

assertThat(configService.getValue("branding.app_name", orgId)).contains("Acme Corp");
assertThat(configService.getValue("branding.app_name", UUID.randomUUID())).contains("EchoTechs");
}

@Test
void clearingAnOverrideRevertsToThePlatformDefault() {
UUID orgId = UUID.randomUUID();
configService.setValue("branding.welcome_text", null, "Welcome!");
configService.setValue("branding.welcome_text", orgId, "Bienvenido!");
assertThat(configService.getValue("branding.welcome_text", orgId)).contains("Bienvenido!");

configService.clearOverride("branding.welcome_text", orgId);

assertThat(configService.getValue("branding.welcome_text", orgId)).contains("Welcome!");
}

@Test
void aSecondWriteToTheSameKeyAndOrgUpdatesTheExistingRowInsteadOfCreatingASecondOne() {
UUID orgId = UUID.randomUUID();
configService.setValue("branding.support_email", orgId, "old@example.com");
configService.setValue("branding.support_email", orgId, "new@example.com");

assertThat(configService.getValue("branding.support_email", orgId)).contains("new@example.com");
assertThat(configService.listOverridesForOrganization(orgId)).hasSize(1);
}

La protección de escritura, verificada contra OpenFGA real

@Test
void anOrganizationAdminCanWriteConfigThroughTheController() throws Exception {
UUID orgId = UUID.randomUUID();
UUID adminUserId = UUID.randomUUID();
tupleWriter.grant(FgaId.of("user", adminUserId), "admin", FgaId.of("organization", orgId));

ConfigEntryResponse response = withPrincipal(new UserPrincipal(adminUserId, orgId, UUID.randomUUID()), () ->
configController.set(orgId, "branding.theme_color", new UpdateConfigValueRequest("#123456")));

assertThat(response.value()).isEqualTo("#123456");
assertThat(response.organizationSpecific()).isTrue();
}

@Test
void aNonAdminMemberIsDeniedWritingConfigThroughTheController() {
UUID orgId = UUID.randomUUID();
UUID memberUserId = UUID.randomUUID();
tupleWriter.grant(FgaId.of("user", memberUserId), "member", FgaId.of("organization", orgId));

assertThatThrownBy(() -> withPrincipal(new UserPrincipal(memberUserId, orgId, UUID.randomUUID()), () ->
configController.set(orgId, "branding.theme_color", new UpdateConfigValueRequest("#654321"))))
.hasCauseInstanceOf(AccessDeniedException.class);
}

Un member que no es admin no puede escribir. Este test es además la primera confirmación en el repo de que el proxying AOP automático de Spring Boot intercepta @RequiresPermission en un bean normal, sin configuración extra.

Errores comunes

El valor cambió pero la aplicación sigue mostrando el viejo. Cache de TTL corto por instancia. setValue invalida sólo en la JVM que atendió la escritura; las otras réplicas tardan hasta cache-ttl (3 min) en verlo.

Aparecieron dos filas para la misma clave y organización. No hay constraint de unicidad en la base — ConfigService.setValue lo garantiza a nivel aplicación con find-or-create. Escribir directo con SQL, o dos escrituras concurrentes sobre una clave nueva, pueden duplicar. Escribí siempre a través de ConfigService.

getValue devuelve Optional.empty(). No existe ni override ni default de plataforma para esa clave. Sembrá los defaults al arrancar y usá siempre un .orElse(...) en el llamador.

403 al escribir siendo miembro de la organización. La escritura exige la relación admin, no member. Verificá la tupla en OpenFGA.

500 en vez de 403 al escribir. Si core-authz no está configurado, @RequiresPermission no puede resolver — revisá que echotechs.authz.openfga.api-url esté seteado.

La cache guarda el Optional.empty(). getValue cachea también los negativos (Cache<String, Optional<String>>). Si escribís una clave por fuera de ConfigService, el empty() cacheado persiste hasta el TTL.

Notas de implementación

Diferencias entre platform-core-spec.md y lo que hace el código:

Nombres en inglés. El spec 1.9 describe la tabla como configuracion(clave, organizacion_id nullable, valor). La tabla real es config_entry(config_key, organization_id, config_value), siguiendo la convención del repo de que todo identificador programático va en inglés.

No hay constraint de unicidad en (config_key, organization_id). El spec implica una clave única. El changeset crea un índice no único, y el comentario explica el motivo: Postgres y Oracle discrepan sobre si dos valores NULL colisionan en un índice único, y el workaround portable (índices funcionales o parciales) divergiría por vendor, justo lo que las reglas de core-persistence buscan evitar. La unicidad la garantiza ConfigService a nivel de aplicación.

No hay pantalla de edición. El spec 1.9 menciona "pantalla de edición simple (CRUD con ui-kit: tabla + modal, ya construidos)". El backend expone los endpoints, pero @echotechs/ui-kit no existe — ver Paquetes frontend.

El GET del catálogo no verifica pertenencia a la organización. El spec dice que la pantalla está "protegida con @RequiresPermission para que solo un admin de esa organización la edite", lo cual el código cumple para las escrituras. Pero la lectura sólo requiere autenticación: cualquier principal autenticado puede leer el catálogo de cualquier organización pasando su id en la ruta.