RBACの基礎
業務システムの権限制御の基本形はロールベースアクセスコントロール(RBAC)です。簡単化すると、以下のようなモデルです。
- Subject(システムユーザ)は、複数のRole(ロール)を持っている。
- Role(ロール)は、Permission(権限)のセットからなる。
- Permission(権限)は、オペレーション(許可される操作)のセットからなる
具体的に、Redmineでの例をみてみましょう。
- ユーザにはデフォルトで「管理者」「開発者」「報告者」のロールが割当可能である。
- 「報告者」ロールは、「Add Issues」の権限をもつ。
- 「Add Issues」の権限をもつユーザは、「Issueの新規作成」ができる。
このモデルをRedmineでは、以下のように表現しています。
Redmineは1人のユーザを、複数のプロジェクトに異なるロールでアサインすることができるので、上記のようなMemberエンティティを間に挟むモデルになっています。RBACの原則をちょっと応用すれば、複雑な業務要件にも整理された考え方で適応できます。
権限のテーブルが見当たりませんが、ロールテーブルのpermissionsカラムにカンマ区切りで入るようになっています。いわゆるSQLアンチパターンでいうところの、ジェイウォークの設計です。これはRedmineのプラグイン側が自由に権限追加が可能になっているため、という言い訳になっているんじゃないかと推測します。
RBACは非常にリーズナブルな設計のようにみえますが、実際こう設計されている業務システムを見かける方が少ないように思えます。組織=ロールにしたり、ロール=パーミッションにしたりする設計、心当たりある人いるのではないでしょうか。またフレームワークもきちんとRBACをサポートしているものが少ないように思えます。
RBACに設計しないときの問題
組織 = ロール
これがよくない理由は想像つくと思いますが、組織は頻繁に変わるものです。これをロールとしてしまうと、その都度大量のデータメンテが必要になってきます。お客さんからは、組織名でロール的な要件を言われることが多いのですが、必ず別途ロールを設計し、組織とマッピングするようにしましょう。
ロール=権限
ロールに相当するものが無いパターン。ユーザへの権限の割当が膨大になりがちです。ロールという業務的権限の集合を見いだせてないので、C2カバレッジを満たそうとすると、保有している権限の組み合わせ分テストが必要になります。
権限 = オペレーション
1つの権限で複数のオペレーションを制御することはあります。後述しますが、ある権限Aをもつということは、とあるURLへのアクセスを許可するだけでなく、そこへ至るためのリンク・ボタンを出し分けるなどで使うこともあります。権限制御したいオペレーションをグルーピングして、権限として設計するようにしましょう。
Javaでの実装
Javaにも業務アプリケーションで使えそうなロールの仕組みがServlet APIにあったりします。しかし…ロールは柔軟な運用が求められることが多いのです。管理者があらたに作ったり消したりできるようにしたい、という要件もよくあります。そうするとJSR250やServlet APIのように、ロール名をアノテーションに付けたり、isUserInRoleのように引数にロール名
を渡すのは固すぎて具合が良くありません。RBACの定義にしたがえば、「権限」を渡せるようになっているのが自然です。
そこで、JSR250のアノテーションを使いつつ、そこに渡すパラメータをロール名でなく、権限名にしたアプリケーションExampleをNinjaframeworkをベースに作ってみましたので、これで説明します。
Exampleの説明
初期データとして、ロールを3種類用意していて、以下のような権限を持たせてあります。
ロール | 権限 |
---|---|
Administrator | readIssue, writeIssue, manageUser |
Developer | readIssue, writeIssue |
Guest | readIssue |
ログイン時の権限取得
ログイン時に、ユーザのもつロールから権限を取得し、ユーザ情報と一緒に持たせておくのが現実的な設計になります。なので、ログイン情報としてセッションに持たせておくDTOを以下のように実装しています。
@Data
@RequiredArgsConstructor
public class UserPrincipal implements Principal {
@NonNull
private String name;
@NonNull
private Set<String> permissions;
}
これをログイン時に、パーミッションに変換しセッションに持たせておきます。
User user = em
.createQuery("SELECT u FROM User u LEFT JOIN FETCH u.roles r LEFT JOIN FETCH r.permissions p WHERE u.account = :account", User.class)
.setParameter("account", account)
.getSingleResult();
session.put("account", account);
Set<Permission> permissions = new HashSet<>();
user.getRoles().stream()
.map(Role::getPermissions)
.forEach(permissionsInRole -> permissions.addAll(permissionsInRole));
session.put("permissions", permissions.stream().map(Permission::getName)
.collect(Collectors.joining(",")));
NinjaframeworkのセッションはCookie格納なので↑のような文字列だけ入れることになっています(これはNinjaframeworkを使わない理由になりますねぇ)。
メソッドのアクセス許可
こうしておいて、コントローラメソッドにディスパッチするときに、持っている権限とアクションメソッドに付いている権限をチェックします。
認証のフィルタでセッションからUserPrincipalを復元します。
Session session = context.getSession();
if (session.get("account") == null) {
return Results.redirect("/login");
}
Set<String> permissions = new HashSet<>();
if (session.get("permissions") != null) {
permissions = ImmutableSet.copyOf(Splitter.on(',').split(session.get("permissions")));
}
context.setAttribute("principal", new UserPrincipal(session.get("account"), permissions));
その後、認可のフィルタでUserPrincipalがメソッドにアクセスできる権限を持っているかチェックすればよいわけです。コントローラのメソッドには、以下のようなJSR250のアノテーションに、必要なパーミッションを書いてるものとします。
@UnitOfWork
@RolesAllowed("readIssue")
public Result list(Context context) {
EntityManager em = entityManagerProvider.get();
List<Issue> issues = em
.createQuery("SELECT i FROM Issue i", Issue.class)
.getResultList();
Map<String, Object> bindings = VariablesHelper.create(context);
bindings.put("issues", issues);
return Results.html().render(bindings);
}
@UnitOfWork
@RolesAllowed("writeIssue")
public Result newIssue(Context context) {
return Results.html().render(VariablesHelper.create(context));
}
認可のチェックは以下のようになります。
Method method = context.getRoute().getControllerMethod();
RolesAllowed rolesAllowed = method.getAnnotation(RolesAllowed.class);
PermitAll permitAll = method.getAnnotation(PermitAll.class);
UserPrincipal principal = (UserPrincipal) context.getAttribute("principal");
if (permitAll != null ||
(rolesAllowed != null && contains(principal.getPermissions(), rolesAllowed.value()))) {
return filterChain.next(context);
} else {
return Results.forbidden().html().template("views/403forbidden.ftl.html");
}
これで上述のように、コントローラメソッドにアノテーションをつけるだけで良くなります。
ビュー層での権限による出し分け
権限の使い方はこれだけではなく、メニューやボタンの出し分けにも使うことでしょう。これもUserPrincipalから必要な権限を持っているかチェックして、ビュー側で出し分けすればよいです。
NinjaframeworkのデフォルトビューはFreemarkerなので、以下のような書き方ができます。
<a href="#" class="header item">
RBAC Example
</a>
<#if principal??>
<a href="/" class="item">Home</a>
</#if>
<#if principal?? && principal.permissions?seq_contains("readIssue")>
<a href="/issues/" class="item">Issue</a>
</#if>
<#if principal?? && principal.permissions?seq_contains("manageUser")>
<a href="/users/" class="item">Users</a>
</#if>
「Admin」権限を持つとき
「Admin」権限がないとき
こんな感じで出し分けができました。
Exampleの動かし方
% git clone https://github.com/kawasima/rbac-example.git
% cd rbac-example
% mvn compile
% mvn waitt:run
でExampleアプリを起動できます。
あ、このwaittというプラグインは、Spring Bootでなくとも、IDEの有償の機能を使わなくとも、Webアプリケーションをパッケージングすることなく、起動できる2015年注目のプラグインです。ぜひお試し下さい。
Spring security
Spring securityを使うと、RBACを実装できます。が、誌面の都合上割愛させていただきます。
まとめ
権限制御は業務システムでは最頻出要件であるにもかかわらず、設計の仕方をしっかり説いたものはなかなか見当たりませんし、テストや運用のしづらい設計がなされているシステムが多数あるのが現実かと思います。本記事が、少しでもその設計のお役に立てることを願っております。