Saltar al contenido principal
Versión: main (sin publicar)

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.

:::info 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-urlActiva el módulo. Sin esto no se crea ningún bean
echotechs.authz.openfga.store-id
echotechs.authz.openfga.authorization-model-idOmitilo 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-tokenSó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.

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

:::caution 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();
}

:::danger 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. :::

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.