OsoでRBACを実装してみる

はじめに

久しぶりに権限周りに関するエントリです。 Osoと呼ばれるライブラリを最近ふとしたタイミングで見つけたのでいろいろと実験してます。

www.osohq.com

Osoについて詳しく書ける時間ができたら書きたいのですが、この記事では軽く紹介するだけにとどめます。

  • Osoは認可(Authorization)に特化したライブラリ
  • コアな部分はRustで作られてますが、Node.js/Python/Go/Ruby/Rust/Java用のライブラリが提供されてる
  • PolicyにはPolarと呼ばれる言語が使われている(OPAで言うところのRego)

権限や認可についてあまり馴染みが無い方は、以下の記事を読んでみると雰囲気が少しつかめるかもしれません。

kenfdev.hateblo.jp

また、英語に抵抗が無い方であれば、Osoが書いてるAuthorization Academyっていう神ドキュメントがあるので、そちらをぜひ読んでみてください。

www.osohq.com

使ってみた

公式ドキュメントはかなり頑張って書かれてはいるものの、自分のコード(JSとか)とPolarがどのように関わってるのかが、実際に手を動かしてみないとわかりづらかったので、サンプルアプリでRBACを実装してみました。

ベースとなってるのは以下のトピックです。

docs.osohq.com

RBAC構成

以下のような権限を実現したいと思ってます。

f:id:kenev:20210922212912p:plain

「誰が(Actor)」、「何に(Resource)」、「何を(Action)」して良いのか。

という観点でこの図を表現すると、

  • contributerはRepositoryをreadすることができる
  • maintainerはRepositoryにpushすることができる

上記は結構単純です。Role(contributor/maintainer)にAction(read/push)が紐付いてて、対象がRepositoryになってます。

ここに、さらにちょっと複雑な要件も増やします。

  • contributerができることは、maintainerもできる
    • なので、maintainerはRepositoryをreadすることもできる
  • Repositoryを所有しているOrganizationのownerであれば、maintainerと同等なことができる
    • なので、単純にRoleにActionが紐付いてるわけではなく、Resourceの関連(Relation)も考慮している

上記を満たすためのPolicyを、Osoを使って書くことができます。

そこで登場するのがOsoのDSLであるPolarです。

Polar

Polarについても詳しいことは割愛してしまいますが、Policyを定義するためのOsoが作った専用の言語です。気になる人は以下のドキュメントを読んでみましょう。

docs.osohq.com

先程の要件を満たすPolarを書くと、(公式ドキュメントそのまんまですけど)以下のようになります。(シンタックスハイライトが無いので、キャプチャを貼り付けます)

f:id:kenev:20210922214912p:plain

全部で30行ほどのPolicyですが、結構Osoの裏側でこれだけでもいろいろなことが起きてます。

allowとhas_permission

エントリポイントとなってるのが1行目のallowです。自分のコード(JSなど)からOsoを呼び出すときの入り口となる部分です。 このallowがtrueかfalseなのか、という判断をPolicyに基づいてOsoが判断してくれます。allowはPolarに最初から組み込まれているRule Typeになります。

最初から組み込まれているRule Typeについては以下参照。

docs.osohq.com

そして、allowの中身は以下のように定義しています。

allow(actor, action, resource) if
  has_permission(actor, action, resource);

has_permissionがもし(if)trueだったら、allowもtrueという意味になってます。このhas_permissionも、Polarに最初から組み込まれている型になります。このhas_permissionの定義は、後ほどShorthand Ruleを書くときに重要になってきます。

has_roleとhas_relationに関してはいったん置いておいて、次はactorとresourceについて見ていきましょう。

actorとresource

actorとresourceの定義を見てみましょう。書き方は下のような感じです。

actor User {}

resource Organization {
# ...
}

resource Repository {
# ...
}

actorは「誰が」に対応する存在となります。で、resourceが「何に」の「何」に該当します。ここで宣言したactorとresourceに関しては、自分のコードと紐付けるときにそれぞれ、コード側のどの型に対応するのか、というのをマッピングする必要があります。

具体的にはJavaScriptであれば以下のようなコードで、OsoにUser, Organization, Repositoryを登録します。

class Organization {}
class Repository {}
class User {}

oso = new Oso();
oso.registerClass(Organization);
oso.registerClass(Repository);
oso.registerClass(User);

これで、JS側のエンティティと、Polarの中のエンティティを紐付けられているということです。

では、resourceの中身を見ていきましょう。まずはOrganizationから。

resource Organization {
  roles = ["owner"];
}

rolesという定義があります。これはRBAC(Role-Based Access Control)をする上で便利で、対象となるResourceに紐づくことのできるRoleを定義することができます。なので、ここではOrganizationというResourceに、ownerというRoleが紐づくことができると言ってます。

次にRepositoryを見てみましょう。こっちはもっと複雑です。

resource Repository {
  permissions = ["read", "push"];
  roles = ["contributor", "maintainer"];
  relations = { parent: Organization };

  # An actor has the "read" permission if they have the "contributor" role.
  "read" if "contributor";
  # An actor has the "push" permission if they have the "maintainer" role.
  "push" if "maintainer";

  # An actor has the "contributor" role if they have the "maintainer" role.
  "contributor" if "maintainer";

  # An actor has the "maintainer" role if they have the "owner" role on the "parent" Organization.
  "maintainer" if "owner" on "parent";
}

permissionsという定義があります。これは、対象となるResourceに対して、「何ができるか」というのを定義します。なので、ここではRepositoryに対して「readとpushを行うことができる」と定義してます。

rolesを見てみると、Repositoryに紐づくRoleはcontributorとmaintainerだと定義しています。

次に登場するのがrelationsの定義ですが、いったん飛ばしましょう。

まずは、3つほど文字列とifで構成されてるRuleを見てみましょう

"read" if "contributor";
"push" if "maintainer";
"contributor" if "maintainer";

読みやすいといえば読みやすいと思います。「contributerならreadできる」、「maintainerならpushできる」、「maintainerであればcontributerでもある」という感じです。

これは何なのかというと、Shorthand Ruleというものになります。Shorthandと言ってるように、要は省略記法です。

通常のRuleで表現すると以下のとおり。

$x if $y;
=> rule1(actor: Actor, $x, resource: $Type) if rule2(actor, $y, resource);

resource: $Type$Type に関しては、どのresource内で書かれていたかによって変動するということです。今回はresource Repositoryで使ってるので以下のようになります。

$x if $y;
=> rule1(actor: Actor, $x, resource: Resource) if rule2(actor, $y, resource);

で、$x がもしpermissionであれば、rule1has_permissionに置き換わりますし、 roleであれば、has_roleに置き換わります。同様なことが$yでも言えます。

なので、

"read" if "contributor";
↓
has_permission(actor: Actor, "read", resource: Repository) if has_role(actor, "contributor", resource);

というように読み換えることができます。ここでシレッとhas_roleを出しましたけど、これもまたPolarに最初から組み込まれているRule Typeの一つです。

先程は飛ばしましたが、あらためてhas_roleの定義を見ると、以下の通りです。

has_role(user: User, name: String, resource: Resource) if
  role in user.roles and
  role.name = name and
  role.resource = resource;

これを見たときにuser.rolesとかrole.nameってどこから現れたんだって思うかもしれません。ここが自分のコード(JSとか)とマッピングした型の情報になります。

class Role {
  constructor(name, resource) {
    this.name = name;
    this.resource = resource;
  }
}

class User {
  constructor(name) {
    this.name = name;
    this.roles = new Set();
  }

  assignRoleForResource(name, resource) {
    this.roles.add(new Role(name, resource));
  }
}

なので、has_roleのRuleが言っていることは、「もしuserのrolesの中で、role.nameが、引数で渡されたnameと一致して、role.resourceもresourceと一致するなら、確かにnameというRoleを持っているね」ということです。

もう一例みておくと

"contributor" if "maintainer";
↓
has_role(actor: Actor, "contributor", resource: Repository) if has_role(actor, "maintainer", resource);

というRuleもありました。これは「「maintainer」Roleを持っているのであれば、「contributor」Roleも持っていることにする」という意味があります。Roleの継承っぽいことができるイメージです。

では、最後に残っているのが以下のルールです。

"maintainer" if "owner" on "parent";

Shorthand Ruleなのですが、今回はonがついてます。これはRelationを使ったShorthand Ruleになります。

$x if $y on $z;
=> rule1(actor: Actor, $x, resource: $Type) if rule2(actor, $y, related) and has_relation(related, $z, resource);

今回の場合であれば

"maintainer" if "owner" on "parent";
↓
has_role(actor: Actor, "maintainer", resource: Repository) if has_role(actor, "owner", related) and has_relation(related, "parent", resource);

になります。「親Resourceにowner Roleを持っていて、Repositoryが、親Resourceとparentのリレーションをもっているのであれば、Repositoryに対してmaintainer Roleを持っているのと同等とする」という意味になります。

ここで登場しているのがhas_relationのRuleです。そして先程飛ばしていたresourceに定義しているrelationsも深く関係しています。

relations = { parent: Organization };

これは、Resource同士の「関連」を定義するときに使えます。ここで言っていることは、「RepositoryはOrganizationとparentリレーションを通して関連づいている」ということです。「parentリレーションってなに?」ってところで登場するのが、has_relationの定義です。

has_relationもまた最初から組み込まれてるPolarのRule Typeです。この中身に、Resource同士の「関連」を定義します。今回の定義を見てみると以下のとおりです。

has_relation(organization: Organization, "parent", repository: Repository) if
  organization = repository.organization;

これは、「もしorganizationが、repositoryに定義されているorganizationと一致するなら、OrganizationとRepositoryはparentリレーションを持っている」と定義しています。なので、Resourceの親子関係を持たせることができているのです。ちなみにここで登場しているorganizationやrepositoryは、自分のコード(JSとか)で定義した型のことです。それぞれ以下のように定義しています。

class Organization {
  constructor(name) {
    this.name = name;
  }
}

class Repository {
  constructor(name, organization) {
    this.name = name;
    this.organization = organization;
  }
}

これでPolarによるPolicyの定義が完了します。

テスト

では、これらの情報を使って実際にテストしてみましょう。まずはOsoに、Polara側で使うJavaScriptのclassを登録しておきます。そして、PolicyをOsoのloadFilesを使って読み込みます。

oso = new Oso();
oso.registerClass(Organization);
oso.registerClass(Repository);
oso.registerClass(User);

await oso.loadFiles([`${__dirname}/../policies/main.polar`]);

あとは、セットアップとして登場人物たちを用意します。

再掲しますが、以下のような関係になるようにします。

f:id:kenev:20210922212912p:plain

コード的には以下のような感じです。

org = new Organization('ACME');
repo = new Repository('ACME App', org);

alice = new User('Alice');
alice.assignRoleForResource('contributor', repo);

bob = new User('Bob');
bob.assignRoleForResource('maintainer', repo);

tom = new User('Tom');
tom.assignRoleForResource('owner', org);

jane = new User('Jane');
jane.assignRoleForResource('guest', repo);

あとは、Osoを使って、permissionが許可されるかどうかを判断してもらうだけです。今回はOsoのisAllowedメソッドを使うことにしました。メソッドにわたすのは、Actor, Permission, Resourceで、Polarのallowに定義されているパラメータと同様です。

// contributorはreadだけできる
expect(await oso.isAllowed(alice, 'read', repo)).toBe(true);
expect(await oso.isAllowed(alice, 'push', repo)).toBe(false);

// maintainerはreadもpushもできる
expect(await oso.isAllowed(bob, 'read', repo)).toBe(true);
expect(await oso.isAllowed(bob, 'push', repo)).toBe(true);

// Organizationのownerはmaintainer同様ではreadもpushもできる
expect(await oso.isAllowed(tom, 'read', repo)).toBe(true);
expect(await oso.isAllowed(tom, 'push', repo)).toBe(true);

// それ以外の人はreadもpushもできない
expect(await oso.isAllowed(jane, 'read', repo)).toBe(false);
expect(await oso.isAllowed(jane, 'push', repo)).toBe(false);

というように、意図した結果を確認することができました。

以下のリポジトリに実際に動作する例を置いているので、興味がある人は見てみてください。

github.com

まとめ

なぜtrueになったのか、なぜfalseになったのか、についてはまた時間のあるときに書きます。

また、この記事はOsoのほんの一部しか紹介できてません。真に強力なのは0.20.0から追加されたData Filterです。これは長年僕が悩んでいた、権限をデータ取得のときにどう適用すれば良いのか、ということに関する答えの一つになってます。興味がある人はぜひ使ってみましょう。

docs.osohq.com

まとめというほとまとめられませんが、OsoでのRBAC実装を実際にやってみた記録を残してみました。Polarの暗黙的な組み込みRule Typeが最初は「なにこれ?」って感じになります。ドキュメントをしっかり読まないとこれが最初の鬼門になりそうです。あと、自分のコード(JSとか)と連携するために、classをOsoに定義してマッピングするあたりも、最初は関係性を理解するのに苦労しそうです。

と、難しそうな感想を言ってますが、期待はめちゃくちゃしてます。そもそも認可とか権限が難しすぎるんです。オレオレ認可フレームワークでいつも苦しむよりは、ようやく登場したかもしれないベストプラクティスを凝縮したOsoというライブラリを使ってみて、このあたりのドメインもあまり悩みすぎることなく作っていけると良いですね。