No description
  • Kotlin 98.3%
  • Java 1.7%
Find a file
Matthias W 4b030a61cc
All checks were successful
CI / build (push) Successful in 5m6s
/ release (push) Successful in 4m35s
feat: create tuple interface
2026-07-27 15:39:07 +02:00
.forgejo/workflows chore: created bom 2026-07-22 14:57:40 +02:00
bom feat: create tuple interface 2026-07-27 15:39:07 +02:00
client feat: create tuple interface 2026-07-27 15:39:07 +02:00
core feat: create tuple interface 2026-07-27 15:39:07 +02:00
example feat: create tuple interface 2026-07-27 15:39:07 +02:00
gradle/wrapper feat: initial 2026-07-15 17:10:45 +02:00
gradle-plugin feat: create tuple interface 2026-07-27 15:39:07 +02:00
maven-plugin feat: create tuple interface 2026-07-27 15:39:07 +02:00
modeling-guide-tests feat: create tuple interface 2026-07-27 15:39:07 +02:00
spring-boot-starter feat: create tuple interface 2026-07-27 15:39:07 +02:00
src feat: create tuple interface 2026-07-27 15:39:07 +02:00
.gitignore feat: initial commit 2026-07-21 18:42:12 +02:00
build.gradle.kts feat: create tuple interface 2026-07-27 15:39:07 +02:00
gradlew feat: initial 2026-07-15 17:10:45 +02:00
gradlew.bat feat: initial 2026-07-15 17:10:45 +02:00
LICENSE feat: create tuple interface 2026-07-27 15:39:07 +02:00
README.md feat: create tuple interface 2026-07-27 15:39:07 +02:00
settings.gradle.kts feat: create tuple interface 2026-07-27 15:39:07 +02:00

OpenFGA Kotlin Generator

The OpenFGA Kotlin Generator turns an OpenFGA DSL model into a type-safe Kotlin API. Instead of assembling tuple strings such as documents__document:roadmap#viewer@iam__user:alice by hand, applications work with generated objects and relation methods:

val alice = IamUser("alice")
val roadmap = DocumentsDocument("roadmap")

val tuple = roadmap.viewer(alice)

Invalid subject, relation, and target combinations are rejected by the Kotlin compiler. The generator uses the official OpenFGA language parser and generates helpers only for subjects that can be assigned directly through OpenFGA tuples.

Generated API at a glance

Given this model:

model
  schema 1.2

type iam__user

type documents__folder
  relations
    define participant: [iam__user]
    define viewer: [iam__user]

type documents__document
  relations
    define can_view: [documents__folder#participant, iam__user]
    define parent: [documents__folder]
    define owner: [iam__user]
    define viewer: [iam__user] or viewer from parent

the generator creates:

  • one Kotlin object class per OpenFGA type, for example IamUser, DocumentsFolder, and DocumentsDocument;
  • typed relation enums for checks;
  • typed methods on target objects for creating writable tuples;
  • typed userset subjects such as DocumentsFolderParticipantSubject;
  • conversions to OpenFGA SDK request objects and JSON.

Generated package names follow the type prefix before __. With the root package com.example.authorization, documents__document is generated below com.example.authorization.documents.

Working with direct relations

The relation:

define viewer: [iam__user]

produces a method accepting only IamUser:

val alice = IamUser("alice")
val folder = DocumentsFolder("product")

val tuple = folder.viewer(alice)

The tuple contains:

user:     iam__user:alice
relation: viewer
object:   documents__folder:product

It can be converted for the OpenFGA SDK or serialized for inspection:

val sdkTuple = tuple.toClientTupleKey()
val json = tuple.toJson()

Working with relation usersets

An assignable subject containing #participant refers to the participant userset of one concrete folder:

define can_view: [documents__folder#participant]

For a folder with the ID ALL, the generated API is:

val participants = DocumentsFolder("ALL").participant()
val document = DocumentsDocument("roadmap")

val tuple = document.canView(participants)

This creates:

documents__document:roadmap#can_view@documents__folder:ALL#participant

ALL is an ordinary object ID here. It is not the OpenFGA wildcard *.

The userset is represented by its own Kotlin type. Therefore, if can_view accepts only documents__folder#participant, these calls do not compile:

document.canView(DocumentsFolder("ALL")) // folder object instead of its participant userset
document.canView(alice)                  // user is not allowed by this relation definition

Relations with multiple allowed subject types

OpenFGA can allow multiple directly assignable subject types:

define can_view: [documents__folder#participant, iam__user]

The generator creates one type-safe overload for each alternative:

val alice = IamUser("alice")
val participants = DocumentsFolder("ALL").participant()
val document = DocumentsDocument("roadmap")

val directAccess = document.canView(alice)
val participantAccess = document.canView(participants)

These produce different subjects while using the same target relation:

documents__document:roadmap#can_view@iam__user:alice
documents__document:roadmap#can_view@documents__folder:ALL#participant

Bound relations

Every directly writable relation can also be obtained without creating a tuple. The returned value retains the relation type and the concrete target instance:

val parent = document.parent()

parent.relation // DocumentsDocument.FolderRelation.PARENT
parent.target   // the same document instance

Tuple creation remains direct:

val tuple = document.parent(folder)

Relation enums are nested in their target type, for example DocumentsDocument.FolderRelation.PARENT. If one relation accepts several subject types, its parameterless accessors are subject-qualified, such as viewerUser() and viewerGroupMember(). The tuple overloads remain viewer(user) and viewer(group.member()).

Computed relations

For a rewrite such as:

define viewer: [iam__user] or viewer from parent

only [iam__user] is directly writable on viewer. The viewer from parent branch is evaluated by OpenFGA and does not create another writable tuple method. The application writes the parent connection and the relevant folder permission:

val alice = IamUser("alice")
val folder = DocumentsFolder("product")
val document = DocumentsDocument("roadmap")

val tuples = listOf(
  folder.viewer(alice),
  document.parent(folder),
)

OpenFGA follows the model rewrite when checking whether Alice is a viewer of the document.

If a relation is computed only:

define viewer: viewer from parent

the generated method returns a check-only query:

val query: FgaAuthorizationQuery<IamUser, DocumentsDocument> = document.viewer(alice)

FgaTuple extends FgaAuthorizationQuery, so direct tuples support both check() and create(). A computed-only query supports check() but intentionally has no create() extension.

Checks

Add the Spring Boot starter to execute tuples as OpenFGA checks:

dependencies {
  implementation("de.denktmit.openfga:openfga-kotlin-spring-boot-starter")
}

Provide an OpenFgaClient as a Spring bean. The starter resolves it automatically:

@Bean
fun openFgaClient(): OpenFgaClient = OpenFgaClient(configuration)

The starter exposes an OpenFgaConnection bean. Its util scope makes the tuple extensions available without exposing the SDK client to OpenFgaUtil:

import de.denktmit.openfga.client.OpenFgaConnection
import de.denktmit.openfga.client.util
import de.denktmit.openfga.core.FgaAuthorizationQuery

@Service
class AuthorizationService(
  private val openFga: OpenFgaConnection,
) {
  fun mayView(query: FgaAuthorizationQuery<*, *>): Boolean =
    openFga.util {
      query.check()
    }
}

Iterables use the OpenFGA batch-check endpoint:

import de.denktmit.openfga.client.FgaAuthorizationCheckResult

val results: List<FgaAuthorizationCheckResult> = openFga.util {
  tuples.check()
}

results.forEach { result ->
  println("${result.query.objectValue}: ${result.allowed}, error=${result.error}")
}

Complete typed tuples can also be written idempotently:

openFga.util {
  document.viewer(user).create()
  additionalTuples.create()
}

The iterable write uses one OpenFGA SDK request and ignores tuples that already exist. A successful write returns Unit; SDK, transport, and configuration failures are propagated as exceptions. Empty iterables return immediately without contacting OpenFGA.

When several clients exist, mark the default as @Primary or configure the client bean name or qualifier:

openfga.kotlin.client-qualifier=authorization

The qualifier may be either a Spring bean name or an @Qualifier value. Applications can override the auto-configured connection by providing their own OpenFgaConnection bean. The extensions are blocking: they wait for the asynchronous OpenFGA SDK response before returning.

The Spring-free openfga-kotlin-client artifact can be used directly:

val connection = openFgaConnection(client)

connection.util {
  tuple.check()
}

Gradle setup

Apply the plugin:

plugins {
  kotlin("jvm")
  id("de.denktmit.openfga.kotlin-generator") version "0.1.0-SNAPSHOT"
}

Add the generated-code runtime. The BOM keeps related artifacts on the same version:

dependencies {
  implementation(platform("de.denktmit.openfga:openfga-kotlin-generator-bom:0.1.0-SNAPSHOT"))
  implementation("de.denktmit.openfga:openfga-kotlin-generator-core")
  implementation("de.denktmit.openfga:openfga-kotlin-spring-boot-starter")
}

Place the model at src/main/openfga/model.fga. Those are the default model and output locations, so no configuration is required for the common case. To customize generation:

import de.denktmit.openfga.generator.gradle.OpenFgaKotlinGeneratorExtension

extensions.configure<OpenFgaKotlinGeneratorExtension>("openFgaKotlin") {
  modelFile.set(layout.projectDirectory.file("src/main/openfga/model.fga"))
  outputDirectory.set(layout.buildDirectory.dir("generated/source/openfga/main"))
  packageName.set("com.example.authorization")

  // Optional filters. With no filters, all model types are generated.
  includeTypes.set(listOf("iam__user"))
  includePrefixes.set(listOf("documents__"))
}

Run generation explicitly with:

./gradlew generateOpenFgaKotlin

The plugin also:

  • adds the output directory to the Kotlin main source set;
  • makes compileKotlin depend on generateOpenFgaKotlin;
  • removes stale files from the generated package directory before generation.

Set cleanOutputDirectory to true only when the entire configured output directory belongs to the generator.

Maven setup

Add the runtime dependency:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>de.denktmit.openfga</groupId>
      <artifactId>openfga-kotlin-generator-bom</artifactId>
      <version>0.1.0-SNAPSHOT</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>de.denktmit.openfga</groupId>
    <artifactId>openfga-kotlin-generator-core</artifactId>
  </dependency>
</dependencies>

Configure the Maven plugin:

<plugin>
  <groupId>de.denktmit.openfga</groupId>
  <artifactId>openfga-kotlin-generator-maven-plugin</artifactId>
  <version>0.1.0-SNAPSHOT</version>
  <executions>
    <execution>
      <id>generate-openfga-kotlin</id>
      <phase>generate-sources</phase>
      <goals>
        <goal>generate</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <modelFile>${project.basedir}/src/main/openfga/model.fga</modelFile>
    <outputDirectory>${project.build.directory}/generated-sources/openfga</outputDirectory>
    <packageName>com.example.authorization</packageName>
  </configuration>
</plugin>

The plugin registers the output as a Maven compile source root. If kotlin-maven-plugin uses explicit <sourceDirs>, also add ${project.build.directory}/generated-sources/openfga.

CLI usage

Print generated files to standard output:

java -cp <generator-classpath> de.denktmit.openfga.generator.OpenFgaConnectionGenerator \
  kotlin src/main/openfga/model.fga \
  --package com.example.authorization

Write generated files to a source directory:

java -cp <generator-classpath> de.denktmit.openfga.generator.OpenFgaConnectionGenerator \
  write-kotlin src/main/openfga/model.fga build/generated/source/openfga/main \
  --package com.example.authorization

Generation can be restricted by exact type or prefix. Both options can be repeated:

--include-type iam__user \
--include-prefix documents__

Without filters, every model type is generated. The legacy command names --kotlin and --write-kotlin remain supported.

By default, write-kotlin cleans only the generated package below the output directory. Use --clean-output-directory only if the entire output directory may be deleted.

Running this repository

Build and test all modules:

./gradlew test

Generate, compile, and test the included example:

./gradlew :example:test

The relevant example files are:

  • example/src/main/openfga/model.fga
  • example/src/main/kotlin/de/denktmit/openfga/example/DocumentPermissions.kt
  • example/src/test/kotlin/de/denktmit/openfga/example/DocumentPermissionsTest.kt

License

Licensed under the Apache License, Version 2.0. See LICENSE.