CLOVER🍀

That was when it all began.

Javaのコマンドラインアプリケーション向けのフレームワーク、picocliで遊ぶ

これは、なにをしたくて書いたもの?

ちょっと前から、picocliというJavaでのコマンドラインアプリケーション向けのフレームワークがあるのが気になっていまして。

picocli - a mighty tiny command line interface

これまでJavaコマンドラインアプリケーション向けのライブラリなどといえば、こちらを使っていました。

args4j parent - Args4j

Commons CLI – Home

個人的にはargs4jをよく使っていましたね。

picocliもかなり良さそうなので、1度試しておこうかなと。

picocli

繰り返しますが、Javaコマンドラインアプリケーション向けのフレームワークです。

picocli - a mighty tiny command line interface

GitHub - remkop/picocli: Picocli is a modern framework for building powerful, user-friendly, GraalVM-enabled command line apps with ease. It supports colors, autocompletion, subcommands, and more. In 1 source file so apps can include as source & avoid adding a dependency. Written in Java, usable from Groovy, Kotlin, Scala, etc.

GNUPOSIXMS-DOSなどのコマンドライン構文のサポート、カラー、ヘルプの生成から、オプション、引数(パラメーター)、
サブコマンドも扱えるそうで。

さらに、GraalVMによるネイティブイメージの作成もサポートしているので、よりCLIフレンドリーですね。

ドキュメントがとても充実しているのもポイントで、かなり機能があることがよくわかります。

picocli - a mighty tiny command line interface

picocli 4.2.0 API

参考記事もGitHub上でリンクされていたり。

Articles & Presentations

Articles & Presentations / 日本語

説明はこれくらいにして、試していってみましょう。

環境

今回の環境は、こちら。

$ java --version
openjdk version "11.0.6" 2020-01-14
OpenJDK Runtime Environment GraalVM CE 19.3.1 (build 11.0.6+9-jvmci-19.3-b07)
OpenJDK 64-Bit Server VM GraalVM CE 19.3.1 (build 11.0.6+9-jvmci-19.3-b07, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.6, vendor: Oracle Corporation, runtime: /usr/local/graalvm-ce
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.3.0-40-generic", arch: "amd64", family: "unix"

ネイティブイメージも作ろうと思うので、GraalVMを使います。

準備とお題

まずはシンプルに。Maven依存関係はこちら。

        <dependency>
            <groupId>info.picocli</groupId>
            <artifactId>picocli</artifactId>
            <version>4.2.0</version>
        </dependency>

お題ですが、すごく簡易なcat、grepコマンドを作り、さらにcat、grepをサブコマンドにしたランチャーを作ります。最後にネイティブ
イメージを作って完了としましょうか。

Getting Started的に

簡単なCatコマンドをマネたものを作ってみましょう。

引数としてファイルを任意数指定可能、ファイルを指定しなかったら標準入力から読み、オプションとしては「-n」の代わりに
「-p true」とすると行番号を表示するようにします。オプションに冗長感ありますけど、まずはパラメーターありのオプション
ということで。

引数やオプションなどはアノテーションで表現していくのですが、基本はこちらを見るとわかる気がします。

Options and Parameters

作成したソースコードは、こちら。
src/main/java/org/littlewings/picocli/Cat.java

package org.littlewings.picocli;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.stream.Stream;

import picocli.CommandLine;

@CommandLine.Command(name = "cat")
public class Cat implements Callable<Integer> {
    @CommandLine.Option(names = {"-h", "--help"}, description = "show this help", usageHelp = true)
    boolean showHelp;

    @CommandLine.Option(names = {"-p", "--print-line-number"}, paramLabel = "true or false", description = "number all output lines", defaultValue = "false")
    String printLineNumber;

    @CommandLine.Parameters(paramLabel = "FILE")
    List<Path> paths;

    int lineCount = 1;

    @Override
    public Integer call() throws Exception {
        if (paths != null) {
            for (Path path : paths) {
                try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
                    if ("true".equals(printLineNumber)) {
                        lines.forEach(line -> System.out.printf("%d: %s%n", lineCount++, line));
                    } else {
                        lines.forEach(line -> System.out.printf("%s%n", line));
                    }
                }
            }
        } else {
            BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
            if ("true".equals(printLineNumber)) {
                reader.lines().forEach(line -> System.out.printf("%d: %s%n", lineCount++, line));
            } else {
                reader.lines().forEach(line -> System.out.printf("%s%n", line));
            }

        }

        return 0;
    }

    public static void main(String... args) {
        System.exit(new CommandLine(new Cat()).execute(args));
    }
}

Callableを実装したクラスを作成。型パラメーターには、Integerを指定。callの戻り値をコマンドの終了ステータスとして使います。

@CommandLine.Command(name = "cat")
public class Cat implements Callable<Integer> {

@Commandアノテーションを付与して、nameを「cat」とします。このnameの値が、サブコマンドやヘルプで使われます。

@Optionアノテーションで文字通りオプションを、@Parametersでコマンド引数を表します。

    @CommandLine.Option(names = {"-h", "--help"}, description = "show this help", usageHelp = true)
    boolean showHelp;

    @CommandLine.Option(names = {"-p", "--print-line-number"}, paramLabel = "true or false", description = "number all output lines", defaultValue = "false")
    String printLineNumber;

    @CommandLine.Parameters(paramLabel = "FILE")
    List<Path> paths;

アノテーションを付与したフィールドには、オプションや引数で指定した値が入ります。

String以外の型でも受け取れます。

Strongly Typed Everything

Listや配列などを使うと、複数個指定できるオプションや引数として使えわれます。

@Optionで「usageHelp」をtrueにすると、ヘルプオプションとして扱われます。

あとはcallメソッドの中を実装して

    @Override
    public Integer call() throws Exception {
        if (paths != null) {
            for (Path path : paths) {
                try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
                    if ("true".equals(printLineNumber)) {
                        lines.forEach(line -> System.out.printf("%d: %s%n", lineCount++, line));
                    } else {
                        lines.forEach(line -> System.out.printf("%s%n", line));
                    }
                }
            }
        } else {
            BufferedReader reader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
            if ("true".equals(printLineNumber)) {
                reader.lines().forEach(line -> System.out.printf("%d: %s%n", lineCount++, line));
            } else {
                reader.lines().forEach(line -> System.out.printf("%s%n", line));
            }

        }

        return 0;
    }

mainメソッドを作成。

    public static void main(String... args) {
        System.exit(new CommandLine(new Cat()).execute(args));
    }

このCallableのインスタンスを、CommandLineのコンストラクタに渡してexecuteを呼び出します。この時に、mainメソッドの引数を
渡すと@Optionや@Parametersアノテーションを付与したフィールドに値が入った状態でcallメソッドが呼び出されます。

executeの戻り値は、callメソッドの戻り値になります。

では、試してみましょう。

サンプルのファイルを作成。

$ python3 -c 'import this' > zen_of_python.txt

実行。大着してますが、「mvn exec:java」で実行します。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Cat -Dexec.args='zen_of_python.txt'

〜省略〜

The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

行番号を表示してみます。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Cat -Dexec.args='-p true zen_of_python.txt'

〜省略〜

1: The Zen of Python, by Tim Peters
2: 
3: Beautiful is better than ugly.
4: Explicit is better than implicit.
5: Simple is better than complex.
6: Complex is better than complicated.
7: Flat is better than nested.
8: Sparse is better than dense.
9: Readability counts.
10: Special cases aren't special enough to break the rules.
11: Although practicality beats purity.
12: Errors should never pass silently.
13: Unless explicitly silenced.
14: In the face of ambiguity, refuse the temptation to guess.
15: There should be one-- and preferably only one --obvious way to do it.
16: Although that way may not be obvious at first unless you're Dutch.
17: Now is better than never.
18: Although never is often better than *right* now.
19: If the implementation is hard to explain, it's a bad idea.
20: If the implementation is easy to explain, it may be a good idea.
21: Namespaces are one honking great idea -- let's do more of those!

OKそうですね。

もうひとつファイルを増やしてみましょう。

$ lsb_release -a > ubuntu_version.txt

複数のファイルを対象にしてみます。オプションは、ロングオプションで指定してみます。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Cat -Dexec.args='--print-line-number true zen_of_python.txt ubuntu_version.txt'

〜省略〜

1: The Zen of Python, by Tim Peters
2: 
3: Beautiful is better than ugly.
4: Explicit is better than implicit.
5: Simple is better than complex.
6: Complex is better than complicated.
7: Flat is better than nested.
8: Sparse is better than dense.
9: Readability counts.
10: Special cases aren't special enough to break the rules.
11: Although practicality beats purity.
12: Errors should never pass silently.
13: Unless explicitly silenced.
14: In the face of ambiguity, refuse the temptation to guess.
15: There should be one-- and preferably only one --obvious way to do it.
16: Although that way may not be obvious at first unless you're Dutch.
17: Now is better than never.
18: Although never is often better than *right* now.
19: If the implementation is hard to explain, it's a bad idea.
20: If the implementation is easy to explain, it may be a good idea.
21: Namespaces are one honking great idea -- let's do more of those!
22: Distributor ID: Ubuntu
23: Description:    Ubuntu 18.04.4 LTS
24: Release:    18.04
25: Codename:   bionic

ファイルを指定せずに、標準出力から入力してみましょう。

$ cat ubuntu_version.txt | mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Cat

〜省略〜

Distributor ID: Ubuntu
Description:    Ubuntu 18.04.4 LTS
Release:    18.04
Codename:   bionic

最後に、ヘルプを表示。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Cat -Dexec.args='-h'

〜省略〜

Usage: cat [-h] [-p=true or false] [FILE...]
      [FILE...]
  -h, --help      show this help
  -p, --print-line-number=true or false
                  number all output lines

ちなみに、このヘルプはカラーで見れます。

OKですね。

もうひとつサンプルを

続いて、grepもどきを作成してみましょう。

ソースコードは、こちら。
src/main/java/org/littlewings/picocli/Grep.java

package org.littlewings.picocli;

import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.Callable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import picocli.CommandLine;

@CommandLine.Command(name = "grep")
public class Grep implements Callable<Integer> {
    @CommandLine.Option(names = {"-h", "--help"}, description = "show this help", usageHelp = true)
    boolean showHelp;

    @CommandLine.Option(names = {"-n", "--line-number"}, description = "display match line number", defaultValue = "false")
    boolean printLineNumber;

    @CommandLine.Option(names = {"-v", "--invert-match"}, description = "print invert match")
    boolean printInvertMatch;

    @CommandLine.Parameters(index = "0", paramLabel = "PATTERN")
    String pattern;

    @CommandLine.Parameters(index = "1", paramLabel = "FILE")
    Path path;

    @Override
    public Integer call() throws Exception {
        Pattern p = Pattern.compile(pattern);

        try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
            String line;
            int lineCount = 1;

            while ((line = reader.readLine()) != null) {
                lineCount++;

                Matcher m = p.matcher(line);

                boolean match = m.find();

                if (match && !printInvertMatch) {
                    if (printLineNumber) {
                        System.out.printf("%d: %s%n", lineCount, line);
                    } else {
                        System.out.printf("%s%n", line);
                    }
                } else if (!match && printInvertMatch) {
                    if (printLineNumber) {
                        System.out.printf("%d: %s%n", lineCount, line);
                    } else {
                        System.out.printf("%s%n", line);
                    }
                }
            }
        }

        return 0;
    }

    public static void main(String... args) {
        System.exit(new CommandLine(new Grep()).execute(args));
    }
}

先ほどと違うのは、booleanのオプションを設けて、指定すればtrueとなるようにしました。オプションとしては、「-n」と「-v」を定義
しています。

    @CommandLine.Option(names = {"-n", "--line-number"}, description = "display match line number", defaultValue = "false")
    boolean printLineNumber;

    @CommandLine.Option(names = {"-v", "--invert-match"}, description = "print invert match")
    boolean printInvertMatch;

Options and Parameters

また、引数の位置も指定するようにしてみます。入力となるファイルは、複数指定できないようにしました。また、ファイルを
指定しないと標準入力から読み込むような動きもしません。

    @CommandLine.Parameters(index = "0", paramLabel = "PATTERN")
    String pattern;

    @CommandLine.Parameters(index = "1", paramLabel = "FILE")
    Path path;

Positional Parameters

その他は、そんなにcatの例と変わらないので、説明は省略。

確認。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Grep -Dexec.args='better zen_of_python.txt'

〜省略〜

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Now is better than never.
Although never is often better than *right* now.

行番号を出力。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Grep -Dexec.args='-n better zen_of_python.txt'

〜省略〜

4: Beautiful is better than ugly.
5: Explicit is better than implicit.
6: Simple is better than complex.
7: Complex is better than complicated.
8: Flat is better than nested.
9: Sparse is better than dense.
18: Now is better than never.
19: Although never is often better than *right* now.

いわゆる「-v」をロングオプションで指定。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Grep -Dexec.args='-n --invert-match better zen_of_python.txt'

〜省略〜

2: The Zen of Python, by Tim Peters
3: 
10: Readability counts.
11: Special cases aren't special enough to break the rules.
12: Although practicality beats purity.
13: Errors should never pass silently.
14: Unless explicitly silenced.
15: In the face of ambiguity, refuse the temptation to guess.
16: There should be one-- and preferably only one --obvious way to do it.
17: Although that way may not be obvious at first unless you're Dutch.
20: If the implementation is hard to explain, it's a bad idea.
21: If the implementation is easy to explain, it may be a good idea.
22: Namespaces are one honking great idea -- let's do more of those!

ヘルプ。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Grep -Dexec.args='--help'

〜省略〜

Usage: grep [-hnv] PATTERN FILE
      PATTERN
      FILE
  -h, --help           show this help
  -n, --line-number    display match line number
  -v, --invert-match   print invert match

サブコマンドを使う

作成するコマンドの最後として、サブコマンドを使ってみましょう。

Subcommands

先ほど作ったcat、grepをサブコマンドにしたものを作ってみます。

こんな感じで。 src/main/java/org/littlewings/picocli/Launcher.java

package org.littlewings.picocli;

import java.util.concurrent.Callable;

import picocli.CommandLine;

@CommandLine.Command(name = "launcher", subcommands = {Cat.class, Grep.class})
public class Launcher implements Callable<Integer> {
    @CommandLine.Option(names = {"-h", "--help"}, description = "show this help", usageHelp = true)
    boolean showHelp;

    @Override
    public Integer call() throws Exception {
        return 0;
    }

    public static void main(String... args) {
        System.exit(new CommandLine(new Launcher()).execute(args));
    }
}

@Commandアノテーションのsubcommandsに、先ほどまで作成していたコマンドを、サブコマンドとして登録します。

@CommandLine.Command(name = "launcher", subcommands = {Cat.class, Grep.class})
public class Launcher implements Callable<Integer> {

callメソッドの中身は、特になにもなくても動きます。

とりあえず、ヘルプを見てみましょう。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Launcher -Dexec.args='-h'

〜省略〜

Usage: launcher [-h] [COMMAND]
  -h, --help   show this help
Commands:
  cat
  grep

ヘルプでサブコマンドが見れますね。

サブコマンドで「grep」を指定して、ヘルプを見てみましょう。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Launcher -Dexec.args='grep -h'

〜省略〜

Usage: launcher grep [-hnv] PATTERN FILE
      PATTERN
      FILE
  -h, --help           show this help
  -n, --line-number    display match line number
  -v, --invert-match   print invert match

grepのヘルプが見れますね。

もちろん、サブコマンドを指定して実行もできます。

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.picocli.Launcher -Dexec.args='grep -n Python zen_of_python.txt'

〜省略〜

2: The Zen of Python, by Tim Peters

補足

今回はコマンドの中身をCallable#callとして実装しましたが、その他にもRunnableとIExitCodeGeneratorインターフェースを実装したりする
方法もあったり

Executing Commands

オプション、引数のパース結果をもっと細かくコントロールする方法もあったりするようです。

DIY Command Execution

どっちにしろ、機能がとても多くて書ききれないので、今回はこのくらいにしておきます。

ネイティブイメージを作る

最後に、GraalVMを使ってネイティブイメージを作ってみましょう。

GraalVM Native Image

Pluggable Annotation Processing APIを使ったモジュールがあるので、こちらをpom.xmlに設定します。

Annotation Processor

Maven Compiler Pluginの、annotationProcessorPathsとして指定します。

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>info.picocli</groupId>
                            <artifactId>picocli-codegen</artifactId>
                            <version>4.2.0</version>
                        </path>
                    </annotationProcessorPaths>
                    <compilerArgs>
                        <arg>-Aproject=${groupId}/${artifactId}</arg>
                    </compilerArgs>
                </configuration>
            </plugin>
        </plugins>
    </build>

この状態でビルドすると

$ mvn compile

「compilerArgs」で指定したパス(「META-INF/native-image/picocli-generated」配下ですが)に、ファイルが生成されます。

$ find target/classes/META-INF/native-image/picocli-generated/org.example/picocli-example -type f
target/classes/META-INF/native-image/picocli-generated/org.example/picocli-example/proxy-config.json
target/classes/META-INF/native-image/picocli-generated/org.example/picocli-example/reflect-config.json
target/classes/META-INF/native-image/picocli-generated/org.example/picocli-example/resource-config.json

今回は、リフレクションに関する設定だけ、意味のある値が生成されました。
target/classes/META-INF/native-image/picocli-generated/org.example/picocli-example/reflect-config.json

[
  {
    "name" : "org.littlewings.picocli.Cat",
    "allDeclaredConstructors" : true,
    "allPublicConstructors" : true,
    "allDeclaredMethods" : true,
    "allPublicMethods" : true,
    "fields" : [
      { "name" : "paths" },
      { "name" : "printLineNumber" },
      { "name" : "showHelp" }
    ]
  },
  {
    "name" : "org.littlewings.picocli.Grep",
    "allDeclaredConstructors" : true,
    "allPublicConstructors" : true,
    "allDeclaredMethods" : true,
    "allPublicMethods" : true,
    "fields" : [
      { "name" : "path" },
      { "name" : "pattern" },
      { "name" : "printInvertMatch" },
      { "name" : "printLineNumber" },
      { "name" : "showHelp" }
    ]
  },
  {
    "name" : "org.littlewings.picocli.Launcher",
    "allDeclaredConstructors" : true,
    "allPublicConstructors" : true,
    "allDeclaredMethods" : true,
    "allPublicMethods" : true,
    "fields" : [
      { "name" : "showHelp" }
    ]
  }
]

あとは、GraalVMのNative Image Maven Pluginを使います。

Integration with Maven

こんな感じで。

                <plugins>
                    <plugin>
                        <groupId>org.graalvm.nativeimage</groupId>
                        <artifactId>native-image-maven-plugin</artifactId>
                        <version>19.3.1</version>
                        <executions>
                            <execution>
                                <goals>
                                    <goal>native-image</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                        <configuration>
                            <skip>false</skip>
                            <imageName>cat</imageName>
                            <buildArgs>
                                --no-fallback
                            </buildArgs>
                            <mainClass>org.littlewings.picocli.Cat</mainClass>
                        </configuration>
                    </plugin>
                </plugins>

コマンドが3つあるので、今回はProfileで分けました。

    <profiles>
        <profile>
            <id>native-cat</id>
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.graalvm.nativeimage</groupId>
                        <artifactId>native-image-maven-plugin</artifactId>
                        <version>19.3.1</version>
                        <executions>
                            <execution>
                                <goals>
                                    <goal>native-image</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                        <configuration>
                            <skip>false</skip>
                            <imageName>cat</imageName>
                            <buildArgs>
                                --no-fallback
                            </buildArgs>
                            <mainClass>org.littlewings.picocli.Cat</mainClass>
                        </configuration>
                    </plugin>
                </plugins>
            </build>
        </profile>
        <profile>
            <id>native-grep</id>
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.graalvm.nativeimage</groupId>
                        <artifactId>native-image-maven-plugin</artifactId>
                        <version>19.3.1</version>
                        <executions>
                            <execution>
                                <goals>
                                    <goal>native-image</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                        <configuration>
                            <skip>false</skip>
                            <imageName>grep</imageName>
                            <buildArgs>
                                --no-fallback
                            </buildArgs>
                            <mainClass>org.littlewings.picocli.Grep</mainClass>
                        </configuration>
                    </plugin>
                </plugins>
            </build>
        </profile>
        <profile>
            <id>native-launcher</id>
            <build>
                <plugins>
                    <plugin>
                        <groupId>org.graalvm.nativeimage</groupId>
                        <artifactId>native-image-maven-plugin</artifactId>
                        <version>19.3.1</version>
                        <executions>
                            <execution>
                                <goals>
                                    <goal>native-image</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                        <configuration>
                            <skip>false</skip>
                            <imageName>launcher</imageName>
                            <buildArgs>
                                --no-fallback
                            </buildArgs>
                            <mainClass>org.littlewings.picocli.Launcher</mainClass>
                        </configuration>
                    </plugin>
                </plugins>
            </build>
        </profile>
    </profiles>

では、ビルド。

$ mvn -P native-cat package && mvn -P native-grep package && mvn -P native-launcher package

確認。

$ ./target/cat -p true zen_of_python.txt 
1: The Zen of Python, by Tim Peters
2: 
3: Beautiful is better than ugly.
4: Explicit is better than implicit.
5: Simple is better than complex.
6: Complex is better than complicated.
7: Flat is better than nested.
8: Sparse is better than dense.
9: Readability counts.
10: Special cases aren't special enough to break the rules.
11: Although practicality beats purity.
12: Errors should never pass silently.
13: Unless explicitly silenced.
14: In the face of ambiguity, refuse the temptation to guess.
15: There should be one-- and preferably only one --obvious way to do it.
16: Although that way may not be obvious at first unless you're Dutch.
17: Now is better than never.
18: Although never is often better than *right* now.
19: If the implementation is hard to explain, it's a bad idea.
20: If the implementation is easy to explain, it may be a good idea.
21: Namespaces are one honking great idea -- let's do more of those!


$ ./target/grep -v Ubuntu ubuntu_version.txt 
Release:    18.04
Codename:   bionic


$ ./target/launcher --help
Usage: launcher [-h] [COMMAND]
  -h, --help   show this help
Commands:
  cat
  grep

OKですね。

まとめ

Javaコマンドラインアプリケーション向けのフレームワークである、picocliを試してみました。

最初はドキュメントの量を見てすごいなーと思いましたが、わかりやすく書かれていてとっつきやすかったと思います。

GraalVMによる、ネイティブイメージの作成に対応しているところもよいですね。

CLIを作る時には、今度からこちらを活用していきましょう。

Infinispan Server(Hot Rod)で、認証・認可設定を行う

これは、なにをしたくて書いたもの?

Infinispan Serverを使った、認証・認可まわりについて、ちゃんと設定したことがなかった気がするので、少しやってみようかなと。

ServerNGになって、ユーザーを追加するためのスクリプトも変わったりしているので、確認の機会としても良いでしょう。

認証・認可といっても、今回はHot Rodでのケースにフォーカスしてみていきます。

Infinispan Server(Hot Rod)の認証・認可まわりの設定

なんの話かというと、Infinispan Serverへアクセスする際、認証・認可をどのように設定するか、という話ですね。

Infinispan Serverを使った認証・認可まわりの設定は、大きく以下のことをする必要があります。

  • Infinispan Server
    • Cache Containerの認可設定
    • Cacheの認可設定
    • Security Realmの設定
    • Endpointの認証設定
  • Infinispan Client
    • 認証設定

今回は、上記のRealmまわりを除いて扱っていきます。また、SSLTLSや認証方法も絞ったり外したりします。

認証・認可に関する、Infinispan Serverのドキュメントはこちら。

Securing Infinispan Servers

ざっくり、認証、認可まわりの設定の流れを書いていきます。

ユーザーはInfinispan Server組み込みのプロパティファイルで管理する場合、コマンドでユーザーを作成し、所属するグループを指定します。

Property Realms

ここで、ユーザーが属するグループに対して、権限を割り当てます。
※後述しますが、1番シンプルな権限の管理方法の場合、です

割り当てられる権限は、設定要素「infinispan/cache-container/security/authorization/role」で指定していきます。

一覧は、以下に記載があります。

urn:infinispan:config:10.1

  • LIFECYCLE … Cacheのライフサイクルをコントロールすることを許可(たとえば、Cacheの起動・停止)
  • READ … Cacheからのデータの読み込みを許可
  • WRITE … Cacheへのデータの書き込みを許可
  • EXEC … Cache上でのタスクの実行を許可(たとえば、Executorsなど)
  • LISTEN … CacheへのListenerのアタッチを許可
  • BULK_READ … バルクRead操作を許可(たとえば、Cacheの全キーを取得するなど)
  • BULK_WRITE … バルクWrite操作を許可(たとえば、Cacheをクリアするなど)
  • ADMIN … キャッシュに対する管理上の操作を許可する
  • ALL … すべての権限をまとめた
  • ALL_READ … Readに関する権限をまとめたもの(READとBULK_READ)
  • ALL_WRITE … Writeに関する権限をまとめたもの(WRITEとBULK_WRITE)
  • NONE … なにも権限を持っていないことを表す権限

ここで作成したユーザーを使って、Hot Rod Clientからアクセスします。

Configuring Authentication Mechanisms for Hot Rod Clients

この時、認証メカニズムの指定が必要です。

Hot Rod Endpoint Authentication Mechanisms

認証メカニズムは、ざっと以下の種類があります(*がある部分は細かい種類を省略しています)。

  • PLAIN
  • DIGEST-*
  • SCRAM-*
  • GSSAPI
  • GS2-KRB5
  • EXTERNAL
  • OAUTHBEARER

認証時にどのようなメカニズムをクライアントが指定できるかは、Infinspan Server側のEndpointで設定します。

Setting Up Hot Rod Authentication

今回は、いろいろあってPLAINとDIGEST-*を試しています(ソースコードとして載せているのはPLAINだけですが)。

では、設定していってみましょう。

環境

今回の環境は、こちらです。

$ java --version
openjdk 11.0.6 2020-01-14
OpenJDK Runtime Environment (build 11.0.6+10-post-Ubuntu-1ubuntu118.04.1)
OpenJDK 64-Bit Server VM (build 11.0.6+10-post-Ubuntu-1ubuntu118.04.1, mixed mode, sharing)


$ mvn --version
Apache Maven 3.6.3 (cecedd343002696d0abb50b32b541b8a6ba2883f)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 11.0.6, vendor: Ubuntu, runtime: /usr/lib/jvm/java-11-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.3.0-40-generic", arch: "amd64", family: "unix"

Infinispan Serverは、10.1.3.Finalを使用し、動作しているホストのIPアドレスは172.17.0.2とします。

準備

クライアント側で使用する、Maven依存関係とプラグインはこちら。

    <dependencies>
        <dependency>
          <groupId>org.infinispan</groupId>
          <artifactId>infinispan-client-hotrod</artifactId>
          <version>10.1.3.Final</version>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.6.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>5.6.0</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.15.0</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

クライアントは、infinispan-client-hotrodを使用します。あとは、JUnit 5とAssertJをテストコード用に追加して、合わせてMaven Surefire Pluginも設定。

また、サーバー側については、デフォルトのInfinispan Serverの設定ファイル「server/conf/infinispan.xml」を確認しておきましょう。
特に関連しそうな箇所を見ておきます。

Cache Container/Cacheの設定(Cacheはまだありませんが)。

   <cache-container name="default" statistics="true">
      <transport cluster="${infinispan.cluster.name}" stack="${infinispan.cluster.stack:tcp}" node-name="${infinispan.node.name:}"/>
      <metrics gauges="true" histograms="true"/>
   </cache-container>

Security Realmの設定(今回は触りません)。

      <security>
         <security-realms>
            <security-realm name="default">
               <!-- Uncomment to enable TLS on the realm -->
               <!-- server-identities>
                  <ssl>
                     <keystore path="application.keystore" relative-to="infinispan.server.config.path"
                               keystore-password="password" alias="server" key-password="password"
                               generate-self-signed-certificate-host="localhost"/>
                  </ssl>
               </server-identities-->
               <properties-realm groups-attribute="Roles">
                  <user-properties path="users.properties" relative-to="infinispan.server.config.path" plain-text="true"/>
                  <group-properties path="groups.properties" relative-to="infinispan.server.config.path" />
               </properties-realm>
            </security-realm>
         </security-realms>
      </security>

Endpointの設定。

      <endpoints socket-binding="default" security-realm="default">
         <hotrod-connector name="hotrod"/>
         <rest-connector name="rest"/>
      </endpoints>

テストコードの雛形

Infinispan Serverへのアクセスは、Hot Rod Clientを使ったテストコードで見ていくことにします。

雛形は、こんな感じで。
src/test/java/org/littlewings/infinispan/authentication/AuthnAuthzTest.java

package org.littlewings.infinispan.authentication;

import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.client.hotrod.RemoteCacheManager;
import org.infinispan.client.hotrod.configuration.Configuration;
import org.infinispan.client.hotrod.configuration.ConfigurationBuilder;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

public class AuthnAuthzTest {

    // ここに、テストを書く!
}

まずは、認証・認可なしで

最初は、認証・認可に関する設定を行わずに確認してみましょう。

ひとつ、シンプルなCacheを追加。
※まだCLIを使いこなせいないので、今回は手で作成…(ServerNGで、このあたりができるかどうかもわかっていません)

   <cache-container name="default" statistics="true">
      <transport cluster="${infinispan.cluster.name}" stack="${infinispan.cluster.stack:tcp}" node-name="${infinispan.node.name:}"/>
      <metrics gauges="true" histograms="true"/>

      <distributed-cache name="plainCache"/>
   </cache-container>

1度、Infinispan Serverを再起動します。

このCacheを使うテストコードを書いてみます。

    @Test
    public void plainCache() {
        Configuration configuration =
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .build();

        RemoteCacheManager manager = new RemoteCacheManager(configuration);

        try {
            RemoteCache<String, String> cache = manager.getCache("plainCache");

            cache.put("key1", "value1");
            assertThat(cache.get("key1")).isEqualTo("value1");
            assertThat(cache.size()).isEqualTo(1L);

            cache.clear();
            assertThat(cache.size()).isEqualTo(0L);
        } finally {
            manager.stop();
        }
    }

まあ、こちらはふつうに動きます。

Infinispan Serverに、認証・認可設定を行う

では、Infinispan Serverに認証・認可まわりの設定を行っていきます。

まず、(設定がわかりやすいので)Cacheに対する認可設定を行いましょう。

以下のドキュメントを見つつ。

Cache Authorization Configuration

以下のように、「cache-container/security/authorization」要素内にroleを作成して、「cache-container/cache/security/authorization」要素から
参照するように設定します。

   <cache-container name="default" statistics="true">
      <transport cluster="${infinispan.cluster.name}" stack="${infinispan.cluster.stack:tcp}" node-name="${infinispan.node.name:}"/>
      <metrics gauges="true" histograms="true"/>

      <security>
         <authorization>
            <identity-role-mapper/>
            <role name="admin" permissions="ALL"/>
            <role name="reader-writer" permissions="READ WRITE"/>
            <role name="writer" permissions="WRITE"/>
            <role name="reader" permissions="READ"/>
         </authorization>
      </security>

      <distributed-cache name="plainCache"/>

      <distributed-cache name="securedCache">
         <security>
            <authorization roles="admin reader-writer writer reader"/> 
         </security>
      </distributed-cache>
   </cache-container>

ロールの設定はこちらですね。

      <security>
         <authorization>
            <identity-role-mapper/>
            <role name="admin" permissions="ALL"/>
            <role name="reader-writer" permissions="READ WRITE"/>
            <role name="writer" permissions="WRITE"/>
            <role name="reader" permissions="READ"/>
         </authorization>
      </security>

ロール名は任意で、ロールに対してどのような権限を割り当てるかをpermissions属性で指定します。複数の権限を指定する場合は、
スペース区切りで記載します。

作成したロールを、Cacheに対して割り当てます。「security/authorization」要素のrolesで指定します。複数のroleを指定する場合は、
スペース区切りで指定します。

      <distributed-cache name="securedCache">
         <security>
            <authorization roles="admin reader-writer writer reader"/> 
         </security>
      </distributed-cache>

「identity-role-mapper」というのは、ロールとユーザーの紐付けに関する設定です。

PrincipalRoleMapper (Infinispan JavaDoc All 10.1.3.Final API)

「identity-role-mapper」は1番シンプルな設定で、ロール名をPrincipalの名前(グループ名)と紐付けます。

IdentityRoleMapper (Infinispan JavaDoc All 10.1.3.Final API)

他には、「common-name-role-mapper」や「cluster-role-mapper」、自分でPrincipalRoleMapper自体を指定する「custom-role-mapper」が
指定できます。

指定方法自体はこちら。

urn:infinispan:config:10.1

実装は、こちら。

https://github.com/infinispan/infinispan/blob/10.1.3.Final/core/src/main/java/org/infinispan/security/mappers/IdentityRoleMapper.java

https://github.com/infinispan/infinispan/blob/10.1.3.Final/core/src/main/java/org/infinispan/security/mappers/CommonNameRoleMapper.java

https://github.com/infinispan/infinispan/blob/10.1.3.Final/core/src/main/java/org/infinispan/security/mappers/ClusterRoleMapper.java

というわけで、今回は「identity-role-mapper」を使っているので、ユーザーの所属するグループがそのままロールの名前になります。

次に、ユーザーを作成します。今回は、Infinispan Serverでデフォルトで使える、プロパティファイルでのユーザー・グループの管理とします。

Adding Users to Property Realms

先ほど記載したとおり、グループ名がそのままロール名になるため、ロールに合わせた形でグループを管理していけばOKです。

以下のスクリプトを使うことになります。

$ bin/user-tool.sh -h
Infinispan User Tool 10.1.3.Final (Turia)
Copyright (C) Red Hat Inc. and/or its affiliates and other contributors
License Apache License, v. 2.0. http://www.apache.org/licenses/LICENSE-2.0
Usage:
  -u, --user=<name>                  Specifies the name of the user to add.
  -p, --password=<password>          Specifies the password for the user.
  -d, --digest                       Store passwords in digest format (WARN: works only with DIGEST-MD5/Digest authentication).
  -g, --groups=<group1[,group2...]>  Adds the user to a comma-separated list of groups.
  -f, --users-file=<file>            Sets the name of the users properties file relative to the server configuration path. Defaults to `users.properties`.
  -w, --groups-file=<file>           Sets the name of the groups properties file relative to the server configuration path. Defaults to `groups.properties`.
  -r, --realm=<realm>                Sets the name of the realm. Defaults to `default`.
  -s, --server-root=<path>           Specifies the root path for the server. Defaults to `server`.
  -b, --batch-mode                   Do not ask for confirmation when overwriting existing users.
  -h, --help                         Displays usage information and exits.
  -v, --version                      Displays version information and exits.

先ほど作成したロールを、グループにマッピングしてユーザーを登録していきます。先にヘルプで表示したとおり、「-u」でユーザー名、
「-p」でパスワード、「-g」で所属するグループを指定します。グループは、カンマ区切りで複数指定することができます。

# 「admin」グループに属するユーザー
$ bin/user-tool.sh -u admin -p password -g admin

# 「reader」グループに属するユーザー
$ bin/user-tool.sh -u read-only-user -p password -g reader

# 「writer」グループに属するユーザー
$ bin/user-tool.sh -u write-only-user -p password -g writer

# 「reader-writer」グループに属するユーザー
$ bin/user-tool.sh -u read-write-user-simple -p password -g reader-writer

# 「reader」と「writer」グループの両方に属するユーザー
$ bin/user-tool.sh -u read-write-user-multi -p password -g reader,writer

ところで、ここで作成したユーザーおよびグループは、Security Realmに記載してあった以下のファイルに追加されます。

               <properties-realm groups-attribute="Roles">
                  <user-properties path="users.properties" relative-to="infinispan.server.config.path" plain-text="true"/>
                  <group-properties path="groups.properties" relative-to="infinispan.server.config.path" />
               </properties-realm>

ちょっとファイルを見てみましょう。
server/conf/groups.properties

#Sun Mar 01 03:45:38 UTC 2020
write-only-user=writer
read-write-user-multi=reader,writer
read-write-user-simple=reader-writer
read-only-user=reader
admin=admin

server/conf/users.properties

#$REALM_NAME=default$
#Sun Mar 01 03:45:38 UTC 2020
write-only-user=password
read-write-user-multi=password
read-write-user-simple=password
read-only-user=password
admin=password

とってもシンプル。

その他のRealmを使いたい場合は、こちらを参照して設定していくようですが、今回はパスです。

Defining Infinispan Server Security Realms

最後に、Hot Rod Endpointの設定を行います。こちらで認証メカニズムの設定を行います。

Setting Up Hot Rod Authentication

ドキュメント通りですが、以下のように設定。

      <endpoints socket-binding="default" security-realm="default">
         <hotrod-connector name="hotrod">
             <authentication>
                <sasl mechanisms="SCRAM-SHA-512 SCRAM-SHA-384 SCRAM-SHA-256 
                                  SCRAM-SHA-1 DIGEST-SHA-512 DIGEST-SHA-384
                                  DIGEST-SHA-256 DIGEST-SHA DIGEST-MD5 PLAIN"
                      server-name="infinispan" 
                      qop="auth"/> 
             </authentication>
         </hotrod-connector>
         <rest-connector name="rest"/>
      </endpoints>

このmechanismsで列挙したのが、クライアントから指定する認証メカニズムで、クライアントはこの中からひとつ選んで使うことに
なります。

server-nameは、クライアントから見た時のサーバー名で、クライアントが認識しているサーバー名と一致させておくべきだとか。
※接続先のサーバーリストとして指定するものとはまた別で、認証に特化したものです

qopというのは、Quality of Protectionの略です。

Ldapwiki: Quality of Protection

HTTP クライアントを作ってみよう(6) - Digest 認証編 -

security-reamについては、今回は触らないと記載したSecurity Reamの設定と参照関係にありそうな気がしますが、今回は追って
いません。クライアント側でもRealmの指定はできそうなので、関連がまだ見えていません…。

ここまで設定したら、Infinispan Serverを再起動します。

ちなみに、このHot Rod Endpointの設定をした時点で、Cacheへのアクセスに認証・認可設定が必要になってしまい、最初に書いた
Cacheとコードは使えなくなるのでテストは無効にしておきます。

    @Disabled
    @Test
    public void plainCache() {

そのままにしておくと、こんな感じにエラーになります。どちらかというと、認可で弾かれているようですね。
Cacheの「authorization」要素の「enabled」属性をfalseにしても、この挙動は変わりません。

org.infinispan.client.hotrod.exceptions.HotRodClientException:Request for messageId=84 returned server error (status=0x85): java.lang.SecurityException: ISPN006017: Unauthorized 'PUT' operation

Endpointに認証設定を入れると、Cacheには認可設定が必須になるのだなぁ、という感じでしょうかね。

Hot Rod Clientを使って認証設定を行う

では、認証設定を使ったコードを書いていってみます。

認証の確認

まずは簡単に、認証できるパターンとそうでないパターンを。

    @Test
    public void authentication() {
        RemoteCacheManager manager1 = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("admin")
                        .password("password")
                        .build());

        try {
            assertThat(manager1.getCache("securedCache")).isNotNull();
        } finally {
            manager1.stop();
        }

        //////////////////////

        RemoteCacheManager manager2 = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("unknown")
                        .password("bad-password")
                        .build());

        try {
            assertThatThrownBy(() -> manager2.getCache("securedCache"))
                    .hasMessage("org.infinispan.client.hotrod.exceptions.HotRodClientException:Request for messageId=80 returned server error (status=0x84): javax.security.sasl.SaslException: ELY05013: Authentication mechanism password not verified");
        } finally {
            manager2.stop();
        }
    }

最初に、「admin」ユーザーで認証を行うコードを書いています。認証メカニズムは、「PLAIN」を選択。認可設定を行ったCacheを、
問題なく取得することが確認できます。

        RemoteCacheManager manager1 = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("admin")
                        .password("password")
                        .build());

        try {
            assertThat(manager1.getCache("securedCache")).isNotNull();
        } finally {
            manager1.stop();
        }

反対に、認証に失敗するように存在しないユーザーを指定すると、Cacheの取得時に失敗します。

        RemoteCacheManager manager2 = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("unknown")
                        .password("bad-password")
                        .build());

        try {
            assertThatThrownBy(() -> manager2.getCache("securedCache"))
                    .hasMessage("org.infinispan.client.hotrod.exceptions.HotRodClientException:Request for messageId=80 returned server error (status=0x84): javax.security.sasl.SaslException: ELY05013: Authentication mechanism password not verified");
        } finally {
            manager2.stop();
        }

どうやら、認証についてもCacheを取得する時がポイントになるようですね。

認可の確認1

次に、認可についてを見ていきましょう。

「read-only-user」と「write-only-user」の読み込み、書き込みのどちらかしかできないユーザーを使って確認。

    @Test
    public void readWriteOnlyUser() {
        RemoteCacheManager manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("read-only-user")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            assertThatThrownBy(() -> cache.put("key1", "value1"))
                    .hasMessage("java.lang.SecurityException: ISPN000287: Unauthorized access: subject 'Subject with principal(s): [read-only-user, RolePrincipal{name='reader'}, InetAddressPrincipal [address=172.17.0.1/172.17.0.1]]' lacks 'WRITE' permission");
        } finally {
            manager.stop();
        }

        //////////////////////

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("write-only-user")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.put("key1", "value1");
            assertThatThrownBy(() -> cache.get("key1"))
                    .hasMessage("java.lang.SecurityException: ISPN000287: Unauthorized access: subject 'Subject with principal(s): [write-only-user, RolePrincipal{name='writer'}, InetAddressPrincipal [address=172.17.0.1/172.17.0.1]]' lacks 'READ' permission");
        } finally {
            manager.stop();
        }

        //////////////////////

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("read-only-user")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            assertThat(cache.get("key1")).isEqualTo("value1");
        } finally {
            manager.stop();
        }

        //////////////////////

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("admin")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.clear();
        } finally {
            manager.stop();
        }
    }

まず、READ権限しか持たない「read-only-user」では、Cache自体の取得はできるものの、書き込みができないことを確認。

        RemoteCacheManager manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("read-only-user")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            assertThatThrownBy(() -> cache.put("key1", "value1"))
                    .hasMessage("java.lang.SecurityException: ISPN000287: Unauthorized access: subject 'Subject with principal(s): [read-only-user, RolePrincipal{name='reader'}, InetAddressPrincipal [address=172.17.0.1/172.17.0.1]]' lacks 'WRITE' permission");
        } finally {
            manager.stop();
        }

「write-only-user」であれば、WRITE権限があるので書き込みが可能です。ですが、READ権限がないので、登録したエントリの
取得には失敗します。

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("write-only-user")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.put("key1", "value1");
            assertThatThrownBy(() -> cache.get("key1"))
                    .hasMessage("java.lang.SecurityException: ISPN000287: Unauthorized access: subject 'Subject with principal(s): [write-only-user, RolePrincipal{name='writer'}, InetAddressPrincipal [address=172.17.0.1/172.17.0.1]]' lacks 'READ' permission");
        } finally {
            manager.stop();
        }

Cacheに登録したエントリの取得は、「read-only-user」で。

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("read-only-user")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            assertThat(cache.get("key1")).isEqualTo("value1");
        } finally {
            manager.stop();
        }

最後にCacheエントリのクリアをしているのですが、これにはBULK_WRITE権限が必要なので、今回は「admin」ユーザーで行っています。

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("admin")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.clear();
        } finally {
            manager.stop();
        }
認可の確認2

続いて、READ権限とWRITE権限の両方を持たせたロール(グループ)に所属するユーザーで、READもWRITEもできることを確認。

    @Test
    public void readWriteRole1() {
        RemoteCacheManager manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("read-write-user-simple")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.put("key1", "value1");
            assertThat(cache.get("key1")).isEqualTo("value1");
        } finally {
            manager.stop();
        }

        //////////////////////

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("admin")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.clear();
        } finally {
            manager.stop();
        }
    }

もう1パターン。ユーザーを複数のロール(グループ)に所属させるパターン。

    @Test
    public void readWriteRole2() {
        RemoteCacheManager manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("read-write-user-multi")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.put("key1", "value1");
            assertThat(cache.get("key1")).isEqualTo("value1");
        } finally {
            manager.stop();
        }

        //////////////////////

        manager = new RemoteCacheManager(
                new ConfigurationBuilder()
                        .addServers("172.17.0.2:11222")
                        .security()
                        .authentication()
                        .saslMechanism("PLAIN")
                        .username("admin")
                        .password("password")
                        .build());

        try {
            RemoteCache<String, String> cache = manager.getCache("securedCache");

            cache.clear();
        } finally {
            manager.stop();
        }
    }

いずれも、CacheへのWRITE、READができることが確認できました。

簡単な認証・認可の確認としては、こんなところでしょうか?

権限について

ここまで、Cache#putならWRITE、Cache#getならREAD、Cache#clearならBULK_WRITEとドキュメントからの記載や類推から
使ってきましたが、これを正確にマッピングを取りたい場合は、ソースコードを見るのかなぁと思います。

SecureCacheの実装を見ればよいと思います。

https://github.com/infinispan/infinispan/blob/10.1.3.Final/core/src/main/java/org/infinispan/security/impl/SecureCacheImpl.java

各メソッドが、どのような権限を必要とするかがわかります。

   @Override
   public String getVersion() {
      authzManager.checkPermission(subject, AuthorizationPermission.ADMIN);
      return delegate.getVersion();
   }

   @Override
   public V put(K key, V value) {
      authzManager.checkPermission(subject, AuthorizationPermission.WRITE);
      return delegate.put(key, value);
   }

   @Override
   public CompletableFuture<Void> putAllAsync(Map<? extends K, ? extends V> data) {
      authzManager.checkPermission(subject, AuthorizationPermission.WRITE);
      return delegate.putAllAsync(data);
   }

認証メカニズムについて

ところで、今回は認証メカニズムを「PLAIN」と明示しました。

Infinispan 10.1.3.Finalでは、認証メカニズムを指定しない場合、デフォルトでは「SCRAM-SHA-512」が使われます。

https://github.com/infinispan/infinispan/blob/10.1.3.Final/client/hotrod-client/src/main/java/org/infinispan/client/hotrod/configuration/AuthenticationConfigurationBuilder.java#L33

なのですが、なにも考えずに使うとこんな感じで怒られるので、今回は1度パスしました…。

org.infinispan.client.hotrod.exceptions.TransportException:: javax.security.sasl.SaslException: ELY05051: Callback handler does not support credential acquisition [Caused by java.security.NoSuchAlgorithmException: class configured for PasswordFactory (provider: WildFlyElytron) cannot be found.]

今回は、とりあえずなんらかの形でも認証・認可の設定をまとめることを目標に、としたので。

まとめ

今回は、Infinispan Serverを使った認証・認可の設定を行ってみました。

最初、Hot Rod Connectorの部分の設定を見落としていてけっこうハマったのですが、今回で設定として必要な要素は概ね把握できた
気がするので、いったんこれでOKかなと。

気が向いたら、もうちょっと深堀りしてみましょう。

今回作成したソースコードは、こちらに置いています。

https://github.com/kazuhira-r/infinispan-getting-started/tree/master/remote-authn-authz