OpenFGAをローカルのDocker環境で動かして認可チェックをするところまでをやってみた

2024.03.11

今回の記事は、OpenFGAの公式ドキュメントから以下の項目に従って実装し、動作確認してみました。

  • Getting Started
    • Setup OpenFGA
      • Docker
    • Install SDK Client
    • Create a Store
    • Setup SDK Client for Store
    • Configure Authorization Model
    • Update Relationship Tuples
    • Perform a Check
    • Perform a List Objects Request

私がどのように実装したか、クライアントアプリのコードも載せていますので、参考にしてみてください。

そもそも、OpenFGAが何なのか、概念については前回記事をご覧ください。

やってみた

Setup OpenFGA with Docker

今回は、共有キー認証ありでOpenFGAサーバーを起動しました。

共有キーとは、OpenFGAサーバーにAPIリクエストする際のAuthorizationヘッダーのBearerトークンです。

compose.yaml

  openfga:
    depends_on:
      migrate:
        condition: service_completed_successfully
    image: openfga/openfga:latest
    container_name: openfga
    environment:
      - OPENFGA_DATASTORE_ENGINE=mysql
      - OPENFGA_DATASTORE_URI=root:secret@tcp(mysql:3306)/openfga?parseTime=true
      - OPENFGA_LOG_FORMAT=json
      - OPENFGA_AUTHN_METHOD=preshared #共有キー追加
      - OPENFGA_AUTHN_PRESHARED_KEYS=preshared #共有キー追加
    command: run
    networks:
      - openfga
    ports:
      # Needed for the http server
      - "8080:8080"
      # Needed for the grpc server (if used)
      - "8081:8081"
      # Needed for the playground (Do not enable in prod!)
      - "3000:3000"

docker compose up でサーバーを実行したら http://localhost:3000/playground で playground にアクセスできるようになります。

ちなみにOIDCによる認証でサーバー起動しようとすると、playgroudを起動できず失敗しました。本番でplaygroundを起動しない、と推奨されていることを考えると、OIDCは本番向けの認証方法のようですね。

openfga          | panic: the playground only supports authn methods 'none' and 'preshared'

Install SDK Client

OpenFGA SDKは、任意のNode.jsサーバーで実行します。今回、私はAuth0のクイックスタートアプリ(Next.js)をクライアントアプリとしてローカルで起動します。

作成手順はこちらを参照ください。

※この記事はSPAを選択しているので、Node.jsサーバーを利用するためにRegular Web Applicationsを選択してください。

Auth0のセットアップが完了すると以下ようにログインができるようになります。

そしてOpenFGA SDK packageをインストールします。

npm install @openfga/sdk

では、続けていきましょう。

Create a Store

OpenFGAのModelやTupleはStoreに保管されますので、まずそのStoreを作成します。

$ curl -X POST http://localhost:8080/stores \
  -H "content-type: application/json" \
  -H "Authorization: Bearer preshared" \
  -d '{"name": "FGA Demo Store"}'

{"id":"01HRNPP1PE44FSYTQ520PEDP0H", "name":"FGA Demo Store", "created_at":"2024-03-11T02:50:18Z", "updated_at":"2024-03-11T02:50:18Z"}%

Setup SDK Client for Store

クライアントアプリの実装でOpenFGAClientを初期化するコードを確認します。

今回は共有キーありなので、このように初期化します。

import { OpenFgaClient } from '@openfga/sdk';

const openFga = new OpenFgaClient({
    apiUrl: process.env.FGA_API_URL,
    storeId: process.env.FGA_STORE_ID,
    authorizationModelId: process.env.FGA_MODEL_ID,
    credentials: {
        method: CredentialsMethod.ApiToken,
        config: {
            token: process.env.$FGA_API_TOKEN,
        },
    }
});

Configure Authorization Model for a Store

アクセス制御を定義するModelを作成します。

model
  schema 1.1

type user

type document
  relations
    define reader: [user]
    define writer: [user]
    define owner: [user]

この定義を登録するには、以下のCurlコマンドを実行します。

※01HRNPP1PE44FSYTQ520PEDP0H は先ほど作成したStoreのIDです。

$ curl -X POST http://localhost:8080/stores/01HRNPP1PE44FSYTQ520PEDP0H/authorization-models \
  -H "Authorization: Bearer preshared" \
  -H "content-type: application/json" \
  -d '{"schema_version":"1.1","type_definitions":[{"type":"user"},{"type":"document","relations":{"reader":{"this":{}},"writer":{"this":{}},"owner":{"this":{}}},"metadata":{"relations":{"reader":{"directly_related_user_types":[{"type":"user"}]},"writer":{"directly_related_user_types":[{"type":"user"}]},"owner":{"directly_related_user_types":[{"type":"user"}]}}}}]}'

{"authorization_model_id":"01HRNPQKJ4TZABVQR4HCSG6R8E"}

モデルが登録されたか、playgroundで確認することができます。

ここまで作業すると、StoreIDとAuthorizationModelIDが確定するので、クライアントアプリの .env.local に以下のように記述します。

# Auth0 Setting
(省略)
AUTH0_SCOPE='openid profile email' #Auth0からemail情報を取得するため、scopeにemailを追記しておきます。今回の実装で使います。

# OpenFGA Setting
FGA_API_URL=http://localhost:8080
FGA_STORE_ID=01HRNPP1PE44FSYTQ520PEDP0H
FGA_MODEL_ID=01HRNPQKJ4TZABVQR4HCSG6R8E
FGA_API_TOKEN=preshared

Update Relationship Tuples

先ほど定義したModelに従ってオブジェクト同士の関係を示すTupleを登録します。

今回は、ユーザーがログインした後に、ドキュメントZへの読み込み権限をユーザーに付与することとします。

実装は、Auth0でログイン・認証した後のコールバック関数で行います。

app/api/auth/[auth0]/route.js を以下のように改修します。

route.js

import { handleAuth, handleCallback } from '@auth0/nextjs-auth0';
import {NextResponse} from "next/server";
import jwt from 'jsonwebtoken';
import { OpenFgaClient, CredentialsMethod } from '@openfga/sdk';
import {console} from "next/dist/compiled/@edge-runtime/primitives";

const afterCallback = async (req, session) => {
  const decodedToken = jwt.decode(session.idToken);
  const email = decodedToken.email;
  
  const fgaClient = new OpenFgaClient({
    apiUrl: process.env.FGA_API_URL,
    storeId: process.env.FGA_STORE_ID,
    authorizationModelId: process.env.FGA_MODEL_ID,
    credentials: {
      method: CredentialsMethod.ApiToken,
      config: {
        token: process.env.FGA_API_TOKEN,
      },
    }
  });
  
  // put tuple_keys
  try {
    await fgaClient.write({
      writes: [
        {
          "user": `user:${email}`,
          "relation": "reader",
          "object": "document:Z"
        }
      ],
    });
  } catch (error) {
    console.error('Error writing to user store:', error);
  }
  
  return session;
};

export const GET = handleAuth({
  callback: async (req, ctx) => {
    try {
      return (await handleCallback(req, ctx, { afterCallback }));
    } catch (error) {
      return NextResponse.redirect('/auth/error');
    }
  }
});

クライアントアプリでログインした後、ログインユーザーのEmailをkeyにしてTupleが登録されます。

playgroundで確認します。

Perform a Check

先ほど登録したTupleは「ユーザーがdocument:Zにreader関係にあること」を宣言するものでした。このTupleが期待通り動作するかチェックします。

app/api/auth/[auth0]/route.js を以下のように改修します。

route.js

import { handleAuth, handleCallback } from '@auth0/nextjs-auth0';
import {NextResponse} from "next/server";
import jwt from 'jsonwebtoken';
import { OpenFgaClient, CredentialsMethod } from '@openfga/sdk';

const afterCallback = async (req, session) => {
  const decodedToken = jwt.decode(session.idToken);
  const email = decodedToken.email;
  
  const fgaClient = new OpenFgaClient({
    apiUrl: process.env.FGA_API_URL,
    storeId: process.env.FGA_STORE_ID,
    authorizationModelId: process.env.FGA_MODEL_ID,
    credentials: {
      method: CredentialsMethod.ApiToken,
      config: {
        token: process.env.FGA_API_TOKEN,
      },
    }
  });
  
  try {
    // read tuple_key
    const readResponse = await fgaClient.read({
      writes: [
        {
          "user": `user:${email}`,
        }
      ],
    })
    
    if (readResponse.tuples.length === 0) {
      // put tuple_key
      await fgaClient.write({
        writes: [
          {
            "user": `user:${email}`,
            "relation": "reader",
            "object": "document:Z"
          }
        ],
      });
    }

    // check relationship
    const { allowed } = await fgaClient.check({
      "user": `user:${email}`,
      "relation": "reader",
      "object": "document:Z"
    });
    
    console.log(allowed)
  } catch (error) {
    console.error('Error writing to user store:', error);
  }
  
  return session;
};

export const GET = handleAuth({
  callback: async (req, ctx) => {
    try {
      return (await handleCallback(req, ctx, { afterCallback }));
    } catch (error) {
      return NextResponse.redirect('/auth/error');
    }
  }
});

OpenFGAサーバーから返却されたallowedの値は、期待通りtrueでした。

Perform a List Objects call

最後に、ユーザーの関係Tupleの一覧をみてみます。

app/api/auth/[auth0]/route.js を以下のように改修します。

route.js

import { handleAuth, handleCallback } from '@auth0/nextjs-auth0';
import {NextResponse} from "next/server";
import jwt from 'jsonwebtoken';
import { OpenFgaClient, CredentialsMethod } from '@openfga/sdk';

const afterCallback = async (req, session) => {
  const decodedToken = jwt.decode(session.idToken);
  const email = decodedToken.email;
  
  const fgaClient = new OpenFgaClient({
    apiUrl: process.env.FGA_API_URL,
    storeId: process.env.FGA_STORE_ID,
    authorizationModelId: process.env.FGA_MODEL_ID,
    credentials: {
      method: CredentialsMethod.ApiToken,
      config: {
        token: process.env.FGA_API_TOKEN,
      },
    }
  });
  
  try {
    // read tuple_key
    const readResponse = await fgaClient.read({
      writes: [
        {
          "user": `user:${email}`,
        }
      ],
    })
    
    if (readResponse.tuples.length === 0) {
      // put tuple_key
      await fgaClient.write({
        writes: [
          {
            "user": `user:${email}`,
            "relation": "reader",
            "object": "document:Z"
          }
        ],
      });
    }

    // check relationship
    const { allowed } = await fgaClient.check({
      "user": `user:${email}`,
      "relation": "reader",
      "object": "document:Z"
    });
    
    console.log(allowed)
    
    // call list objects
    const listObjectResponse = await fgaClient.listObjects({
      "user": `user:${email}`,
      "relation": "reader",
      "type": "document" // ここtypeになっているの注意
    });
    
    console.log(listObjectResponse)
  } catch (error) {
    console.error('Error writing to user store:', error);
  }
  
  return session;
};

export const GET = handleAuth({
  callback: async (req, ctx) => {
    try {
      return (await handleCallback(req, ctx, { afterCallback }));
    } catch (error) {
      return NextResponse.redirect('/auth/error');
    }
  }
});

OpenFGAサーバーから返却されたlistObjectResponseの値は、期待通り{ objects: [ 'document:Z' ] }でした。期待通りです。

感想

今回の検証を通して、実際にOpenFGAがアプリケーション上でどのように動作するのか、大枠で理解することができました。

次はモデル定義を深堀りしてみようと思います。

以上。