Quickstart
:::danger create-echotechs-app todavía no existe
El scaffolder de la sección 3.5 del spec no está implementado. No hay ningún CLI, repo-template ni
paquete npm en este repositorio ni en su historial de git.
La Parte 1 de esta página describe el flujo tal como lo diseña platform-core-spec.md, para que quede
registrado el objetivo. No lo intentes correr — los comandos no van a funcionar.
La Parte 2 es el camino equivalente que sí funciona hoy, verificado contra el código y contra
reference-app. Si querés arrancar un proyecto ahora, andá directo ahí.
:::
Parte 1 — El flujo diseñado (sin implementar)
Lo que la sección 3.5 del spec propone, y el criterio de éxito de la sección 6: "un desarrollador debería
poder arrancar un proyecto nuevo, agregar las dependencias de platform-core y @echotechs/*, y en el
primer día ya tener: login funcionando, un recurso de ejemplo con multi-tenancy automático, subida de
archivos, y una tabla en el frontend — sin escribir una sola línea de código de infraestructura."
# ⚠️ DISEÑO — este comando no existe todavía
npx create-echotechs-app mi-proyecto
El scaffolder debería generar un proyecto con los cuatro paquetes @echotechs/* integrados, routing base y
estructura de carpetas estándar, de forma que:
- Crear proyecto — un comando genera backend + frontend cableados entre sí.
- Primera corrida —
docker compose uplevanta el servicio, Postgres, OpenFGA y MinIO. - Login funcionando — el frontend ya trae las pantallas de login usando
@echotechs/auth-web, contra los endpoints decore-auth. - Primer recurso con multi-tenancy — una entidad de ejemplo con su tipo en el modelo de OpenFGA, sus
tuplas escritas al crear, y una tabla en el frontend con
@echotechs/ui-kit.
Nada de eso existe hoy. El estado real de cada paquete está en Paquetes frontend.
Parte 2 — Cómo arrancar hoy
Este camino usa sólo lo que está publicado y verificado. Al final vas a tener: una API que arranca, login funcionando con cookies, y un recurso con multi-tenancy real vía OpenFGA.
Paso 1 — Crear el proyecto y agregar las dependencias
Arrancá desde un Spring Boot 4.1 vacío (Java 25). En el build.gradle.kts:
plugins {
java
id("org.springframework.boot") version "4.1.0"
id("io.spring.dependency-management") version "1.1.7"
}
group = "com.tuproyecto"
version = "0.1.0-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
repositories {
mavenCentral()
maven {
url = uri("https://nexus.lab.echotechs.net/repository/maven-releases")
credentials {
username = System.getenv("NEXUS_USERNAME")
password = System.getenv("NEXUS_PASSWORD")
}
}
}
dependencies {
// core-auth arrastra core-web y core-persistence; core-authz arrastra core-auth.
implementation("dev.echotechs.core:core-authz:0.1.0-SNAPSHOT")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.withType<Test> {
useJUnitPlatform()
}
:::warning Gradle 8.14 no arranca bajo JDK 25 Necesitás JDK 21 como launcher y JDK 25 sólo como toolchain:
./gradlew build --no-daemon -Porg.gradle.java.installations.paths="/ruta/al/jdk25" -Porg.gradle.java.installations.auto-detect=false
:::
Paso 2 — El changelog y la configuración
core-auth trae su changelog dentro del jar; se resuelve por classpath, no hay que copiar archivos:
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd">
<include file="db/changelog/core-auth/changelog-master.xml"/>
<!-- tus propios changesets debajo -->
<include file="001-purchase-request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
spring:
application:
name: mi-proyecto
liquibase:
change-log: classpath:db/changelog/app/changelog-master.xml
jpa:
hibernate:
ddl-auto: validate
open-in-view: false
server:
port: 8080
echotechs:
auth:
jwt:
# Sin default a propósito. Generalo con: openssl rand -base64 32
secret: ${JWT_SECRET}
authz:
openfga:
api-url: ${OPENFGA_URL:http://localhost:8080}
store-id: ${OPENFGA_STORE_ID}
echotechs:
auth:
cookies:
secure: false # sólo para HTTP local; en uat y arriba tiene que quedar true
logging:
level:
dev.echotechs: DEBUG
spring:
datasource:
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mi_proyecto}
username: ${DB_USER}
password: ${DB_PASSWORD}
driver-class-name: org.postgresql.Driver
Los perfiles se combinan: SPRING_PROFILES_ACTIVE=dev,postgres.
Paso 3 — Implementar las dos interfaces de core-auth
core-auth no tiene tabla de usuarios propia. Sin estos dos beans el contexto no arranca.
@Component
public class DatabaseUserCredentialAuthenticator implements UserCredentialAuthenticator {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public DatabaseUserCredentialAuthenticator(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Override
public Optional<AuthenticatedUser> authenticate(String username, String password) {
return userRepository.findByUsername(username)
.filter(user -> passwordEncoder.matches(password, user.getPasswordHash()))
.map(user -> new AuthenticatedUser(user.getId(), user.getOrganizationId()));
}
}
@Component
public class EmailPasswordResetHandler implements PasswordResetHandler {
@Override
public void requestReset(String email) {
// Comportate igual exista o no la cuenta — no filtres qué emails están registrados.
}
@Override
public void confirmReset(String resetToken, String newPassword) {
// Validá el token contra tu tabla y guardá el hash nuevo.
}
}
Para arrancar rápido y verificar el flujo, podés empezar con la versión demo de reference-app
(DemoUserCredentialAuthenticator), que usa un mapa hardcodeado, y reemplazarla después.
Paso 4 — Primera corrida
Levantá las dependencias:
docker run -d --name pg -e POSTGRES_DB=mi_proyecto -e POSTGRES_USER=app -e POSTGRES_PASSWORD=app -p 5432:5432 postgres:16
docker run -d --name openfga -p 8081:8080 openfga/openfga:v1.18.1 run
Y arrancá la aplicación:
JWT_SECRET=$(openssl rand -base64 32) DB_USER=app DB_PASSWORD=app SPRING_PROFILES_ACTIVE=dev,postgres ./gradlew bootRun
Liquibase crea el esquema core con user_session, refresh_token, service_account_client y api_key.
Paso 5 — Verificar que el login funciona
curl -i -X POST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"changeme"}'
Deberías ver un 200 con dos Set-Cookie (access_token y refresh_token, ambas HttpOnly) y un body
{"userId":"...","organizationId":"...","sessionId":"..."}. Los tokens no están en el body: viajan sólo
en cookies.
Probá que el access token autentica un endpoint protegido:
curl -i -X POST http://localhost:8080/api/auth/logout-all -b "access_token=<el-valor-de-la-cookie>"
Sin la cookie, el mismo endpoint devuelve 401 con {"code":"AUTHENTICATION_REQUIRED"}.
Paso 6 — Publicar el modelo de autorización
Creá el store y publicá el modelo. FgaAuthorizationModelPublisher no corre solo al arrancar (cada
publicación crea una versión nueva del modelo), así que cableálo a un paso deliberado. Para el arranque
inicial, un runner activado por perfil sirve:
@Component
@Profile("fga-bootstrap")
public class FgaBootstrapRunner implements ApplicationRunner {
private final FgaAuthorizationModelPublisher publisher;
public FgaBootstrapRunner(FgaAuthorizationModelPublisher publisher) {
this.publisher = publisher;
}
@Override
public void run(ApplicationArguments args) {
String dsl = publisher.readBaseModelDsl() + """
type purchase_request
relations
define owner_organization: [organization]
define can_view: member from owner_organization
define can_edit: admin from owner_organization
""";
System.out.println("authorization-model-id = " + publisher.publish(dsl));
}
}
Guardá el id que imprime en echotechs.authz.openfga.authorization-model-id y desactivá el perfil.
:::caution Identificadores en inglés, ASCII only
El DSL de OpenFGA no acepta caracteres no-ASCII: organizacion_dueña no parsea. Los ejemplos en
español del spec no funcionan tal cual — usá organization, member, owner_organization, can_view,
can_edit.
:::
Paso 7 — El primer recurso con multi-tenancy
La entidad, extendiendo CoreEntity:
@Entity
@Table(name = "purchase_request", schema = "app")
public class PurchaseRequest extends CoreEntity {
@JdbcTypeCode(SqlTypes.CHAR)
@Column(name = "organization_id", nullable = false, length = 36)
private UUID organizationId;
@Column(name = "title", nullable = false, length = 255)
private String title;
protected PurchaseRequest() {
}
public PurchaseRequest(UUID organizationId, String title) {
this.organizationId = organizationId;
this.title = title;
}
public UUID getOrganizationId() {
return organizationId;
}
public String getTitle() {
return title;
}
}
El changeset — CHAR(36) para el id, que es lo que CoreEntity espera:
<changeSet id="app-001-purchase-request" author="tuproyecto">
<createTable tableName="purchase_request" schemaName="app">
<column name="id" type="CHAR(36)">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="organization_id" type="CHAR(36)">
<constraints nullable="false"/>
</column>
<column name="title" type="VARCHAR(255)">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
El servicio — acá están las dos mitades del multi-tenancy: escribir la tupla al crear, y anotar la lectura:
@Service
public class PurchaseRequestService {
private final PurchaseRequestRepository repository;
private final FgaTupleWriter tupleWriter;
public PurchaseRequestService(PurchaseRequestRepository repository, FgaTupleWriter tupleWriter) {
this.repository = repository;
this.tupleWriter = tupleWriter;
}
@Transactional
public PurchaseRequest create(String title) {
UUID orgId = TenantContext.getCurrentOrg();
PurchaseRequest saved = repository.save(new PurchaseRequest(orgId, title));
// Sin esta tupla, ni siquiera quien lo creó puede leerlo después.
tupleWriter.grant(
FgaId.of("organization", orgId),
"owner_organization",
FgaId.of("purchase_request", saved.getId()));
return saved;
}
@RequiresPermission(relation = "can_view", objectType = "purchase_request", objectId = "#id")
public PurchaseRequest get(UUID id) {
return repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Purchase request " + id + " not found"));
}
}
Y el controller, usando las convenciones de core-web:
@RestController
@RequestMapping("/api/purchase-requests")
public class PurchaseRequestController {
private final PurchaseRequestService service;
private final PurchaseRequestRepository repository;
@PostMapping
public PurchaseRequest create(@Valid @RequestBody CreatePurchaseRequestDto dto) {
return service.create(dto.title());
}
@GetMapping("/{id}")
public PurchaseRequest get(@PathVariable UUID id) {
return service.get(id);
}
@GetMapping
public PageResponse<PurchaseRequest> list(Pageable pageable) {
return PageResponse.from(
repository.findByOrganizationId(TenantContext.getCurrentOrg(), pageable));
}
}
El usuario necesita además ser miembro de su organización, tupla que normalmente escribís al darlo de alta:
tupleWriter.grant(FgaId.of("user", userId), "member", FgaId.of("organization", orgId));
Paso 8 — Verificar el aislamiento entre organizaciones
Esta es la prueba que importa:
# Como usuario de la organización A
curl -X POST http://localhost:8080/api/purchase-requests -b "access_token=<token-org-A>" -H 'Content-Type: application/json' -d '{"title":"Compra de la org A"}'
# El mismo recurso, pedido por un usuario de la organización B → 403 ACCESS_DENIED
curl -i http://localhost:8080/api/purchase-requests/<id> -b "access_token=<token-org-B>"
Ese 403 lo produce RequiresPermissionAspect → AccessDeniedException → GlobalExceptionHandler, y es
exactamente el caso que base-authorization-model.test.fga.yaml cubre como test explícito del modelo.
Paso 9 — Empaquetar
El Dockerfile de reference-app sirve como base. Los puntos que importan: usuario no-root, doble JDK
(21 launcher / 25 toolchain), y sin perfil fijado en el ENTRYPOINT — SPRING_PROFILES_ACTIVE es una
variable de entorno de runtime, nunca un argumento de build. Una misma imagen sirve para dev, UAT, preprod y
prod.
Qué falta para cumplir el criterio de éxito del spec
De los cuatro puntos de la sección 6 del spec, el backend cubre tres:
| Objetivo | Estado |
|---|---|
| Login funcionando | ✅ core-auth, verificado en AuthFlowIntegrationTest |
| Recurso con multi-tenancy automático | ✅ core-authz, verificado contra OpenFGA real |
| Subida de archivos | ✅ core-storage, verificado contra MinIO real |
| Una tabla en el frontend | ❌ @echotechs/ui-kit no existe |
Y el "sin escribir una sola línea de código de infraestructura" todavía no se cumple: hoy hay que
implementar UserCredentialAuthenticator/PasswordResetHandler, publicar el modelo de OpenFGA a mano, y
escribir las tuplas de cada recurso. Eso es lo que create-echotechs-app debería automatizar.