Saltar al contenido principal
Versión: v0.1.2

core-authz

Qué problema resuelve​

Este es el módulo que existe por el bug de multi-tenancy: un usuario de una organización viendo datos de otra. La causa de raíz es siempre la misma — la comprobación de permisos se escribe a mano en cada endpoint, y basta que falte en uno.

core-authz mueve esa decisión fuera del código de dominio y la delega a OpenFGA (self-hosted, corriendo en Coolify), con un modelo de relaciones explícito. En vez de escribir if (recurso.getOrgId().equals(...)) en cada método, anotás el método con @RequiresPermission y el aspecto arma el check() contra el principal autenticado, tirando 403 si falla.

El modelo base que trae define la relación organización → recurso una sola vez, y cada proyecto de dominio lo extiende con sus propios tipos siguiendo el mismo patrón.

Cómo se agrega​

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

Arrastra core-auth (y por lo tanto core-web y core-persistence), el openfga-sdk como api, y openfga-language + aspectjweaver como implementation. No tiene tablas propias — el estado de autorización vive en OpenFGA, no en tu base.

Por qué el SDK crudo y no el starter

dev.openfga:openfga-spring-boot-starter fija spring-boot-dependencies 3.4.13 en su POM: apunta a Spring Boot 3.4, no a nuestro 4.1. core-authz cablea el SDK crudo por su cuenta en CoreAuthzAutoConfiguration.

Configuración​

application.yml
echotechs:
authz:
openfga:
api-url: http://openfga:8080
store-id: 01HXXXXXXXXXXXXXXXXXXXXXXX
authorization-model-id: 01HYYYYYYYYYYYYYYYYYYYYYYY # opcional
PropiedadDefaultNotas
echotechs.authz.openfga.api-url—Activa el módulo. Sin esto no se crea ningún bean
echotechs.authz.openfga.store-id—
echotechs.authz.openfga.authorization-model-id—Omitilo para usar el último modelo publicado del store
echotechs.authz.openfga.read-timeout5s
echotechs.authz.openfga.connect-timeout5s
echotechs.authz.openfga.credentials.methodNONENONE o API_TOKEN
echotechs.authz.openfga.credentials.api-token—Sólo con API_TOKEN

Toda la autoconfiguración está condicionada a api-url (@ConditionalOnProperty), así que un proyecto que todavía no levantó OpenFGA no falla al arrancar — simplemente no tiene los beans. Justo porque la autoconfig entera depende de esa condición, OpenFgaProperties sólo se enlaza una vez que api-url ya está presente: @Validated + @NotBlank (spec 1.10) ahí sólo cubre el caso de que esté seteada pero en blanco, no reemplaza el @ConditionalOnProperty.

Admin UI comunitario — infraestructura, no código

El spec (1.2/1.3) agrega una nota sobre desplegar el OpenFGA Admin UI comunitario como un container aparte en Coolify, apuntando al mismo servidor de OpenFGA — uno por ambiente, no por proyecto, para que el equipo pueda inspeccionar stores/modelos/tuplas sin CLI. No es el "Playground" embebido de OpenFGA: ese sólo sirve en localhost mientras diseñás un modelo, no soporta OIDC y no se puede exponer para uso compartido. Esto es un cambio de infraestructura (Coolify), no algo que este módulo implemente en código — no hay ningún cambio correspondiente en core-authz, y no está desplegado todavía — esto es la config de referencia, no confirmación de que el container ya corre en algún ambiente real.

Config de referencia (verificado que la imagen existe y está activa en Docker Hub — communica/openfga-admin-ui, 522 pulls al momento de escribir esto; no confundir con imágenes homónimas de otros mantenedores como ubuntu/identity-platform-admin-ui, que es un proyecto distinto de Canonical):

Servicio nuevo en el compose/stack de Coolify — no reemplaza nada existente
services:
openfga-admin-ui:
image: communica/openfga-admin-ui:latest
environment:
# Apunta al mismo servidor OpenFGA que ya corre en Coolify — no se despliega OpenFGA de nuevo.
FGA_API_URL: http://openfga:8080
ports:
- "3001:3000" # verificar el puerto real expuesto por la imagen antes de exponerlo públicamente
restart: unless-stopped

Antes de exponerlo fuera de la red interna de Coolify: confirmar si esta imagen soporta autenticación propia u OIDC (el spec explícitamente descarta el Playground embebido por no soportarlo) — si no la soporta, el proxy de Coolify tiene que agregar su propia capa de auth delante, igual que cualquier otro panel interno sin login propio.

El modelo de autorización base​

classpath:openfga/base-authorization-model.fga, exactamente como está en el código:

model
schema 1.1

type user

type service_account

type organization
relations
define admin: [user]
define member: [user, service_account] or admin

type resource
relations
define owner_organization: [organization]
define can_view: member from owner_organization
define can_edit: admin from owner_organization

Lo que se lee de ahí:

  • admin implica member (por el or admin), así que un admin puede ver y editar.
  • Un member puede can_view pero no can_edit.
  • Los service_account pueden ser miembros de una organización, pero nunca admin (el [user] de admin no los incluye).
  • El acceso a un resource se deriva enteramente de qué organización lo posee. No hay forma de darle permiso a un usuario sobre un recurso suelto sin pasar por una organización.

Tu proyecto extiende esto con sus propios tipos siguiendo el mismo patrón:

type purchase_request
relations
define owner_organization: [organization]
define can_view: member from owner_organization
define can_edit: admin from owner_organization
El DSL de OpenFGA es sólo ASCII

Los identificadores del modelo no aceptan caracteres no-ASCII. organizacion_dueña (como aparece en el spec) no parsea. Por eso el modelo real usa nombres en inglés.

API pública​

@RequiresPermission​

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RequiresPermission {
String relation(); // p.ej. "can_view"
String objectType(); // p.ej. "purchase_request"
String objectId(); // expresión SpEL sobre los parámetros del método, p.ej. "#id"
}

Los tres atributos son obligatorios. objectId es una expresión SpEL que se evalúa contra los parámetros del método anotado: "#id" para un parámetro llamado id, o algo como "#request.purchaseRequestId()" para navegar dentro de un parámetro.

El aspecto (RequiresPermissionAspect, un @Before) hace tres cosas:

  1. Toma el principal de TenantContext.getCurrentPrincipal(). Si es null, tira AccessDeniedException sin llamar a OpenFGA.
  2. Evalúa objectId. Si resuelve a null, tira IllegalStateException.
  3. Llama FgaService.check(FgaId.of(principal), relation, FgaId.of(objectType, objectId)). Si devuelve false, tira AccessDeniedException → que GlobalExceptionHandler traduce a 403 ACCESS_DENIED.

FgaService​

public class FgaService {
/**
* @param user p.ej. "user:<uuid>"
* @param relation p.ej. "can_view"
* @param object p.ej. "resource:<uuid>"
*/
public boolean check(String user, String relation, String object);
}

FgaTupleWriter​

public class FgaTupleWriter {
public void grant(String user, String relation, String object);
public void revoke(String user, String relation, String object);
}

Esto es lo que escribís al crear o asignar un recurso. Un check() sin las tuplas correspondientes devuelve siempre false.

FgaId​

public final class FgaId {
public static String of(String type, UUID id);
public static String of(String type, String id);
public static String of(CorePrincipal principal); // type().name().toLowerCase() + ":" + principalId()
}

Usalo siempre en vez de concatenar strings a mano. La sobrecarga con CorePrincipal produce user:<uuid> para un UserPrincipal y service_account:<uuid> para un ServiceAccountPrincipal — coincidiendo con los tipos del modelo base.

FgaAuthorizationModelPublisher​

public class FgaAuthorizationModelPublisher {
/** @return el id del modelo nuevo, para guardar en echotechs.authz.openfga.authorization-model-id */
public String publish(String dsl);
public String publishBaseModel();
public String readBaseModelDsl();
}
No corre solo al arrancar

Cada publicación crea una versión nueva del modelo en el store. Llamarlo en cada boot, con N réplicas del servicio, llenaría el store de versiones. Cableálo a un paso deliberado: una acción de admin, una tarea de migración, o un hook de deploy.

POST /api/authz/check — el equivalente HTTP de @RequiresPermission​

CheckController (core-authz/src/main/java/dev/echotechs/core/authz/web/CheckController.java) expone la misma pregunta que hace el aspecto, pero para que la decida un frontend antes de renderizar un botón o una sección — un hook tipo usePermission(relation, objectType, objectId).

public record CheckRequest(@NotBlank String relation, @NotBlank String objectType, @NotBlank String objectId) {}
public record CheckResponse(boolean allowed) {}
POST /api/authz/check
{"relation": "can_edit", "objectType": "purchase_request", "objectId": "3f2..."}

200 {"allowed": false}

La diferencia clave con @RequiresPermission: esto nunca tira 403 por un check() negativo. Es una consulta, no un guardia — la respuesta a "¿puedo?" siendo "no" es una respuesta normal, no un error. La única forma de que este endpoint falle es que no haya principal autenticado en TenantContext, en cuyo caso tira AccessDeniedException sin llegar a llamar a OpenFGA — mismo primer paso que RequiresPermissionAspect. Por lanzarse dentro del controller, GlobalExceptionHandler la traduce a 403 ACCESS_DENIED, igual que cualquier otro AccessDeniedException — ver core-web.

Registrado como bean en CoreAuthzAutoConfiguration, así que hereda la misma condición que el resto del módulo: sin echotechs.authz.openfga.api-url seteado, no existe.

No reemplaza a @RequiresPermission en el backend

Este endpoint es para que un cliente decida qué mostrar. No protege nada por sí mismo — un endpoint de escritura sigue necesitando su propio @RequiresPermission. Usarlo como única defensa dejaría la autorización real del lado del cliente.

OpenFgaOperationException​

public class OpenFgaOperationException extends RuntimeException {}

Envuelve las excepciones checked/async del SDK. No extiende ApiException, así que GlobalExceptionHandler la trata por el fallback genérico → 500 INTERNAL_ERROR. Es deliberado: si OpenFGA no responde, no es un error del cliente.

Ejemplo de uso​

Proteger un método​

Del ProtectedResourceService de los tests:

@Service
public class PurchaseRequestService {

@RequiresPermission(relation = "can_view", objectType = "purchase_request", objectId = "#id")
public PurchaseRequest get(UUID id) {
return repository.findById(id).orElseThrow(() -> new ResourceNotFoundException("..."));
}

@RequiresPermission(relation = "can_edit", objectType = "purchase_request", objectId = "#id")
public PurchaseRequest approve(UUID id) {
// ...
}
}

Un uso real sobre un @RestController, tomado de ConfigController en core-config:

@PutMapping("/{key}")
@RequiresPermission(relation = "admin", objectType = "organization", objectId = "#organizationId")
public ConfigEntryResponse set(@PathVariable UUID organizationId, @PathVariable String key,
@Valid @RequestBody UpdateConfigValueRequest request) {
ConfigEntry entry = configService.setValue(key, organizationId, request.value());
return new ConfigEntryResponse(entry.getConfigKey(), entry.getValue(), true);
}

ConfigModuleIntegrationTest confirma que el proxying AOP automático de Spring Boot lo intercepta sin configuración extra, igual que cualquier otro bean.

Escribir las tuplas al crear un recurso​

Esto es lo que no podés olvidarte: sin tuplas, nadie tiene acceso.

@Service
public class PurchaseRequestService {

private final FgaTupleWriter tupleWriter;
private final PurchaseRequestRepository repository;

@Transactional
public PurchaseRequest create(String title) {
UUID orgId = TenantContext.getCurrentOrg();
PurchaseRequest saved = repository.save(new PurchaseRequest(orgId, title));

tupleWriter.grant(
FgaId.of("organization", orgId),
"owner_organization",
FgaId.of("purchase_request", saved.getId()));

return saved;
}
}

Y la membresía de un usuario, típicamente al darlo de alta en una organización:

tupleWriter.grant(FgaId.of("user", userId), "member", FgaId.of("organization", orgId));
// o, para un administrador:
tupleWriter.grant(FgaId.of("user", userId), "admin", FgaId.of("organization", orgId));

Test de integración contra un OpenFGA real​

De CoreAuthzIntegrationTest, que corre contra la imagen oficial vía Testcontainers, sin mocks:

@Testcontainers
class CoreAuthzIntegrationTest {

@Container
static final OpenFGAContainer OPENFGA = new OpenFGAContainer("openfga/openfga:v1.18.1");

static FgaService fgaService;
static FgaTupleWriter tupleWriter;

@BeforeAll
static void publishModelAgainstARealStore() throws Exception {
String apiUrl = OPENFGA.getHttpEndpoint();

OpenFgaClient bootstrapClient = new OpenFgaClient(new ClientConfiguration().apiUrl(apiUrl));
String storeId = bootstrapClient.createStore(new CreateStoreRequest().name("core-authz-test")).get().getId();

OpenFgaClient fgaClient = new OpenFgaClient(new ClientConfiguration().apiUrl(apiUrl).storeId(storeId));
new FgaAuthorizationModelPublisher(fgaClient).publishBaseModel();

fgaService = new FgaService(fgaClient);
tupleWriter = new FgaTupleWriter(fgaClient);
}

@Test
void memberCanViewButNotEditAResourceOwnedByTheirOrg() {
String user = FgaId.of("user", UUID.randomUUID());
String org = FgaId.of("organization", UUID.randomUUID());
String resource = FgaId.of("resource", UUID.randomUUID());

tupleWriter.grant(user, "member", org);
tupleWriter.grant(org, "owner_organization", resource);

assertThat(fgaService.check(user, "can_view", resource)).isTrue();
assertThat(fgaService.check(user, "can_edit", resource)).isFalse();
}
}

Para ejercitar el aspecto hace falta que el principal esté en TenantContext, lo que a su vez requiere pasar por TenantContextFilter. El helper que usan los tests:

private void withPrincipal(UserPrincipal principal, Runnable assertion) throws Exception {
SecurityContextHolder.getContext().setAuthentication(new CorePrincipalAuthenticationToken(principal));
TenantContextFilter filter = new TenantContextFilter();
FilterChain chain = (request, response) -> assertion.run();
filter.doFilter(new MockHttpServletRequest(), new MockHttpServletResponse(), chain);
}

Tests del modelo, sin Java​

base-authorization-model.test.fga.yaml prueba el modelo con el CLI de OpenFGA, sin servidor ni código. Un caso, que es el bug de multi-tenancy convertido en test explícito:

- name: "a user in a different organization cannot see a resource owned by another org"
tuples:
- user: user:diego
relation: member
object: organization:acme
- user: organization:acme
relation: owner_organization
object: resource:123
- user: user:otro_usuario_org_b
relation: member
object: organization:globex
check:
- user: user:otro_usuario_org_b
object: resource:123
assertions:
can_view: false
can_edit: false

Corre en CI vía .github/workflows/fga-model-test.yml, disparado sólo cuando cambia un .fga o .fga.yaml.

Errores comunes​

Todos los check() devuelven false y nadie tiene acceso. Casi siempre falta escribir las tuplas. @RequiresPermission sólo lee; alguien tiene que haber llamado FgaTupleWriter.grant(...) al crear el recurso y al dar de alta al usuario en la organización.

AccessDeniedException con "No authenticated principal to check permissions for." El aspecto no encontró principal en TenantContext. O la request no pasó por TenantContextFilter, o estás llamando el método desde un @Scheduled/@Async/tarea de arranque donde no hay request.

@RequiresPermission no hace nada. Es AOP de Spring: sólo funciona atravesando el proxy. Una llamada this.otroMetodo() dentro de la misma clase no pasa por el proxy y la anotación se ignora. Movelo a otro bean.

IllegalStateException: objectId expression '#id' resolved to null. El SpEL no encontró el parámetro. Verificá el nombre — depende de que los nombres de parámetro estén en el bytecode (-parameters, que el plugin de Spring Boot activa por default). Si el valor legítimamente puede ser null, validalo antes.

La app no arranca porque falta un bean FgaService. Todo CoreAuthzAutoConfiguration está condicionado a echotechs.authz.openfga.api-url. Si algo tuyo inyecta FgaService pero no configuraste esa propiedad, el contexto falla. Lo mismo aplica a core-storage, que necesita FgaService.

Un 500 en vez de un 403. OpenFgaOperationException (timeout, OpenFGA caído) no es ApiException, así que cae en el fallback genérico → 500. Un 403 significa "OpenFGA respondió que no"; un 500, "no se pudo preguntar".

El modelo publicado no coincide con el que espera el código. Si authorization-model-id no está seteado, OpenFGA usa el último modelo publicado del store — que puede no ser el que vos creés. Fijá el id explícitamente en producción.

Notas de implementación​

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

Todos los identificadores del modelo están en inglés. El spec 1.3 muestra el modelo en español (organizacion, miembro, recurso, organizacion_dueña, puede_ver, puede_editar). El modelo real usa organization, member, resource, owner_organization, can_view, can_edit. Dos motivos: el DSL de OpenFGA es ASCII-only y organizacion_dueña directamente no parsea, y la convención del repo es que todo identificador programático va en inglés. Si copiás un ejemplo del spec, no va a funcionar.

@RequiresPermission tiene un tercer atributo obligatorio. El spec la muestra como @RequiresPermission(relation = "puede_ver", objectType = "solicitud") — dos atributos. La anotación real exige además objectId, una expresión SpEL. Sin él no compila.

El modelo base agrega service_account como tipo propio. El spec 1.1(b) anticipa define miembro: [user, service_account], y el código lo cumple, pero además declara type service_account como tipo de primer nivel. admin sigue siendo sólo [user]: un service account nunca puede ser admin de una organización.

El publicador del modelo no se ejecuta automáticamente. El spec no dice cuándo se publica el modelo. La implementación es explícita en que no lo hace al arrancar, y deja el cableado del "paso deliberado" al proyecto que lo consume. En la práctica: hoy no existe ninguna tarea, endpoint ni comando en este repo que publique el modelo en un entorno real — sólo lo llaman los tests.