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
dependencies {
implementation("dev.echotechs.core:core-config:0.1.0-SNAPSHOT")
}
Arrastra core-auth, core-authz, core-persistence y Caffeine.
<include file="db/changelog/core-config/changelog-master.xml"/>
Crea la tabla core.config_entry.
Configuración
echotechs:
config:
cache-ttl: 3m
| Propiedad | Default | Notas |
|---|---|---|
echotechs.config.cache-ttl | 3m | TTL 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étodo | Ruta | Permiso | Qué hace |
|---|---|---|---|
| GET | /api/organizations/{organizationId}/config | autenticado | Catálogo efectivo (defaults + overrides mezclados) |
| PUT | /api/organizations/{organizationId}/config/{key} | admin sobre la organización | Escribe el override |
| DELETE | /api/organizations/{organizationId}/config/{key} | admin sobre la organización | Borra 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.