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
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
echotechs:
authz:
openfga:
api-url: http://openfga:8080
store-id: 01HXXXXXXXXXXXXXXXXXXXXXXX
authorization-model-id: 01HYYYYYYYYYYYYYYYYYYYYYYY # opcional
| Propiedad | Default | Notas |
|---|---|---|
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-timeout | 5s | |
echotechs.authz.openfga.connect-timeout | 5s | |
echotechs.authz.openfga.credentials.method | NONE | NONE 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.
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í:
adminimplicamember(por elor admin), así que un admin puede ver y editar.- Un
memberpuedecan_viewpero nocan_edit. - Los
service_accountpueden ser miembros de una organización, pero nuncaadmin(el[user]deadminno los incluye). - El acceso a un
resourcese 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:
- Toma el principal de
TenantContext.getCurrentPrincipal(). Si esnull, tiraAccessDeniedExceptionsin llamar a OpenFGA. - Evalúa
objectId. Si resuelve anull, tiraIllegalStateException. - Llama
FgaService.check(FgaId.of(principal), relation, FgaId.of(objectType, objectId)). Si devuelvefalse, tiraAccessDeniedException→ queGlobalExceptionHandlertraduce a 403ACCESS_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.