Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.context.properties.PropertyMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.ResourceLoader;

/**
* Configures an {@code openFgaClient} and {@code openFga} beans based
Expand Down Expand Up @@ -200,6 +201,30 @@ public OpenFgaExceptionHandler openFgaExceptionHandler() {
return new OpenFgaExceptionHandler();
}

/**
* Creates an {@link OpenFgaInitializer} that writes the configured authorization model and
* tuples into OpenFGA at startup. Only created when {@code openfga.initialization.mode} is
* set to {@code embedded}.
*
* @param openFgaClient the {@link OpenFgaClient} bean
* @param openFgaProperties the configuration properties for OpenFGA
* @param objectMapperProvider provides the {@link ObjectMapper} bean
* @param resourceLoader the {@link ResourceLoader} used to resolve the configured locations
* @return the {@link OpenFgaInitializer} bean
*/
@Bean
@ConditionalOnProperty(prefix = "openfga.initialization", name = "mode", havingValue = "embedded")
@ConditionalOnMissingBean
public OpenFgaInitializer openFgaInitializer(
OpenFgaClient openFgaClient,
OpenFgaProperties openFgaProperties,
ObjectProvider<ObjectMapper> objectMapperProvider,
ResourceLoader resourceLoader) {
var objectMapper = objectMapperProvider.getIfAvailable(OpenFgaAutoConfiguration::createDefaultObjectMapper);
return new OpenFgaInitializer(
openFgaClient, openFgaProperties.getInitialization(), resourceLoader, objectMapper);
}

/**
* Creates an {@link OpenFga} bean if no other bean of this type is present.
*
Expand Down
108 changes: 108 additions & 0 deletions src/main/java/dev/openfga/autoconfigure/OpenFgaInitializer.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package dev.openfga.autoconfigure;

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.openfga.sdk.api.client.OpenFgaClient;
import dev.openfga.sdk.api.client.model.ClientWriteRequest;
import dev.openfga.sdk.api.configuration.ClientWriteOptions;
import dev.openfga.sdk.api.model.WriteAuthorizationModelRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.StringUtils;

/**
* Writes an initial authorization model, and optionally a set of initial tuples, into OpenFGA at
* application startup. This is the OpenFGA analogue of Spring Boot's {@code schema.sql}/{@code data.sql}
* database initialization.
*
* <p>The initializer is idempotent: if the configured store already has an authorization model, it
* does nothing. Otherwise it writes the model from {@code openfga.initialization.model-location} and,
* if configured, the tuples from {@code openfga.initialization.tuples-location}. Any failure is
* propagated so that the application fails fast.
*/
public class OpenFgaInitializer implements ApplicationRunner {

private static final Logger logger = LoggerFactory.getLogger(OpenFgaInitializer.class);

private final OpenFgaClient fgaClient;
private final OpenFgaProperties.Initialization initialization;
private final ResourceLoader resourceLoader;
private final ObjectMapper objectMapper;

/**
* Create a new initializer.
*
* @param fgaClient the {@link OpenFgaClient} to write the model and tuples with
* @param initialization the initialization properties
* @param resourceLoader the {@link ResourceLoader} used to resolve the configured locations
* @param objectMapper the {@link ObjectMapper} used to deserialize the model and tuples
*/
public OpenFgaInitializer(
OpenFgaClient fgaClient,
OpenFgaProperties.Initialization initialization,
ResourceLoader resourceLoader,
ObjectMapper objectMapper) {
this.fgaClient = fgaClient;
this.initialization = initialization;
this.resourceLoader = resourceLoader;
this.objectMapper = objectMapper;
}

@Override
public void run(ApplicationArguments args) throws Exception {
if (authorizationModelExists()) {
Comment thread
Adrastopoulos marked this conversation as resolved.
logger.info("OpenFGA store already has an authorization model; skipping initialization");
return;
}
String authorizationModelId = writeModel();
writeTuples(authorizationModelId);
}

private boolean authorizationModelExists() {
try {
return fgaClient.readLatestAuthorizationModel().get().getAuthorizationModel() != null;
} catch (Exception e) {
logger.debug("Unable to read latest authorization model; assuming none exists", e);
return false;
}
}
Comment thread
Adrastopoulos marked this conversation as resolved.
Outdated
Comment on lines +64 to +66

private String writeModel() throws Exception {
Resource resource = resourceLoader.getResource(initialization.getModelLocation());
if (!resource.exists()) {
throw new IllegalStateException(
"OpenFGA authorization model location does not exist: " + initialization.getModelLocation());
}
var request = objectMapper.readValue(resource.getContentAsByteArray(), WriteAuthorizationModelRequest.class);
String authorizationModelId =
fgaClient.writeAuthorizationModel(request).get().getAuthorizationModelId();
Comment on lines +75 to +76
logger.info(
"Wrote OpenFGA authorization model {} from {}",
authorizationModelId,
initialization.getModelLocation());
return authorizationModelId;
}

private void writeTuples(String authorizationModelId) throws Exception {
if (!StringUtils.hasText(initialization.getTuplesLocation())) {
return;
}
Resource resource = resourceLoader.getResource(initialization.getTuplesLocation());
if (!resource.exists()) {
throw new IllegalStateException(
"OpenFGA initial tuples location does not exist: " + initialization.getTuplesLocation());
}
var request = objectMapper.readValue(resource.getContentAsByteArray(), ClientWriteRequest.class);
fgaClient
.write(
request,
new ClientWriteOptions()
.authorizationModelId(authorizationModelId)
.disableTransactions(true))
.get();
Comment on lines +94 to +100
logger.info("Wrote initial OpenFGA tuples from {}", initialization.getTuplesLocation());
}
}
127 changes: 127 additions & 0 deletions src/main/java/dev/openfga/autoconfigure/OpenFgaProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public class OpenFgaProperties implements InitializingBean {
*/
private Map<TelemetryMetric, Map<TelemetryAttribute, Object>> telemetryConfiguration;

/**
* Configuration for importing an initial authorization model and tuples at startup.
*/
private Initialization initialization = new Initialization();

/**
* Gets the URL to the OpenFGA instance.
*
Expand Down Expand Up @@ -295,6 +300,24 @@ public void setTelemetryConfiguration(
this.telemetryConfiguration = telemetryConfiguration;
}

/**
* Gets the initialization configuration.
*
* @return the initialization configuration
*/
public Initialization getInitialization() {
return initialization;
}

/**
* Sets the initialization configuration.
*
* @param initialization the initialization configuration to set
*/
public void setInitialization(Initialization initialization) {
this.initialization = initialization;
}

@Override
public void afterPropertiesSet() {
validate();
Expand Down Expand Up @@ -341,6 +364,12 @@ public void validate() {
throw new IllegalStateException("minimumRetryDelay must be set if maxRetries is set");
}
}
if (initialization != null
&& initialization.getMode() == InitializationMode.EMBEDDED
&& !StringUtils.hasText(initialization.getModelLocation())) {
throw new IllegalStateException(
"initialization.model-location must be set when initialization.mode is 'EMBEDDED'");
}
Comment thread
Adrastopoulos marked this conversation as resolved.
Comment on lines +367 to +372
}

private static void assertPositivity(Duration duration, String fieldName) {
Expand All @@ -349,6 +378,104 @@ private static void assertPositivity(Duration duration, String fieldName) {
}
}

/**
* Properties controlling whether an initial authorization model and tuples are
* written to OpenFGA at application startup, analogous to Spring Boot's
* {@code schema.sql}/{@code data.sql} database initialization.
*/
public static class Initialization {

/**
* Whether to perform OpenFGA initialization at startup. Defaults to {@link InitializationMode#NEVER}.
*/
private InitializationMode mode = InitializationMode.NEVER;

/**
* Location of the authorization model to write, as a Spring resource path
* (e.g. {@code classpath:fga/model.json}). The resource must contain a JSON
* {@code WriteAuthorizationModelRequest}. Required when {@link #mode} is {@code EMBEDDED}.
*/
private String modelLocation;

/**
* Optional location of the initial tuples to write, as a Spring resource path
* (e.g. {@code classpath:fga/tuples.json}). The resource must contain a JSON
* {@code ClientWriteRequest}.
*/
private String tuplesLocation;

/**
* Gets the initialization mode.
*
* @return the initialization mode
*/
public InitializationMode getMode() {
return mode;
}

/**
* Sets the initialization mode.
*
* @param mode the initialization mode to set
*/
public void setMode(InitializationMode mode) {
this.mode = mode;
}

/**
* Gets the authorization model resource location.
*
* @return the model location
*/
public String getModelLocation() {
return modelLocation;
}

/**
* Sets the authorization model resource location.
*
* @param modelLocation the model location to set
*/
public void setModelLocation(String modelLocation) {
this.modelLocation = modelLocation;
}

/**
* Gets the initial tuples resource location.
*
* @return the tuples location
*/
public String getTuplesLocation() {
return tuplesLocation;
}

/**
* Sets the initial tuples resource location.
*
* @param tuplesLocation the tuples location to set
*/
public void setTuplesLocation(String tuplesLocation) {
this.tuplesLocation = tuplesLocation;
}
}

/**
* Modes controlling OpenFGA startup initialization.
*/
public enum InitializationMode {

/**
* Do not perform any initialization.
*/
NEVER,

/**
* Write the configured authorization model and tuples at startup if no
* authorization model already exists in the store.
*/
EMBEDDED
}

/**
* {@link dev.openfga.sdk.api.client.OpenFgaClient} credentials properties
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,61 @@ void failsIfMaxRetriesIsNegative() {
assertThat(exception.getCause().getMessage(), containsString("maxRetries must be positive or zero"));
}

@Test
void initializerBeanNotCreatedByDefault() {
contextRunner
.withPropertyValues("openfga.api-url=https://api.fga.example", "openfga.store-id=store ID")
.withConfiguration(AutoConfigurations.of(OpenFgaAutoConfiguration.class))
.run(context -> assertThat(context.containsBean("openFgaInitializer"), is(false)));
}

@Test
void initializerBeanNotCreatedWhenModeNever() {
contextRunner
.withPropertyValues(
"openfga.api-url=https://api.fga.example",
"openfga.store-id=store ID",
"openfga.initialization.mode=never",
"openfga.initialization.model-location=classpath:fga/model.json")
.withConfiguration(AutoConfigurations.of(OpenFgaAutoConfiguration.class))
.run(context -> assertThat(context.containsBean("openFgaInitializer"), is(false)));
}

@Test
void initializerBeanCreatedWhenModeEmbedded() {
contextRunner
.withPropertyValues(
"openfga.api-url=https://api.fga.example",
"openfga.store-id=store ID",
"openfga.initialization.mode=embedded",
"openfga.initialization.model-location=classpath:fga/model.json",
"openfga.initialization.tuples-location=classpath:fga/tuples.json")
.withConfiguration(AutoConfigurations.of(OpenFgaAutoConfiguration.class))
.run(context -> {
assertThat(context.containsBean("openFgaInitializer"), is(true));
OpenFgaProperties properties = context.getBean(OpenFgaProperties.class);
OpenFgaProperties.Initialization initialization = properties.getInitialization();
assertThat(initialization.getMode(), is(OpenFgaProperties.InitializationMode.EMBEDDED));
assertThat(initialization.getModelLocation(), is("classpath:fga/model.json"));
assertThat(initialization.getTuplesLocation(), is("classpath:fga/tuples.json"));
});
}

@Test
void failsIfModeEmbeddedButNoModelLocation() {
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> contextRunner
.withPropertyValues(
"openfga.api-url=https://api.fga.example",
"openfga.store-id=store ID",
"openfga.initialization.mode=embedded")
.withConfiguration(AutoConfigurations.of(OpenFgaAutoConfiguration.class))
.run(context -> context.getBean("openFgaInitializer")));

assertThat(
exception.getCause().getMessage(),
containsString("initialization.model-location must be set when initialization.mode is 'EMBEDDED'"));
}

@Test
void failsIfMaxRetriesIsPositiveButMinimumRetryDelayIsNotSet() {
IllegalStateException exception = assertThrows(IllegalStateException.class, () -> contextRunner
Expand Down
27 changes: 27 additions & 0 deletions src/test/resources/fga/model.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"schema_version": "1.1",
"type_definitions": [
{
"type": "user"
},
{
"type": "document",
"relations": {
"reader": {
"this": {}
}
},
"metadata": {
"relations": {
"reader": {
"directly_related_user_types": [
{
"type": "user"
}
]
}
}
}
}
]
}
Loading