close icon
ReBAC

OpenFGA for Spring Boot Applications

How to add Fine-Grained Authorization (FGA) to a Spring Boot API using the OpenFGA Spring Boot starter.

June 13, 2024

Fine-grained authorization (FGA) refers to the capability of granting individual users permission to perform particular actions on specific resources. Effective FGA systems enable the management of permissions for a large number of objects and users. These permissions can undergo frequent changes as the system dynamically adds objects and adjusts access permissions for its users.

OpenFGA is an open-source Relationship Based Access Control (ReBAC) system designed by Okta for developers, and adopted by the Cloud Native Computing Foundation (CNCF). It offers scalability and flexibility, and it also supports the implementation of RBAC and ABAC authorization models, moving authorization logic outside application code, making it simpler to evolve authorization policies as complexity grows. In this guide, you will learn how to secure a Spring Boot document API with Auth0 and integrate Fine-Grained Authorization (FGA) into the document operations with OpenFGA.

logo.png

This tutorial was created with the following tools and services:

Add security with Auth0 and the Okta Spring Boot starter

Before working on the authorization (giving access to resources), you must define the mechanism for identifying the user. Auth0 is an easy-to-implement, adaptable authentication and authorization platform that allows you to implement authentication for any application in just minutes. With Auth0 CLI, you can create access tokens and use them to identify the user when making requests to the document API. Sign up at Auth0 and install the Auth0 CLI. Then, in the command line, run:

auth0 login

The command output will display a device confirmation code and open a browser session to activate the device.

You don't need to create a client application at Auth0 for your API if you are not using opaque tokens. But you must register the API within your tenant; you can do it using Auth0 CLI:

auth0 apis create \
  --name "Document API" \
  --identifier https://document-api.okta.com \
  --offline-access=false

Leave scopes empty and default values when prompted.

Checkout the document API repository, which already implements basic request handling:

git clone https://github.com/indiepopart/spring-api-fga.git

The repository contains two project folders, start and final. The bare bones document API is a Gradle project in the start folder; open it with your favorite IDE. Add the okta-spring-boot-starter dependency:

// build.gradle
dependencies {
    ...
    implementation 'com.okta.spring:okta-spring-boot-starter:3.0.6'
    ...
}

As the document-api must be configured as an OAuth2 resource server, add the following properties to application.yml

# application.yml
okta:
  oauth2:
    issuer: https://<your-auth0-domain>/
    audience: https://document-api.okta.com

You can find your Auth0 domain with the following Auth0 CLI command:

auth0 tenants list

Run the API with:

./gradlew bootRun

Test the API authorization with curl:

curl -i localhost:8080/document

You will get HTTP response code 401 because the request requires bearer authentication. Using Auth0 CLI, get an access token:

auth0 test token -a https://document-api.okta.com -s openid

Select any available client when prompted. You also will be prompted to open a browser window and log in with a user credential. You can sign up as a new user using an email and password or using the Google social login.

With curl, send a request to the API server using a bearer access token:

ACCESS_TOKEN=<auth0-access-token> && \
  curl -i --header "Authorization: Bearer $ACCESS_TOKEN" localhost:8080/document

You should get a JSON response listing the available documents:

[
   {
      "id":1,
      "parentId":null,
      "ownerId":"test-user",
      "name":"planning-v0",
      "description":"Planning doc",
      "createdTime":"2024-05-03T11:48:35.617694826",
      "modifiedTime":"2024-05-03T11:48:35.617694826",
      "quotaBytesUsed":null,
      "version":null,
      "originalFilename":null,
      "fileExtension":".doc"
   },
   {
      "id":2,
      "parentId":null,
      "ownerId":"test-user",
      "name":"image-1",
      "description":"Some image",
      "createdTime":"2024-05-03T11:48:35.617694826",
      "modifiedTime":"2024-05-03T11:48:35.617694826",
      "quotaBytesUsed":null,
      "version":null,
      "originalFilename":null,
      "fileExtension":".jpg"
   },
   {
      "id":3,
      "parentId":null,
      "ownerId":"test-user",
      "name":"meeting-notes",
      "description":"Some text file",
      "createdTime":"2024-05-03T11:48:35.617694826",
      "modifiedTime":"2024-05-03T11:48:35.617694826",
      "quotaBytesUsed":null,
      "version":null,
      "originalFilename":null,
      "fileExtension":".txt"
   }
]

Define an Authorization Model

At a high level, an authorization model is defined by indicating user types, object types, and relationships between them. As we are not going to deep-dive on ReBAC in this guide, you can refer to OpenFGA documentation for learning about modeling concepts. Under the Advanced use-cases section in the doc, there is a simplified authorization model for a Google Drive application ready to test:

model
  schema 1.1

type user

type document
  relations
    define owner: [user, domain#member] or owner from parent
    define writer: [user, domain#member] or owner or writer from parent
    define commenter: [user, domain#member] or writer or commenter from parent
    define viewer: [user, user:*, domain#member] or commenter or viewer from parent
    define parent: [document]

type domain
  relations
    define member: [user]

The graph below is a model preview generated by the OpenFGA playground where you can see the direct and implied relations of the model.

fga-model.png

The OpenFGA authorization model is expressed in a configuration language that can be presented in DSL or JSON syntax. The API accepts the JSON syntax. The DSL adds syntactic sugar on top of JSON for ease of use and can be transformed to JSON using the FGA CLI. The syntax above is the document authorization model in DSL. Copy the model DSL from above to the file src/main/resources/fga/auth-model.fga file, install the FGA CLI, and transform the model to JSON:

fga model transform --file=auth-model.fga > auth-model.json

The transformed model should be like the JSON below:

// auth-model.json
{
   "schema_version":"1.1",
   "type_definitions":[
      {
         "type":"user"
      },
      {
         "metadata":{...},
         "relations":{...},
         "type":"document"
      },
      {
         "metadata":{...},
         "relations":{...},
         "type":"domain"
      }
   ]
}

Place the file at src/main/resources/fga/auth-model.json as it will be required later.

Add Fine-Grained Authorization (FGA) with OpenFGA

Now, let's integrate fine-grained authorization into the application. The OpenFGA team has just released the OpenFGA Spring Boot Starter 0.0.1, and you can add it to your project with the following dependency:

implementation 'dev.openfga:openfga-spring-boot-starter:0.0.1'

The OpenFGA Spring Boot Starter dependency provides the auto-configuration of an FGA client and FGA bean that exposes a check method for authorizing operations. Before adding method-security, let's add the changes required to create the owner tuple when the document is created. Create an AuthorizationService in the package com.example.demo.service:

// src/main/java/com/example/demo/service/AuthorizationService.java
package com.example.demo.service;

import com.example.demo.model.Permission;
import dev.openfga.sdk.api.client.OpenFgaClient;
import dev.openfga.sdk.api.client.model.ClientTupleKey;
import dev.openfga.sdk.api.client.model.ClientWriteResponse;
import dev.openfga.sdk.errors.FgaInvalidParameterException;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.concurrent.ExecutionException;

@Service
public class AuthorizationService {

    private OpenFgaClient fgaClient;

    public AuthorizationService(OpenFgaClient fgaClient) {
        this.fgaClient = fgaClient;
    }

    public void create(Permission permission) {
        try {
            ClientTupleKey tuple = new ClientTupleKey()
                    .user("user:" + permission.getUserId())
                    .relation(permission.getRelation())
                    ._object("document:" + permission.getDocumentId());
            ClientWriteResponse response = fgaClient.writeTuples(List.of(tuple)).get();
        } catch (FgaInvalidParameterException | InterruptedException | ExecutionException e) {
            throw new AuthorizationServiceException("Unexpected error", e);
        }
    }
}

Add the exception class in the package com.example.demo.service:

// src/main/java/com/example/demo/service/AuthorizationServiceException.java
package com.example.demo.service;

public class AuthorizationServiceException extends RuntimeException {
    public AuthorizationServiceException(Exception e) {
        super(e);
    }

    public AuthorizationServiceException(String message, Exception e) {
        super(message, e);
    }
}

Update the DocumentService.save() method for the required dual write (database and OpenFGA server) when creating a Document:

// src/main/java/com/example/demo/service/DocumentService.java
package com.example.demo.service;

import com.example.demo.model.Document;
import com.example.demo.model.DocumentRepository;
import com.example.demo.model.Permission;
import com.example.demo.model.PermissionBuilder;
import jakarta.transaction.Transactional;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.parameters.P;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class DocumentService {

    private DocumentRepository documentRepository;

    private AuthorizationService authorizationService;

    public DocumentService(DocumentRepository documentRepository, AuthorizationService authorizationService) {
        this.documentRepository = documentRepository;
        this.authorizationService = authorizationService;
    }

    @Transactional
    public Document save(@P("document") Document document) {
        try {
            Document result = documentRepository.save(document);
            Permission permission = new PermissionBuilder()
                    .withDocumentId(result.getId())
                    .withRelation("owner")
                    .withUserId(result.getOwnerId())
                    .build();
            authorizationService.create(permission);
            return result;
        } catch(Exception e){
            throw new DocumentServiceException("Unexpected error", e);
        }
    }
...
}

Annotate the DocumentApiApplication with @EnableTransactionManagement and @EnableGlobalMethodSecurity:

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.transaction.annotation.EnableTransactionManagement;

@SpringBootApplication
@EnableGlobalMethodSecurity(prePostEnabled = true)
@EnableTransactionManagement
public class DocumentApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(DocumentApiApplication.class, args);
    }

}

With the approach above, the Document will not be saved if the permission tuple cannot be created, and this prevents having an entity without permission.

Next, you need to add method-security enforcing the permissions policy. With @PreAuthorize and @fga.check, you can express the permissions required for each operation. For example, the save operation can be guarded as follows:

@PreAuthorize("#document.parentId == null or @fga.check('document', #document.parentId, 'writer', 'user')")
public Document save(@P("document") Document document)

The expression will produce a call to OpenFGA that will check if the authenticated user is a writer of the parent document when the parentId is not null. Assuming the parent is a folder, the document can be created in the folder if the user is a writer (has write permission) for that folder. The complete method-security can be expressed as follows:

// src/main/java/com/example/demo/service/DocumentService.java
package com.example.demo.service;

import com.example.demo.model.Document;
import com.example.demo.model.DocumentRepository;
import com.example.demo.model.Permission;
import com.example.demo.model.PermissionBuilder;
import jakarta.transaction.Transactional;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.parameters.P;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class DocumentService {

    private DocumentRepository documentRepository;

    private AuthorizationService authorizationService;

    public DocumentService(DocumentRepository documentRepository, AuthorizationService authorizationService) {
        this.documentRepository = documentRepository;
        this.authorizationService = authorizationService;
    }

    @Transactional
    @PreAuthorize("#document.parentId == null or @fga.check('document', #document.parentId, 'writer', 'user')")
    public Document save(@P("document") Document document) {
        try {
            Document result = documentRepository.save(document);
            Permission permission = new PermissionBuilder()
                    .withDocumentId(result.getId())
                    .withRelation("owner")
                    .withUserId(result.getOwnerId())
                    .build();
            authorizationService.create(permission);
            return result;
        } catch(Exception e){
            throw new DocumentServiceException("Unexpected error", e);
        }
    }

    @PreAuthorize("@fga.check('document', #id, 'viewer', 'user')")
    public Optional<Document> findById(@P("id") Long id) {
        return documentRepository.findById(id);
    }

    @PreAuthorize("@fga.check('document', #id, 'owner', 'user')")
    public void deleteById(@P("id") Long id) {
        documentRepository.deleteById(id);
    }

    @PreAuthorize("@fga.check('document', #document.id, 'writer', 'user')")
    public Document update(@P("document") Document document){
        return documentRepository.save(document);
    }

    public List<Document> findAll() {
        return documentRepository.findAll();
    }
}

NOTE: In this guide, OpenFGA tuples are not deleted when the document is deleted

In the previous section, you created an authorization model and converted it to JSON format. This will allow you to initialize the OpenFGA server in development with that model. Add the utility component OpenFGAUtil in the com.example.demo.initializer package:

// src/main/java/com/example/demo/initializer/OpenFGAUtil.java
package com.example.demo.initializer;

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.openfga.sdk.api.model.AuthorizationModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;

import java.io.IOException;
import java.nio.charset.Charset;

@Component
public class OpenFGAUtil {

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

    private ResourceLoader resourceLoader;

    private ObjectMapper objectMapper;

    public OpenFGAUtil(ResourceLoader resourceLoader, ObjectMapper objectMapper) {
        this.resourceLoader = resourceLoader;
        this.objectMapper = objectMapper;
    }

    public AuthorizationModel convertJsonToAuthorizationModel(String path){
        try {
            Resource resource = resourceLoader.getResource(path);
            String json = StreamUtils.copyToString(resource.getInputStream(), Charset.defaultCharset());
            logger.debug(json);
            AuthorizationModel authorizationModel = objectMapper.readValue(json, AuthorizationModel.class);
            logger.debug(authorizationModel.toString());
            return authorizationModel;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

}

The class OpenFGAUtil encapsulates the task of loading the JSON model into an AuthorizationModel object to build the write model request. Create the class OpenFGAInitializer with the following code:

// src/main/java/com/example/demo/initializer/OpenFGAInitializer.java
package com.example.demo.initializer;

import dev.openfga.sdk.api.client.OpenFgaClient;
import dev.openfga.sdk.api.model.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;

import java.util.concurrent.ExecutionException;

@Component
@ConditionalOnProperty(prefix = "openfga", name = "initialize", havingValue = "true")
public class OpenFGAInitializer implements CommandLineRunner {

    private Logger logger = LoggerFactory.getLogger(OpenFGAInitializer.class);

    private OpenFgaClient fgaClient;

    private OpenFGAUtil openFgaUtil;


    public OpenFGAInitializer(OpenFgaClient fgaClient, OpenFGAUtil openFgaUtil) {
        this.fgaClient = fgaClient;
        this.openFgaUtil = openFgaUtil;
    }

    @Override
    public void run(String... args) throws Exception {
        CreateStoreRequest storeRequest = new CreateStoreRequest().name("test");
        try {
            CreateStoreResponse storeResponse = fgaClient.createStore(storeRequest).get();
            logger.info("Created store: {}", storeResponse);
            fgaClient.setStoreId(storeResponse.getId());

            WriteAuthorizationModelRequest modelRequest = new WriteAuthorizationModelRequest();
            AuthorizationModel model = openFgaUtil.convertJsonToAuthorizationModel("classpath:fga/auth-model.json");
            modelRequest.setTypeDefinitions(model.getTypeDefinitions());
            modelRequest.setConditions(model.getConditions());
            modelRequest.setSchemaVersion(model.getSchemaVersion());
            WriteAuthorizationModelResponse modelResponse = fgaClient.writeAuthorizationModel(modelRequest).get();
            logger.info("Created model: {}", modelResponse);
            fgaClient.setAuthorizationModelId(modelResponse.getAuthorizationModelId());

        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException("Error writing to FGA", e);
        }
    }
}

Before running the API, add some integration tests to verify the method's security. Add the following Testcontainers dependencies:

developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
testImplementation "org.testcontainers:testcontainers:1.19.7"
testImplementation "org.testcontainers:openfga:1.19.7"
testImplementation "org.testcontainers:junit-jupiter:1.19.7"
testImplementation 'org.springframework.security:spring-security-test'

Then create the DocumentIntegrationTest class with the following code:

// src/main/java/com/example/demo/DocumentIntegrationTest.java
package com.example.demo;

import com.example.demo.model.Document;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.openfga.OpenFGAContainer;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@Testcontainers
public class DocumentIntegrationTest {

    @Container
    static OpenFGAContainer openfga = new OpenFGAContainer("openfga/openfga:v1.4.3");
    @Autowired
    private WebApplicationContext applicationContext;
    private MockMvc mockMvc;
    @Autowired
    private ObjectMapper objectMapper;

    @DynamicPropertySource
    static void registerOpenFGAProperties(DynamicPropertyRegistry registry) {
        registry.add("openfga.api-url", () -> "http://localhost:" + openfga.getMappedPort(8080));
    }

    @BeforeEach
    public void init() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(applicationContext)
                .apply(springSecurity()).build();
    }

    @Test
    @WithMockUser(username = "test-user")
    public void testCreateDocumentIsFobidden() throws Exception {

        Document document = new Document();
        document.setParentId(1L);
        document.setName("test-doc");
        document.setDescription("test-description");

        mockMvc.perform(post("/document").with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(document)))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(username = "test-user")
    public void testCreateDocument() throws Exception {

        Document document = new Document();
        document.setName("test-doc");
        document.setDescription("test-description");

        MvcResult mvcResult = mockMvc.perform(post("/document")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(document)))
                .andExpect(status().isOk()).andExpect(jsonPath("$").exists())
                .andExpect(jsonPath("$.name").value("test-doc"))
                .andExpect(jsonPath("$.description").value("test-description"))
                .andReturn();

        Document result = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Document.class);

        mockMvc.perform(get("/document/{id}", result.getId())
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").exists())
                .andExpect(jsonPath("$.name").value("test-doc"))
                .andExpect(jsonPath("$.description").value("test-description"));
    }

    @Test
    @WithMockUser(username = "test-user")
    public void testDeleteDocument_NotOwned_AccessDenied() throws Exception {
        Document document = new Document();
        document.setName("test-doc");
        document.setDescription("test-description");

        MvcResult mvcResult = mockMvc.perform(post("/document")
                        .with(csrf())
                        .with(user("owner-user"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(document)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$").exists())
                .andExpect(jsonPath("$.name").value("test-doc"))
                .andExpect(jsonPath("$.description").value("test-description"))
                .andReturn();

        Document result = objectMapper.readValue(mvcResult.getResponse().getContentAsString(), Document.class);

        mockMvc.perform(delete("/document/{id}", result.getId()).with(csrf())
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isForbidden());

    }
}

Update application.yml and add the following properties:

# src/main/resources/application.yml
openfga:
  api-url: http://localhost:8090
  store-id: 01AAAAAAAAAAAAAAAAAAAAAAAA
  authorization-model-id: 01AAAAAAAAAAAAAAAAAAAAAAAA
  initialize: true

Don't worry about the dummy store-id and authorization-model-id; in development, the initializer will create the store and authorization model and set them as default for the FGA operations.

Run the test with:

./gradlew test --tests com.example.demo.DocumentIntegrationTest

Running the Spring Boot API

Taking advantage of Spring Boot Docker, create a compose.yml file at the root of the project to start an OpenFGA server when the application runs:

# compose.yml
services:
  openfga:
    image: openfga/openfga:v1.5.3
    container_name: openfga
    command: run
    environment:
      - OPENFGA_DATASTORE_ENGINE=memory
      - OPENFGA_PLAYGROUND_ENABLED=true
    networks:
      - default
    ports:
      - "8090:8080" #http
      - "3000:3000" #playground
    healthcheck:
      test: ["CMD", "/usr/local/bin/grpc_health_probe", "-addr=openfga:8081"]
      interval: 5s
      timeout: 30s
      retries: 3

Notice the HTTP API port is mapped to host port 8090. Run the application with:

./gradlew bootRun

You should see in the application logs messages when the OpenFGA container is starting:

[Document API] [           main] .s.b.d.c.l.DockerComposeLifecycleManager : Using Docker Compose file '.../compose.yml'
[Document API] [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  Container openfga  Recreate
[Document API] [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  Container openfga  Recreated
[Document API] [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  Container openfga  Starting
[Document API] [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  Container openfga  Started
[Document API] [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  Container openfga  Waiting
[Document API] [utReader-stderr] o.s.boot.docker.compose.core.DockerCli   :  Container openfga  Healthy

Create a new test token with Auth0 CLI:

auth0 test token -a https://document-api.okta.com -s openid

With curl, send a request to the API server using a bearer access token:

TOM_ACCESS_TOKEN=<auth0-access-token> && \
  curl -i -X POST \
    -H "Authorization:Bearer $TOM_ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"name": "planning.doc"}' \
    http://localhost:8080/document

Verify the API does not authorize creating a document with a parent not owned:

curl -i -X POST \
  -H "Authorization:Bearer $TOM_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "planning.doc", "parentId": 6}' \
  http://localhost:8080/document

The API will reject with HTTP 403:

HTTP/1.1 403
...
Access is denied%

Authorizing Access

You can remove the auth0.com cookie and, with the Auth0 CLI, create a new access token for a second user. Let's suppose this second user is ANA:

auth0 test token -a https://document-api.okta.com -s openid

Then request a document owned by TOM with $ANA_ACCESS_TOKEN with the following command:

ANA_ACCESS_TOKEN=<auth0-access-token> && \
  curl -i -H "Authorization:Bearer $ANA_ACCESS_TOKEN" http://localhost:8080/document/4

Before creating the permission, the call will return HTTP code 403. Let's add a permission endpoint to grant writer access to ANA. Create a PermissionController class:

// src/main/java/com/example/demo/web/PermissionController.java
package com.example.demo.web;

import com.example.demo.model.Permission;
import com.example.demo.service.AuthorizationService;
import com.example.demo.service.AuthorizationServiceException;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.parameters.P;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class PermissionController {

    private AuthorizationService authorizationService;

    public PermissionController(AuthorizationService authorizationService) {
        this.authorizationService = authorizationService;
    }

    // Only the owner can create permissions for the document
    @PostMapping("/permission")
    @PreAuthorize("@fga.check('document', #permission.documentId, 'owner', 'user')")
    public void createPermission(@P("permission") @RequestBody Permission permission) {
        authorizationService.create(permission);
    }

    @ExceptionHandler
    public ResponseEntity<String> handle(AuthorizationServiceException ex) {
        return ResponseEntity.status(500).body(ex.getMessage());
    }
}

Restart the server. Find out ANA's userId with:

curl -i -H "Authorization:Bearer $ANA_ACCESS_TOKEN" http://localhost:8080/greeting

Then, in the command below, replace <user-id> with ANA's userId and create the writer permission using $TOM_ACCESS_TOKEN, as TOM is the document's owner:

curl -i -X POST \
  -H "Authorization:Bearer $TOM_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"documentId": 4, "relation": "writer", "userId": "<user-id>"}' \
  http://localhost:8080/permission

Repeat the GET call to the document API and verify the view operation is authorized for the document, as it is implicit in the writer relation:

curl -i -H "Authorization:Bearer $ANA_ACCESS_TOKEN" http://localhost:8080/document/4

Using Okta FGA

Okta FGA is a managed solution by Okta that uses the OpenFGA modeling language (DSL) and decision engine, and provides a set of APIs that let you check for permissions or add more data to the decision engine whenever data changes in your application so the decisions can stay up to date.

You can use the same credentials of your Auth0 account to log in to the Okta FGA dashboard at https://dashboard.fga.dev. If you don't have an Auth0 account, hop over to https://dashboard.fga.dev and create a free account. Navigate to the Model Explorer page in the dashboard, add the contents of auth-model.fga from the previous sections, and click Save.

oktafga-explorer.png

Note: Check out the Okta FGA Playground to play around with the model editor or see some example models for applications like Google Drive, GitHub, or Slack.

On the left menu, find the Settings option and copy the Store ID and Model ID. In the Authorized Clients section, click on Create Client. Set the following values:

  • Client Name: spring-api-fga
  • Read/Write model, changes, assertions: Yes
  • Write and delete tuples: Yes
  • Read and query: Yes

Click on Create, copy the Client ID and Client Secret, and click Continue. Then update the application.yml with the following properties:

openfga:
  api-url: https://api.us1.fga.dev # check options for your region
  store-id: <okta-fga-store-id>
  authorization-model-id: <okta-fga-authorization-model-id>
  initialize: false
  credentials:
    method: CLIENT_CREDENTIALS # constant
    config:
      client-id: <client-id>
      client-secret: <client-secret>
      api-token-issuer: fga.us.auth0.com
      api-audience: https://api.us1.fga.dev/

spring:
  docker:
    compose:
      lifecycle-management: none

Stop the application and run it again; the API should work as before.

Learn more about OpenFGA and Spring Boot

In this post, you learned about OpenFGA integration to a Spring Boot API using the OpenFGA Spring Boot Starter 0.0.1, which was just released. I hope you found this introduction useful and can grasp the basics of fine-grained authorization through the OpenFGA system, as well as the benefits of moving authorization logic outside the application code. You can find the code shown in this tutorial on GitHub. If you'd rather skip the step-by-step and prefer running a sample application, follow the README instructions in the same repository.

Also, if you liked this post, you might enjoy these related posts:

Check out the Spring Boot resources in our Developer Center:

Please follow us on Twitter @oktadev and subscribe to our YouTube channel for more Spring Boot and microservices knowledge.

You can also sign up for our developer newsletter to stay updated on everything Identity and Security.

  • Twitter icon
  • LinkedIn icon
  • Faceboook icon