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 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=openfga para 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​

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), 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.

Por qué el SDK crudo de OpenFGA 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, 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:

application.yml
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
PropiedadDefaultNotas
echotechs.authz.cedar.cache-ttl10sTTL del snapshot de esquema + políticas en CedarPolicyStore. Se recarga bajo demanda al expirar o tras un evict()
echotechs.authz.cedar.schema-locationclasspath:cedar/core-schema.cedarschemaUbicació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-enabledtrueSi leer las políticas administrables de la base además de las del classpath
echotechs.authz.cedar.roles3 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.

RolResumen
platform_adminAdministra la plataforma: platform:admin, authz:model:view, operate_as sobre cualquier organización activa, admin/can_view/can_edit sobre la organización activa
adminauthz:model:view, operate_as/admin/can_view/can_edit sobre su organización
analystoperate_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)​

application.yml
echotechs:
authz:
engine: openfga
openfga:
api-url: http://openfga:8080
store-id: 01HXXXXXXXXXXXXXXXXXXXXXXX
authorization-model-id: 01HYYYYYYYYYYYYYYYYYYYYYYY # opcional
PropiedadDefaultNotas
echotechs.authz.enginecedar (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-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 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.

Admin UI comunitario — infraestructura, no código

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.

Servicio nuevo en el compose/stack de Coolify — no reemplaza nada existente
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. El organizationId del 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 su organizationId y su active.
  • can_edit declara un context.readOnly opcional — la política base lo usa para denegar ediciones en contexto de sólo lectura.
  • platform:admin y authz:model:view se deciden sobre Platform::"global" (un singleton).
  • Los ServiceAccount/ApiKey pueden can_view/can_edit recursos pero no platform:admin, authz:model:view, operate_as ni admin (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_edit en contexto readOnly. Un forbid siempre le gana a un permit en Cedar.
  • operate_as permite operar como tu propia org (homeOrg) o, si sos platform_admin, como cualquier org activa.
  • admin/can_view/can_edit sobre un recurso se dan cuando el recurso cuelga de tu activeOrg (igualdad o membership en la jerarquía) y tenés el rol correspondiente.
  • activeOrg viene del TenantContext (override de organización activa) o, si no hay override, de tu homeOrg. Así es como un platform_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
  • CedarPolicyStore mantiene un Snapshot cacheado (cache-ttl, default 10s) con el esquema, las políticas del classpath, las políticas habilitadas de la base, los attachments y el PolicySet global. Toda mutación de admin llama evict() para que el siguiente check() recargue. Si no hay ninguna política configurada, arrancar/evaluar tira IllegalStateException — siempre hay, como mínimo, las del classpath.
  • CedarEntityResolver arma las entidades Cedar a partir del CorePrincipal y la base. Si la cuenta o la organización no existen, lanza CedarEntityNotFoundException, que el servicio traduce en un DENY (no un 500): un principal o recurso inexistente simplemente no tiene acceso.
  • CedarNames mapea tipos externos a tipos de entidad Cedar: el principal a User/ServiceAccount/ ApiKey según PrincipalType; platform→Platform, organization→Organization; cualquier otro tipo de recurso se PascalCasea (purchase_request→PurchaseRequest). Las acciones son Action::"<action>".
  • listObjects no usa partial evaluation de Cedar: enumera hasta 500 candidatos vía CedarResourceEnumerator (o los catálogos nativos de platform/organization) y filtra con check(). 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:

  1. Toma el principal de TenantContext.getCurrentPrincipal(). Si es null, tira AccessDeniedException sin llamar al motor.
  2. Evalúa objectId. Si resuelve a null, tira IllegalStateException.
  3. Llama permissionDecisionService.check(principal, relation, objectType, objectId, Map.of()). Si devuelve false, tira AccessDeniedException → que GlobalExceptionHandler traduce a 403 ACCESS_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.

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.

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étodoPathBody / ParamsRetornaGate
GET/api/authz/policies—{ configured, database } (políticas del classpath + de la base, con sus attachments)model viewer
POST/api/authz/policiesSavePolicyRequestPolicyResponsepolicy admin
PUT/api/authz/policies/{id}SavePolicyRequestPolicyResponse (con attachments)policy admin
DELETE/api/authz/policies/{id}—204policy admin
POST/api/authz/policies/validate{ body }{ valid, status }model viewer
POST/api/authz/policies/{id}/attachmentsSaveAttachmentRequestAttachmentResponsepolicy admin
DELETE/api/authz/policy-attachments/{id}—204policy admin
GET/api/authz/resources?type=&limit=50typeList<{ id, label }>model viewer
GET/api/authz/policy-templates—3 plantillas (role-allow, explicit-deny, abac-threshold)model viewer
POST/api/authz/simulateSimulateRequestSimulationResponsemodel 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étodoPathBodyRetorna
POST/api/organizations{ name, slug }OrganizationResponse
GET/api/organizationsPageablePage<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):

ColumnaTipoNotas
idCHAR(36) PK
nameVARCHAR(160), único, not null
effectVARCHAR(16), not nullpermit/forbid/mixed
descriptionVARCHAR(1024)
bodyVARCHAR(1048576), not nullel cuerpo Cedar
policy_typeVARCHAR(32), default CUSTOMER_MANAGEDCUSTOMER_MANAGED o INLINE
policy_scopeVARCHAR(32), default GLOBALGLOBAL o ATTACHED
organization_idCHAR(36)
enabledBOOLEAN, default true
created_at / updated_atTIMESTAMP

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();
}
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. 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
  • 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 pero nunca admin.
  • El acceso a un resource se 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).

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.

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.