Saltar al contenido principal
Versión: v0.1.0

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:

  1. Crear proyecto — un comando genera backend + frontend cableados entre sí.
  2. Primera corridadocker compose up levanta el servicio, Postgres, OpenFGA y MinIO.
  3. Login funcionando — el frontend ya trae las pantallas de login usando @echotechs/auth-web, contra los endpoints de core-auth.
  4. 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:

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:

src/main/resources/db/changelog/app/changelog-master.xml
<?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>
src/main/resources/application.yml
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}
src/main/resources/application-dev.yml
echotechs:
auth:
cookies:
secure: false # sólo para HTTP local; en uat y arriba tiene que quedar true
logging:
level:
dev.echotechs: DEBUG
src/main/resources/application-postgres.yml
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.

DatabaseUserCredentialAuthenticator.java
@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()));
}
}
EmailPasswordResetHandler.java
@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:

FgaBootstrapRunner.java
@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:

PurchaseRequest.java
@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:

db/changelog/app/001-purchase-request.xml
<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:

PurchaseRequestService.java
@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:

PurchaseRequestController.java
@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 RequiresPermissionAspectAccessDeniedExceptionGlobalExceptionHandler, 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 ENTRYPOINTSPRING_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:

ObjetivoEstado
Login funcionandocore-auth, verificado en AuthFlowIntegrationTest
Recurso con multi-tenancy automáticocore-authz, verificado contra OpenFGA real
Subida de archivoscore-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.