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 un motor de autorización. En
vez de escribir if (recurso.getOrgId().equals(...)) en cada método, anotás el método con
@RequiresPermission y un aspecto arma el check() contra el principal autenticado, tirando 403 si falla.
El motor decide la pregunta ¿este principal puede hacer esta acción sobre este recurso?. El módulo trae
dos implementaciones detrás de la misma interfaz (PermissionDecisionService):
- Cedar — el motor por defecto. Corre en proceso (
com.cedarpolicy:cedar-java), sin servidor, con el esquema y las políticas como recursos del classpath + filas de tu propia base. No necesita infraestructura ni configuración para activarse: si no decís nada, Cedar es lo que arranca. - OpenFGA — el motor legacy, disponible detrás de
echotechs.authz.engine=openfgapara los consumidores que todavía no migraron (p.ej.reference-app). Es un servidor externo self-hosted con un modelo de relaciones y un store de tuplas.
@RequiresPermission, POST /api/authz/check y la superficie IAM son agnósticos al motor: los dos
implementan PermissionDecisionService, así que el código de dominio no se entera de cuál está corriendo.
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), cedar-java y aspectjweaver como
implementation. Bajo Cedar tiene tablas propias — core.cedar_policy y core.cedar_policy_attachment
(esquema core, creadas por el changelog de Liquibase del módulo) — donde viven las políticas administrables
en runtime. Bajo OpenFGA no hay tablas propias; el estado vive en el servidor OpenFGA.
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,
sólo cuando el motor OpenFGA está activo. Cedar no tiene este problema — es una librería Java pura.
Configuración
Cedar (motor por defecto)
Cedar se activa solo — @ConditionalOnProperty(name = "echotechs.authz.engine", havingValue = "cedar", matchIfMissing = true). No seteés echotechs.authz.engine y Cedar arranca. Las propiedades van bajo
echotechs.authz.cedar:
echotechs:
authz:
# engine: cedar # innecesario: es el default
cedar:
cache-ttl: 10s
database-policies-enabled: true
# schema-location: classpath:cedar/core-schema.cedarschema
# policy-locations:
# - classpath:cedar/core-policies.cedar
| Propiedad | Default | Notas |
|---|---|---|
echotechs.authz.cedar.cache-ttl | 10s | TTL del snapshot de esquema + políticas en CedarPolicyStore. Se recarga bajo demanda al expirar o tras un evict() |
echotechs.authz.cedar.schema-location | classpath:cedar/core-schema.cedarschema | Ubicación del esquema Cedar |
echotechs.authz.cedar.policy-locations | [classpath:cedar/core-policies.cedar] | Lista de ubicaciones de políticas versionadas (classpath) |
echotechs.authz.cedar.database-policies-enabled | true | Si leer las políticas administrables de la base además de las del classpath |
echotechs.authz.cedar.roles | 3 roles (ver abajo) | Catálogo de roles: clave, etiqueta, descripción y permisos por rol |
Catálogo de roles
CedarProperties trae tres roles por defecto, cada uno con su lista de permisos (action, resourceType, scope, description). Los roles no se sincronizan contra un store de tuplas: viven en la columna
rolesCsv de la cuenta (core-auth) y se proyectan como principal.roles al momento de cada check().
Asignar un rol es escribir esa columna — ver PUT /api/users/{id}/roles.
| Rol | Resumen |
|---|---|
platform_admin | Administra la plataforma: platform:admin, authz:model:view, operate_as sobre cualquier organización activa, admin/can_view/can_edit sobre la organización activa |
admin | authz:model:view, operate_as/admin/can_view/can_edit sobre su organización |
analyst | operate_as/can_view/can_edit sobre su organización (cartera) |
Podés sobrescribir la lista entera con echotechs.authz.cedar.roles[*] si tu dominio necesita otros roles o
permisos; los permisos que definas acá son los que expone GET /api/authz/model.
OpenFGA (motor legacy, opt-in)
echotechs:
authz:
engine: openfga
openfga:
api-url: http://openfga:8080
store-id: 01HXXXXXXXXXXXXXXXXXXXXXXX
authorization-model-id: 01HYYYYYYYYYYYYYYYYYYYYYYY # opcional
| Propiedad | Default | Notas |
|---|---|---|
echotechs.authz.engine | cedar (por matchIfMissing) | Seteala en openfga para usar el motor legacy |
echotechs.authz.openfga.api-url | — | URL del servidor OpenFGA |
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 OpenFGA está condicionada a echotechs.authz.engine=openfga (y el cliente, además,
a que api-url esté presente). OpenFgaProperties es @Validated + @NotBlank en apiUrl: cubre el caso
de que el motor esté activo pero api-url esté en blanco, no reemplaza el @ConditionalOnProperty.
El spec 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. Es un cambio de infraestructura (Coolify), no algo que este módulo implemente en código — no está desplegado todavía; esto es la config de referencia.
services:
openfga-admin-ui:
image: communica/openfga-admin-ui:latest
environment:
FGA_API_URL: http://openfga:8080 # el mismo servidor OpenFGA que ya corre en Coolify
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 — si no la soporta, el proxy de Coolify tiene que agregar su propia capa de auth delante.
El modelo Cedar
Esquema — classpath:cedar/core-schema.cedarschema
entity User = {
principalType: String,
principalId: String,
roles: Set<String>,
homeOrg: Organization,
activeOrg: Organization,
organizationId: String,
activeOrganizationId: String,
};
entity ServiceAccount = {
principalType: String,
principalId: String,
roles: Set<String>,
homeOrg?: Organization,
activeOrg?: Organization,
organizationId?: String,
activeOrganizationId?: String,
};
entity ApiKey = {
principalType: String,
principalId: String,
roles: Set<String>,
homeOrg?: Organization,
activeOrg?: Organization,
organizationId?: String,
activeOrganizationId?: String,
};
entity Organization in [Organization] = {
id: String,
name: String,
slug: String,
active: Bool,
};
entity Platform;
entity Resource in [Organization] = {
organizationId: String,
active?: Bool,
};
action "platform:admin" appliesTo {
principal: [User],
resource: [Platform],
};
action "authz:model:view" appliesTo {
principal: [User],
resource: [Platform],
};
action "operate_as" appliesTo {
principal: [User],
resource: [Organization],
};
action "admin" appliesTo {
principal: [User],
resource: [Organization],
};
action "can_view" appliesTo {
principal: [User, ServiceAccount, ApiKey],
resource: [Resource],
};
action "can_edit" appliesTo {
principal: [User, ServiceAccount, ApiKey],
resource: [Resource],
context: {
readOnly?: Bool,
},
};
Lo que se lee de ahí:
Organization in [Organization]— una organización puede ser miembro de otra (jerarquía). El resolvedor camina el árbol de padres al armar las entidades, con detección de ciclos.Resource in [Organization]— un recurso pertenece a una organización por membership, no por una tupla. ElorganizationIddel recurso basta para que Cedar sepa de qué org cuelga. No hay que escribir tuplas al crear un recurso (a diferencia de OpenFGA): alcanza con que el recurso exponga suorganizationIdy suactive.can_editdeclara uncontext.readOnlyopcional — la política base lo usa para denegar ediciones en contexto de sólo lectura.platform:adminyauthz:model:viewse deciden sobrePlatform::"global"(un singleton).- Los
ServiceAccount/ApiKeypuedencan_view/can_editrecursos pero noplatform:admin,authz:model:view,operate_asniadmin(esos son sólo[User]).
Políticas base — classpath:cedar/core-policies.cedar
forbid (principal, action, resource)
when {
resource has active && !resource.active
};
forbid (principal, action == Action::"can_edit", resource)
when {
context has readOnly && context.readOnly
};
permit (
principal,
action == Action::"platform:admin",
resource == Platform::"global"
)
when {
principal.roles.contains("platform_admin")
};
permit (
principal,
action == Action::"authz:model:view",
resource == Platform::"global"
)
when {
principal.roles.contains("platform_admin") || principal.roles.contains("admin")
};
permit (principal, action == Action::"operate_as", resource)
when {
(principal has homeOrg && principal.homeOrg == resource)
|| (principal.roles.contains("platform_admin") && resource.active)
};
permit (principal, action == Action::"admin", resource)
when {
principal.roles.contains("platform_admin")
|| (
principal.roles.contains("admin")
&& principal has activeOrg
&& (resource == principal.activeOrg || resource in principal.activeOrg)
)
};
permit (principal, action == Action::"can_view", resource)
when {
principal has activeOrg
&& (resource == principal.activeOrg || resource in principal.activeOrg)
&& (
principal.roles.contains("platform_admin")
|| principal.roles.contains("admin")
|| principal.roles.contains("analyst")
)
};
permit (principal, action == Action::"can_edit", resource)
when {
principal has activeOrg
&& (resource == principal.activeOrg || resource in principal.activeOrg)
&& (
principal.roles.contains("platform_admin")
|| principal.roles.contains("admin")
|| principal.roles.contains("analyst")
)
};
Dos forbid globales y cinco permit. Lo importante:
- Se deniega primero sobre cualquier recurso inactivo, y sobre cualquier
can_editen contextoreadOnly. Unforbidsiempre le gana a unpermiten Cedar. operate_aspermite operar como tu propia org (homeOrg) o, si sosplatform_admin, como cualquier org activa.admin/can_view/can_editsobre un recurso se dan cuando el recurso cuelga de tuactiveOrg(igualdad o membership en la jerarquía) y tenés el rol correspondiente.activeOrgviene delTenantContext(override de organización activa) o, si no hay override, de tuhomeOrg. Así es como unplatform_admin"opera como" un tenant: cambia su organización activa.
Estas políticas son GLOBAL (se evalúan en cada request). Las políticas que creás por la API de admin son
GLOBAL o ATTACHED (ver Admin de políticas).
Cómo se evalúa una decisión
CedarAuthorizationService.check(principal, action, resourceType, resourceId, context)
└─ evaluate(...)
├─ CedarEntityResolver.resolve(...) → CedarRequestEntities
│ ├─ policyScopeFor(principal) → roles (de UserAccount.rolesCsv) + homeOrg + activeOrg
│ ├─ entidad principal (User/ServiceAccount/ApiKey) con attrs + roles + homeOrg/activeOrg
│ ├─ entidad action
│ ├─ entidad resource (vía CedarResourceResolver, o platform/organization nativos)
│ └─ árbol de organizaciones (padres) con attrs id/name/slug/active
├─ CedarPolicyStore.policySet(scope, draftPolicies) → PolicySet (classpath + DB + drafts)
└─ BasicAuthorizationEngine.isAuthorized(request, policySet, entities) → ALLOW | DENY
CedarPolicyStoremantiene unSnapshotcacheado (cache-ttl, default 10s) con el esquema, las políticas del classpath, las políticas habilitadas de la base, los attachments y elPolicySetglobal. Toda mutación de admin llamaevict()para que el siguientecheck()recargue. Si no hay ninguna política configurada, arrancar/evaluar tiraIllegalStateException— siempre hay, como mínimo, las del classpath.CedarEntityResolverarma las entidades Cedar a partir delCorePrincipaly la base. Si la cuenta o la organización no existen, lanzaCedarEntityNotFoundException, que el servicio traduce en un DENY (no un 500): un principal o recurso inexistente simplemente no tiene acceso.CedarNamesmapea tipos externos a tipos de entidad Cedar: el principal aUser/ServiceAccount/ApiKeysegúnPrincipalType;platform→Platform,organization→Organization; cualquier otro tipo de recurso se PascalCasea (purchase_request→PurchaseRequest). Las acciones sonAction::"<action>".listObjectsno usa partial evaluation de Cedar: enumera hasta 500 candidatos víaCedarResourceEnumerator(o los catálogos nativos deplatform/organization) y filtra concheck(). Es enumerate-and-check fuerza bruta — adecuado para catálogos acotados, no para conjuntos enormes.
SPI de recursos de dominio
El núcleo sólo conoce platform y organization nativamente. Para que check() y listObjects() sepan de
tus tipos de dominio (purchase_request, invoice, …) implementá estos beans (ambos inyectados como
ObjectProvider, ambos opcionales):
public interface CedarResourceResolver {
Optional<CedarResource> resolve(String type, String id); // un recurso concreto por id
}
public interface CedarResourceEnumerator {
boolean supports(String type);
List<CedarResourceCandidate> list(String type, int limit); // candidatos para listObjects
}
CedarResource lleva type, id, organizationId y attributes — el resolvedor le agrega
organizationId y lo cuelga como miembro de esa organización. Un recurso sin resolver (sin
CedarResourceResolver que lo soporte) resulta en CedarEntityNotFoundException → DENY.
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 al motor. - Evalúa
objectId. Si resuelve anull, tiraIllegalStateException. - Llama
permissionDecisionService.check(principal, relation, objectType, objectId, Map.of()). Si devuelvefalse, tiraAccessDeniedException→ queGlobalExceptionHandlertraduce a 403ACCESS_DENIED.
Es agnóstico al motor: el PermissionDecisionService que recibe es CedarAuthorizationService o
FgaPermissionDecisionService según el engine activo.
PermissionDecisionService
public interface PermissionDecisionService {
boolean check(CorePrincipal principal, String action, String resourceType,
String resourceId, Map<String, Object> context);
default List<String> listObjects(CorePrincipal principal, String action, String resourceType) {
return List.of();
}
}
Las dos implementaciones: CedarAuthorizationService (Cedar, default) y FgaPermissionDecisionService
(OpenFGA). Ambas implementan además ActiveOrganizationAuthorizer (canOperateAs), que
ActiveOrganizationFilter usa para validar el override de organización activa.
POST /api/authz/check — el equivalente HTTP de @RequiresPermission
CheckController 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). Agnóstico
al motor.
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 al motor — mismo primer paso que
RequiresPermissionAspect. Por lanzarse dentro del controller, GlobalExceptionHandler la traduce a 403
ACCESS_DENIED — ver core-web.
@RequiresPermission en el backendEste 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.
Admin de políticas (Cedar)
AuthzPolicyController (/api/authz) expone CRUD de políticas administrables en runtime + simulación. Sólo
se registra bajo Cedar. Los gates son authz:model:view (lectura/simulación sobre tu propio principal) y
platform:admin (escritura, y simular como otro principal).
| Método | Path | Body / Params | Retorna | Gate |
|---|---|---|---|---|
| GET | /api/authz/policies | — | { configured, database } (políticas del classpath + de la base, con sus attachments) | model viewer |
| POST | /api/authz/policies | SavePolicyRequest | PolicyResponse | policy admin |
| PUT | /api/authz/policies/{id} | SavePolicyRequest | PolicyResponse (con attachments) | policy admin |
| DELETE | /api/authz/policies/{id} | — | 204 | policy admin |
| POST | /api/authz/policies/validate | { body } | { valid, status } | model viewer |
| POST | /api/authz/policies/{id}/attachments | SaveAttachmentRequest | AttachmentResponse | policy admin |
| DELETE | /api/authz/policy-attachments/{id} | — | 204 | policy admin |
| GET | /api/authz/resources?type=&limit=50 | type | List<{ id, label }> | model viewer |
| GET | /api/authz/policy-templates | — | 3 plantillas (role-allow, explicit-deny, abac-threshold) | model viewer |
| POST | /api/authz/simulate | SimulateRequest | SimulationResponse | model viewer (policy admin si simulás como otro principal) |
public record SavePolicyRequest(
@NotBlank @Size(max = 160) String name,
@Size(max = 1024) String description,
String effect, // "permit" | "forbid" | "mixed" (se infiere del body si va null)
@NotBlank String body,
CedarPolicyType policyType, // default CUSTOMER_MANAGED
CedarPolicyScope policyScope, // default ATTACHED al crear por API
UUID organizationId,
Boolean enabled) {}; // default true
POST /api/authz/simulate evalúa una decisión contra el motor real, opcionalmente con draftPolicies
(borradores que no están en la base) — útil para probar una política antes de guardarla. El
SimulateRequest lleva principalId (simular como otro usuario requiere ser policy admin), action,
resourceType, resourceId, context y draftPolicies. La respuesta trae allowed, decision
(ALLOW/DENY) y las políticas activas que se evaluaron.
Modelo y roles — GET /api/authz/model
AuthzModelController arma una vista read-only del modelo: los roles (de CedarProperties), las acciones
(distintas, derivadas de los permisos de los roles) y las políticas activas (classpath + base habilitadas).
Gate: authz:model:view. Es agnóstico al motor (inyecta CedarPolicyAdminService como ObjectProvider,
así que un despliegue OpenFGA no rompe).
IAM admin
La superficie IAM combina CRUD de organizaciones (gate = platform:admin vía el motor) y asignación de
roles (gate = org-admin por base, porque core-auth no puede alcanzar el motor de authz).
OrganizationController — /api/organizations (platform admin):
| Método | Path | Body | Retorna |
|---|---|---|---|
| POST | /api/organizations | { name, slug } | OrganizationResponse |
| GET | /api/organizations | Pageable | Page<OrganizationResponse> |
| GET | /api/organizations/{id} | — | OrganizationResponse |
| PUT | /api/organizations/{id} | { name, slug } | OrganizationResponse |
| POST | /api/organizations/{id}/archive | — | 204 |
| POST | /api/organizations/{id}/restore | — | 204 |
slug valida ^[a-z0-9][a-z0-9-]*$ (lowercase, dígitos, guiones). El DB work se delega a
OrganizationService de core-auth; el controller sólo agrega el gate y el límite HTTP.
RoleAssignmentController — PUT /api/users/{id}/roles con { roles }. Gate: OrgAdminService (DB,
rolesCsv-based, no Cedar). Bajo Cedar esto es lo único que hay que hacer para darle permisos a un usuario:
escribir la columna de roles. No hay tuplas que sincronizar.
OperableOrganizationsController — GET /api/authz/operable-organizations: las organizaciones activas
sobre las que el principal actual puede operate_as.
Dos capas de guardia. PlatformAdminGuard (en core-authz, vía platform:admin del motor) y
OrgAdminService/OrgAdminGuard (en core-auth, DB-side, sobre rolesCsv). La asignación de roles usa el
org-admin de base a propósito: core-auth no puede alcanzar el motor de authz (la dependencia va en la
dirección opuesta).
Tablas (Cedar)
Liquibase: db/changelog/core-authz/changelog-master.xml incluye 001-cedar-policy-schema.xml y
002-cedar-iam-policy-management.xml. Todo en el esquema core.
core.cedar_policy (001, ampliado en 002):
| Columna | Tipo | Notas |
|---|---|---|
id | CHAR(36) PK | |
name | VARCHAR(160), único, not null | |
effect | VARCHAR(16), not null | permit/forbid/mixed |
description | VARCHAR(1024) | |
body | VARCHAR(1048576), not null | el cuerpo Cedar |
policy_type | VARCHAR(32), default CUSTOMER_MANAGED | CUSTOMER_MANAGED o INLINE |
policy_scope | VARCHAR(32), default GLOBAL | GLOBAL o ATTACHED |
organization_id | CHAR(36) | |
enabled | BOOLEAN, default true | |
created_at / updated_at | TIMESTAMP |
core.cedar_policy_attachment (002): id PK, policy_id (FK a cedar_policy.id ON DELETE CASCADE), target_type (USER/ROLE/ORGANIZATION), target_id, organization_id, created_at.
Único sobre (policy_id, target_type, target_id).
Scope GLOBAL vs ATTACHED. Una política GLOBAL se evalúa en cada request (las del classpath son
siempre GLOBAL; las de la base pueden serlo también). Una ATTACHED sólo se evalúa cuando está attached a
un target que matchea al principal: USER → targetId == principalId; ROLE →
principal.roles.contains(targetId); ORGANIZATION → la organización activa/home del principal contiene
targetId. Attachear una política GLOBAL se rechaza (CEDAR_POLICY_IS_GLOBAL) — ya se evalúa siempre.
Ejemplo de uso
Proteger un método
@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);
}
Resolvedor de recursos de dominio
Bajo Cedar no hay tuplas que escribir al crear un recurso: el acceso se deriva del organizationId del
recurso. Lo que sí tenés que dar es un CedarResourceResolver para que el motor sepa construir la entidad a
partir del id:
@Component
public class PurchaseRequestResourceResolver implements CedarResourceResolver {
private final PurchaseRequestRepository repository;
@Override
public Optional<CedarResource> resolve(String type, String id) {
if (!"purchase_request".equals(type)) {
return Optional.empty();
}
return repository.findById(UUID.fromString(id))
.map(pr -> CedarResource.of("purchase_request", pr.getId().toString(), pr.getOrganizationId(),
Map.of("active", pr.isActive())));
}
}
Y un CedarResourceEnumerator si querés que listObjects("purchase_request", ...) devuelva los ids
candidatos en vez de quedar vacío.
Dar permisos a un usuario
Bajo Cedar, asignar roles es escribir la columna rolesCsv:
PUT /api/users/{id}/roles
{"roles": ["admin"]}
No hay FgaTupleWriter.grant(...), no hay store que sincronizar. Los roles se proyectan como
principal.roles en el siguiente check().
Test contra Cedar (en proceso, sin container)
CedarAuthorizationServiceTest ejercita el motor en proceso — sin Testcontainers, sin servidor. El esquema
y las políticas del classpath se cargan solos; el CedarEntityResolver necesita los repos de core-auth
(user/organization), que en un test unitario se mockean. Para un test de integración con base real, el motor
arranca solo con la autoconfiguración (perfil Cedar por defecto).
Test contra OpenFGA real (motor legacy)
CoreAuthzIntegrationTest corre contra la imagen oficial vía Testcontainers, con
echotechs.authz.engine=openfga, 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:
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);
}
OpenFGA (legado, opt-in)
Esta sección aplica sólo si configurás echotechs.authz.engine=openfga. Es el motor que usa reference-app
hoy; los proyectos nuevos deberían usar Cedar.
FgaService
public class FgaService {
public boolean check(String user, String relation, String object);
public List<String> listObjects(String user, String relation, String objectType);
}
check() responde "¿este usuario puede con este objeto?". listObjects() responde "¿qué objetos de este tipo
puede ver/editar este usuario?" en una sola llamada a OpenFGA (ListObjects) — usalo para cargar sólo las
filas autorizadas en vez de filtrar página a página con check(). No pagina: el SDK devuelve la lista
completa en una llamada, sin token de continuación.
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 bajo OpenFGA. Un check() sin las tuplas
correspondientes devuelve siempre false. Bajo Cedar no existe este paso — el organizationId del
recurso basta.
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.
FgaAuthorizationModelPublisher
public class FgaAuthorizationModelPublisher {
public String publish(String dsl); // → id del modelo nuevo
public String publishBaseModel();
public String readBaseModelDsl();
}
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. Hoy sólo lo llaman los tests y el FgaBootstrapRunner (@Profile("fga-bootstrap")) de reference-app.
Modelo base — classpath:openfga/base-authorization-model.fga
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
adminimplicamember(por elor admin), así que un admin puede ver y editar.- Un
memberpuedecan_viewpero nocan_edit. - Los
service_accountpueden ser miembros pero nuncaadmin. - El acceso a un
resourcese deriva enteramente de qué organización lo posee (owner_organization).
Tu proyecto extiende esto con sus propios tipos siguiendo el mismo patrón (owner_organization +
can_view/can_edit delegados). Los tests del modelo sin Java viven en
base-authorization-model.test.fga.yaml (corren en CI vía .github/workflows/fga-model-test.yml cuando
cambia un .fga/.fga.yaml).
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.
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. Un 403 significa "OpenFGA respondió que no"; un 500, "no se
pudo preguntar".
Errores comunes
Un check() devuelve false y nadie tiene acceso (Cedar).
Casi siempre falta el CedarResourceResolver para ese tipo de recurso — el resolvedor no encuentra cómo
construir la entidad y lanza CedarEntityNotFoundException, que se traduce en DENY. Verificá que tu resolvedor
soporte el type y que el recurso tenga su organizationId. También que el principal tenga el rol
necesario en rolesCsv (asignado con PUT /api/users/{id}/roles) y que la organización esté activa.
Un check() devuelve false y nadie tiene acceso (OpenFGA).
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.
IllegalStateException: No Cedar policies configured.
No se encontró ninguna política. Con los defaults esto no pasa (las del classpath siempre cargan); si
seteaste policy-locations a algo que no existe o apagaste database-policies-enabled y vaciaste la base,
queda sin políticas.
Un 500 en vez de un 403 (OpenFGA).
OpenFgaOperationException (timeout, OpenFGA caído) no es ApiException, así que cae en el fallback
genérico → 500. Bajo Cedar un error del motor se envuelve en IllegalStateException (también 500): "no se
pudo evaluar", distinto de un DENY limpio.
La app no arranca porque falta un bean FgaTupleWriter/FgaService.
Bajo Cedar (default) los beans OpenFGA no se crean. Si tu código inyecta FgaTupleWriter/FgaService como
dependencia dura, el contexto falla — es lo que le pasa a reference-app si lo corrés sin
echotechs.authz.engine=openfga. Migrá el consumo a PermissionDecisionService (agnóstico) o activá el
motor OpenFGA.
Notas de implementación
Cedar corre en proceso; OpenFGA es un servidor. No hay un "servidor Cedar": el esquema y las políticas
son recursos del classpath + filas de tu base, cargados en un Snapshot cacheado en CedarPolicyStore. El
motor (BasicAuthorizationEngine) vive en el mismo JVM. OpenFGA, en cambio, es un proceso aparte al que se
alcanza por HTTP.
Los roles se proyectan, no se sincronizan. Bajo Cedar, los roles viven en UserAccount.rolesCsv
(core-auth) y se proyectan como principal.roles en cada check(). RoleAssignmentService sólo escribe
esa columna; no hay store de tuplas que mantener sincronizado (a diferencia de FgaTupleWriter bajo
OpenFGA). El FgaTupleWriter.grant(...) que todavía llama reference-app al crear un recurso es legacy e
inerte bajo Cedar.
listObjects es enumerate-and-check. Cedar no expone partial evaluation acá: se enumeran hasta 500
candidatos (CedarResourceEnumerator o los catálogos nativos) y se filtra con check(). Para catálogos
muy grandes es una limitación.
Dos capas de guardia. PlatformAdminGuard (core-authz, vía platform:admin del motor) y
OrgAdminService (core-auth, DB-side, rolesCsv). La asignación de roles usa el org-admin de base porque
core-auth no puede alcanzar el motor (la dependencia va de core-authz→core-auth, no al revés).
Todos los identificadores del modelo están en inglés. El spec muestra el modelo en español
(organizacion, miembro, organizacion_dueña). El código usa organization, member, resource,
can_view, can_edit. Dos motivos: el DSL de OpenFGA es ASCII-only y organizacion_dueña 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 con dos atributos. La
anotación real exige además objectId, una expresión SpEL. Sin él no compila.
El publicador del modelo OpenFGA no se ejecuta automáticamente. No hay tarea/endpoint/comando en este
repo que publique el modelo en un entorno real — sólo lo llaman los tests y el FgaBootstrapRunner
(@Profile("fga-bootstrap")) de reference-app.