placet 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +19 -39
- data/lib/placet/action_registry.rb +44 -0
- data/lib/placet/decision.rb +14 -0
- data/lib/placet/document.rb +99 -0
- data/lib/placet/dsl.rb +40 -0
- data/lib/placet/engine.rb +131 -0
- data/lib/placet/errors.rb +19 -0
- data/lib/placet/explain.rb +49 -0
- data/lib/placet/runtime.rb +197 -0
- data/lib/placet/test_helpers.rb +32 -0
- data/lib/placet/version.rb +1 -1
- data/lib/placet.rb +8 -4
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2d415c5785fb1644baaf3c3ab610b931b059fb41794e03436556cb660953cf86
|
|
4
|
+
data.tar.gz: 806a4ad95bf60a3d38da95035a26eeffc99e76cd6fd3efb193058862c7319045
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 99c24faf6e6ea23af66494a24166f1866d1364eb79c7eb62959d7dca9b675a0fb297e574cb90a1e6e2eafda09d73297bb0e5f28784df39b5f9d47a73e4c7ed9a
|
|
7
|
+
data.tar.gz: dd10b1484a2b0377668760c424aef678f8781e041feafe199b75766f183d8f58bc2db2dbc6abcb62da8fb6bd1ca952002307ca3be555a8846f570c4c7bf0b92e
|
data/README.md
CHANGED
|
@@ -1,52 +1,32 @@
|
|
|
1
|
-
# placet
|
|
1
|
+
# placet (Ruby)
|
|
2
2
|
|
|
3
3
|
> 宣言的な権限管理ライブラリ — simplified IAM-style authorization(コンセプト策定中)
|
|
4
4
|
|
|
5
|
-
placet
|
|
5
|
+
placet の Ruby ランタイム(リファレンス実装)です。コア評価エンジン(PDP)・Ruby DSL・principal 導出(resolver / derive / relation)・scope 合成を実装し、[spec/conformance](../../spec/conformance) の適合性テストをすべてパスします。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## インストール
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- 操作は `post:view` のような **action** で表され、action に対する allow / deny の集合が **policy** として principal にアタッチされる
|
|
13
|
-
- 決定は **deny-overrides**(1 つでも deny があれば拒否)と **implicit deny**(どこにもマッチしなければ拒否)に固定され、常に「どの policy のどの statement が決め手か」という根拠つきで返される
|
|
14
|
-
|
|
15
|
-
```yaml
|
|
16
|
-
policies:
|
|
17
|
-
- name: post-editor
|
|
18
|
-
statements:
|
|
19
|
-
- effect: allow
|
|
20
|
-
actions: [post:create, post:update, post:delete]
|
|
21
|
-
|
|
22
|
-
- name: suspended
|
|
23
|
-
statements:
|
|
24
|
-
- effect: deny
|
|
25
|
-
actions: ["*"]
|
|
26
|
-
|
|
27
|
-
attachments:
|
|
28
|
-
- principal: role:editor
|
|
29
|
-
policies: [post-editor]
|
|
30
|
-
- principal: flag:suspended
|
|
31
|
-
policies: [suspended]
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "placet"
|
|
32
12
|
```
|
|
33
13
|
|
|
34
|
-
|
|
14
|
+
Rails 統合(宣言 DSL・ActiveRecord への scope 合成)には [placet-rails](../rails) を併用してください。動く例は [examples/sinatra](../../examples/sinatra) を参照。
|
|
35
15
|
|
|
36
|
-
|
|
16
|
+
テストの実行:
|
|
37
17
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- **言語非依存の 3 層構造** — コア仕様(フォーマット + 評価セマンティクス + 適合性テスト)/ 言語ランタイム / ORM・フレームワークアダプタ
|
|
18
|
+
```sh
|
|
19
|
+
cd packages/ruby
|
|
20
|
+
for f in test/*_test.rb; do ruby -Ilib -Itest "$f"; done
|
|
21
|
+
```
|
|
43
22
|
|
|
44
|
-
|
|
23
|
+
- プロジェクト全体: https://github.com/aspick/placet
|
|
24
|
+
- 設計ドキュメント: https://github.com/aspick/placet/blob/main/docs/concept.md
|
|
45
25
|
|
|
46
|
-
|
|
47
|
-
- [ ] 正規形の JSON Schema
|
|
48
|
-
- [ ] リファレンス実装(Ruby ランタイム + 適合性 fixture)
|
|
26
|
+
## モデルの概要
|
|
49
27
|
|
|
50
|
-
|
|
28
|
+
- アクセスユーザーは `user:42` / `role:editor` / `tenant:acme` のような複数の **principal**(主体の「面」)を持つ
|
|
29
|
+
- 操作は `post:view` のような **action** で表され、allow / deny の集合が **policy** として principal にアタッチされる
|
|
30
|
+
- 決定は **deny-overrides** と **implicit deny** に固定され、常に根拠つきで返される
|
|
51
31
|
|
|
52
|
-
|
|
32
|
+
将来的には、このランタイムの上に ActiveRecord アダプタ・Rails PEP ヘルパー(一覧フィルタリングの scope 合成、呼び忘れ検知フック)が別 gem として提供される予定です。
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Placet
|
|
6
|
+
# 既知の action を宣言して typo を検出するレジストリ(docs/rails-usage.md 2.5)。
|
|
7
|
+
# 評価セマンティクスに影響しない lint であり、正規形(version 1)には含まれない
|
|
8
|
+
# ランタイム機能なので、Definition(正規形と 1:1)ではなくランタイムが所有する
|
|
9
|
+
class ActionRegistry
|
|
10
|
+
def initialize
|
|
11
|
+
@by_resource = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(resource, operations)
|
|
15
|
+
(@by_resource[resource] ||= Set.new).merge(operations)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# 宣言が 1 つもない場合、レジストリは未使用(lint 無効)として扱う
|
|
19
|
+
def empty? = @by_resource.empty?
|
|
20
|
+
|
|
21
|
+
# 具体的な action の照合
|
|
22
|
+
def known_action?(action)
|
|
23
|
+
return true if empty?
|
|
24
|
+
|
|
25
|
+
resource, operation = action.split(":", 2)
|
|
26
|
+
@by_resource.key?(resource) && @by_resource[resource].include?(operation)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# パターン(ワイルドカード可)の照合
|
|
30
|
+
def known_pattern?(pattern)
|
|
31
|
+
return true if empty? || pattern == "*"
|
|
32
|
+
|
|
33
|
+
resource, operation = pattern.split(":", 2)
|
|
34
|
+
return false if resource != "*" && !@by_resource.key?(resource)
|
|
35
|
+
return true if operation == "*"
|
|
36
|
+
|
|
37
|
+
if resource == "*"
|
|
38
|
+
@by_resource.values.any? { |ops| ops.include?(operation) }
|
|
39
|
+
else
|
|
40
|
+
@by_resource[resource].include?(operation)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Placet
|
|
4
|
+
# 決め手になった statement。via は derive による導出の連鎖(concept.md 3.6)
|
|
5
|
+
Determinant = Struct.new(:principal, :policy, :statement, :effect, :via, keyword_init: true) do
|
|
6
|
+
def to_h = super.compact
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# 根拠つきの決定。basis は :explicit_allow / :explicit_deny / :implicit_deny
|
|
10
|
+
Decision = Struct.new(:decision, :basis, :determinants, keyword_init: true) do
|
|
11
|
+
def permit? = decision == :permit
|
|
12
|
+
def to_h = { decision: decision, basis: basis, determinants: determinants.map(&:to_h) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Placet
|
|
4
|
+
ACTION_PATTERN_RE = /\A(\*|(\*|[a-z][a-z0-9_]*):(\*|[a-z][a-z0-9_]*))\z/
|
|
5
|
+
CONCRETE_ACTION_RE = /\A[a-z][a-z0-9_]*:[a-z][a-z0-9_]*\z/
|
|
6
|
+
PRINCIPAL_RE = /\A[a-z][a-z0-9_-]*:[^\s*]+\z/
|
|
7
|
+
POLICY_NAME_RE = /\A[a-z][a-z0-9_-]*\z/
|
|
8
|
+
|
|
9
|
+
Statement = Struct.new(:effect, :actions, keyword_init: true)
|
|
10
|
+
|
|
11
|
+
# Policy 定義の保持と検証。正規形(spec/schema/policy-document.schema.json)と 1:1
|
|
12
|
+
class Definition
|
|
13
|
+
attr_reader :policies, :attachments
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@policies = {} # name => [Statement]
|
|
17
|
+
@attachments = {} # principal => [policy names]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# 正規形(JSON 互換 Hash・文字列キー)からの読み込み
|
|
21
|
+
def self.from_canonical(doc)
|
|
22
|
+
raise DefinitionError, "ドキュメントが Hash ではない" unless doc.is_a?(Hash)
|
|
23
|
+
|
|
24
|
+
version = doc["version"]
|
|
25
|
+
raise DefinitionError, "未対応の version: #{version.inspect}" unless version == 1
|
|
26
|
+
|
|
27
|
+
definition = new
|
|
28
|
+
Array(doc["policies"]).each do |policy|
|
|
29
|
+
statements = Array(policy["statements"]).map do |st|
|
|
30
|
+
effect = st["effect"]
|
|
31
|
+
raise DefinitionError, "effect が不正: #{effect.inspect}" unless %w[allow deny].include?(effect)
|
|
32
|
+
|
|
33
|
+
Statement.new(effect: effect, actions: Array(st["actions"]))
|
|
34
|
+
end
|
|
35
|
+
definition.add_policy(policy["name"], statements)
|
|
36
|
+
end
|
|
37
|
+
Array(doc["attachments"]).each do |attachment|
|
|
38
|
+
definition.add_attachment(attachment["principal"], Array(attachment["policies"]))
|
|
39
|
+
end
|
|
40
|
+
definition.validate!
|
|
41
|
+
definition
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def add_policy(name, statements)
|
|
45
|
+
raise DefinitionError, "policy 名が不正: #{name.inspect}" unless name.is_a?(String) && name =~ POLICY_NAME_RE
|
|
46
|
+
raise DefinitionError, "policy 名が重複: #{name}" if @policies.key?(name)
|
|
47
|
+
raise DefinitionError, "statements が空: #{name}" if statements.empty?
|
|
48
|
+
|
|
49
|
+
@policies[name] = statements
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def add_attachment(principal, names)
|
|
53
|
+
unless principal.is_a?(String) && principal =~ PRINCIPAL_RE
|
|
54
|
+
raise DefinitionError, "principal が不正(type:id 形式・* 不可): #{principal.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
@attachments[principal] = (@attachments[principal] || []) | names
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# registry を渡すと、action パターンのレジストリ照合(lint)もあわせて行う
|
|
61
|
+
def validate!(registry = nil)
|
|
62
|
+
@attachments.each do |principal, names|
|
|
63
|
+
names.each do |name|
|
|
64
|
+
unless @policies.key?(name)
|
|
65
|
+
raise DefinitionError, "attachment が未定義の policy を参照: #{name} (principal: #{principal})"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
@policies.each do |name, statements|
|
|
70
|
+
statements.each { |st| st.actions.each { |pattern| validate_pattern!(pattern, name, registry) } }
|
|
71
|
+
end
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_canonical
|
|
76
|
+
{
|
|
77
|
+
"version" => 1,
|
|
78
|
+
"policies" => @policies.map do |name, statements|
|
|
79
|
+
{ "name" => name,
|
|
80
|
+
"statements" => statements.map { |s| { "effect" => s.effect, "actions" => s.actions } } }
|
|
81
|
+
end,
|
|
82
|
+
"attachments" => @attachments.map do |principal, names|
|
|
83
|
+
{ "principal" => principal, "policies" => names }
|
|
84
|
+
end
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def validate_pattern!(pattern, policy_name, registry)
|
|
91
|
+
unless pattern.is_a?(String) && pattern =~ ACTION_PATTERN_RE
|
|
92
|
+
raise DefinitionError, "action パターンが不正: #{pattern.inspect} (policy: #{policy_name})"
|
|
93
|
+
end
|
|
94
|
+
return if registry.nil? || registry.known_pattern?(pattern)
|
|
95
|
+
|
|
96
|
+
raise DefinitionError, "未知の action: #{pattern} (policy: #{policy_name})"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
data/lib/placet/dsl.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Placet
|
|
4
|
+
# Ruby DSL(docs/rails-usage.md 2.2)。評価結果は正規形と等価な Definition になる
|
|
5
|
+
class PolicyBuilder
|
|
6
|
+
attr_reader :statements
|
|
7
|
+
|
|
8
|
+
def initialize = @statements = []
|
|
9
|
+
def allow(*actions) = @statements << Statement.new(effect: "allow", actions: actions.flatten)
|
|
10
|
+
def deny(*actions) = @statements << Statement.new(effect: "deny", actions: actions.flatten)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class DefinitionBuilder
|
|
14
|
+
def initialize(definition, registry)
|
|
15
|
+
@definition = definition
|
|
16
|
+
@registry = registry
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def actions(resource, operations) = @registry.add(resource, operations)
|
|
20
|
+
|
|
21
|
+
def policy(name, attach_to: nil, &block)
|
|
22
|
+
builder = PolicyBuilder.new
|
|
23
|
+
builder.instance_eval(&block)
|
|
24
|
+
@definition.add_policy(name, builder.statements)
|
|
25
|
+
Array(attach_to).each { |principal| @definition.add_attachment(principal, [name]) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def attach(principal, *names) = @definition.add_attachment(principal, names.flatten)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# relation は check(個体判定)と scope(一覧用の逆写像)を必ずペアで宣言する
|
|
32
|
+
Relation = Struct.new(:name, :check, :scope, keyword_init: true)
|
|
33
|
+
|
|
34
|
+
class RelationBuilder
|
|
35
|
+
attr_reader :check_block, :scope_block
|
|
36
|
+
|
|
37
|
+
def check(&block) = @check_block = block
|
|
38
|
+
def scope(&block) = @scope_block = block
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Placet
|
|
6
|
+
# action パターンのマッチング(concept.md 3.3: セグメント単位で literal か *)
|
|
7
|
+
module ActionMatch
|
|
8
|
+
def self.match?(pattern, action)
|
|
9
|
+
return true if pattern == "*"
|
|
10
|
+
|
|
11
|
+
p_res, p_op = pattern.split(":", 2)
|
|
12
|
+
a_res, a_op = action.split(":", 2)
|
|
13
|
+
(p_res == "*" || p_res == a_res) && (p_op == "*" || p_op == a_op)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# 一覧フィルタリングの集合演算の計画(concept.md 8.4 / spec/conformance/README.md)。
|
|
18
|
+
# ORM アダプタはこの plan を WHERE 句へ写像する
|
|
19
|
+
ScopePlan = Struct.new(:kind, :include_relations, :exclude_relations, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
# コア評価エンジン(PDP)。純粋関数として動作し、適合性テストの対象となる。
|
|
22
|
+
# 入力は principal 文字列の配列のみで、derive の由来(via)などランタイム層の
|
|
23
|
+
# 拡張は関知しない
|
|
24
|
+
class Engine
|
|
25
|
+
def initialize(definition, registry: nil)
|
|
26
|
+
@definition = definition
|
|
27
|
+
@registry = registry
|
|
28
|
+
@grant_cache = {}
|
|
29
|
+
@match_cache = {}
|
|
30
|
+
@valid_actions = Set.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def decide(principals, action)
|
|
34
|
+
validate_action!(action)
|
|
35
|
+
matches = matches_for(action)
|
|
36
|
+
|
|
37
|
+
matched = []
|
|
38
|
+
principals.each do |principal|
|
|
39
|
+
@definition.attachments.fetch(principal, []).each do |policy_name|
|
|
40
|
+
matches.fetch(policy_name, []).each do |index, effect|
|
|
41
|
+
matched << Determinant.new(principal: principal, policy: policy_name,
|
|
42
|
+
statement: index, effect: effect)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
denies, allows = matched.partition { |m| m.effect == "deny" }
|
|
48
|
+
return Decision.new(decision: :deny, basis: :explicit_deny, determinants: denies) if denies.any?
|
|
49
|
+
return Decision.new(decision: :permit, basis: :explicit_allow, determinants: allows) if allows.any?
|
|
50
|
+
|
|
51
|
+
Decision.new(decision: :deny, basis: :implicit_deny, determinants: [])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 一覧フィルタリングの計画を返す。
|
|
55
|
+
# static_principals: 静的 principal(rel: を含まない導出結果)
|
|
56
|
+
# relations: 対象モデルに定義された relation 名のリスト
|
|
57
|
+
def scope_plan(static_principals, action, relations:)
|
|
58
|
+
validate_action!(action)
|
|
59
|
+
allow_ps, deny_ps = grants(action)
|
|
60
|
+
static = static_principals.to_a
|
|
61
|
+
|
|
62
|
+
if static.any? { |p| deny_ps.include?(p) }
|
|
63
|
+
return ScopePlan.new(kind: "empty", include_relations: [], exclude_relations: [])
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
exclude = relations.select { |name| deny_ps.include?("rel:#{name}") }
|
|
67
|
+
|
|
68
|
+
if static.any? { |p| allow_ps.include?(p) }
|
|
69
|
+
return ScopePlan.new(kind: "all", include_relations: [], exclude_relations: exclude)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
include_ = relations.select { |name| allow_ps.include?("rel:#{name}") }
|
|
73
|
+
if include_.empty?
|
|
74
|
+
ScopePlan.new(kind: "empty", include_relations: [], exclude_relations: [])
|
|
75
|
+
else
|
|
76
|
+
ScopePlan.new(kind: "union", include_relations: include_, exclude_relations: exclude)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# action 検証の一元的な入口。決定要求・scope 合成の action は具体的
|
|
81
|
+
# (ワイルドカード不可)かつレジストリ宣言済み(使用時)でなければならない。
|
|
82
|
+
# typo は静かな全拒否 / 空 scope になるため即エラーにする。
|
|
83
|
+
# 宣言時 fail-fast には error_class に DefinitionError を渡して使う
|
|
84
|
+
def validate_action!(action, error_class: Error)
|
|
85
|
+
return if @valid_actions.include?(action)
|
|
86
|
+
|
|
87
|
+
unless action.is_a?(String) && action =~ CONCRETE_ACTION_RE
|
|
88
|
+
raise error_class, "action は具体的でなければならない: #{action.inspect}"
|
|
89
|
+
end
|
|
90
|
+
unless @registry.nil? || @registry.known_action?(action)
|
|
91
|
+
raise error_class, "未知の action: #{action}(レジストリに未宣言)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@valid_actions << action
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# action ごとの「マッチ済み statement 索引」(policy 名 => [[index, effect]])。
|
|
100
|
+
# 定義は Engine 生成後に不変なのでキャッシュできる。decide の毎回の
|
|
101
|
+
# 全 statement 走査と action 文字列の split 繰り返しを避ける
|
|
102
|
+
def matches_for(action)
|
|
103
|
+
@match_cache[action] ||= @definition.policies.each_with_object({}) do |(name, statements), out|
|
|
104
|
+
hits = statements.each_with_index.filter_map do |st, index|
|
|
105
|
+
[index, st.effect] if statement_matches?(st, action)
|
|
106
|
+
end
|
|
107
|
+
out[name] = hits unless hits.empty?
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def statement_matches?(statement, action)
|
|
112
|
+
statement.actions.any? { |pattern| ActionMatch.match?(pattern, action) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# action → allow / deny を与える principal 集合の逆引き(起動時コンパイル相当)
|
|
116
|
+
def grants(action)
|
|
117
|
+
@grant_cache[action] ||= begin
|
|
118
|
+
allow = Set.new
|
|
119
|
+
deny = Set.new
|
|
120
|
+
@definition.attachments.each do |principal, names|
|
|
121
|
+
names.each do |name|
|
|
122
|
+
@definition.policies[name].each do |st|
|
|
123
|
+
(st.effect == "deny" ? deny : allow) << principal if statement_matches?(st, action)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
[allow, deny]
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Placet
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# 定義(Policy / attachment / relation)の不備。起動時に検出される
|
|
7
|
+
class DefinitionError < Error; end
|
|
8
|
+
|
|
9
|
+
# authorize! が拒否されたときに送出される。decision に根拠が入る
|
|
10
|
+
class Denied < Error
|
|
11
|
+
attr_reader :action, :decision
|
|
12
|
+
|
|
13
|
+
def initialize(action, decision)
|
|
14
|
+
@action = action
|
|
15
|
+
@decision = decision
|
|
16
|
+
super("denied: #{action} (#{decision.basis})")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Placet
|
|
4
|
+
# 実効権限と決定根拠の説明表示(rails placet:explain 等が利用する)。
|
|
5
|
+
# Rails には依存しない。resolver はアプリの user オブジェクトを必要とするため、
|
|
6
|
+
# explain は principal 集合を直接受け取り、derive の 1 段展開だけを適用する
|
|
7
|
+
module Explain
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# principal に直接・derive 経由でアタッチされる policy を一覧する
|
|
11
|
+
def principal_report(principal)
|
|
12
|
+
lines = [principal]
|
|
13
|
+
Placet.expand_principals([principal]).each do |expanded, via|
|
|
14
|
+
if expanded == principal
|
|
15
|
+
append_policies(lines, expanded, indent: " ")
|
|
16
|
+
else
|
|
17
|
+
lines << " └─ #{expanded}(#{via.join(' ← ')} から導出)"
|
|
18
|
+
append_policies(lines, expanded, indent: " ")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
lines.join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# principal 集合(derive 展開込み)で action を判定し、根拠を表示する
|
|
25
|
+
def decision_report(principals, action)
|
|
26
|
+
decision = Placet.decide_with_provenance(Placet.expand_principals(principals), action)
|
|
27
|
+
lines = ["#{decision.decision} (#{decision.basis})"]
|
|
28
|
+
decision.determinants.each do |d|
|
|
29
|
+
line = "└─ #{d.effect}: policy \"#{d.policy}\" statement #{d.statement} via #{d.principal}"
|
|
30
|
+
line += " ← #{d.via.join(' ← ')}" if d.via
|
|
31
|
+
lines << line
|
|
32
|
+
end
|
|
33
|
+
lines << "└─ マッチする statement なし(implicit deny)" if decision.determinants.empty?
|
|
34
|
+
lines.join("\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def append_policies(lines, principal, indent:)
|
|
38
|
+
names = Placet.definition.attachments.fetch(principal, [])
|
|
39
|
+
return lines << "#{indent}(アタッチされた policy なし)" if names.empty?
|
|
40
|
+
|
|
41
|
+
names.each do |name|
|
|
42
|
+
lines << "#{indent}policy: #{name}"
|
|
43
|
+
Placet.definition.policies[name].each do |st|
|
|
44
|
+
lines << "#{indent} #{st.effect} #{st.actions.join(', ')}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Placet
|
|
4
|
+
# モジュールレベルのランタイム API(docs/rails-usage.md)。
|
|
5
|
+
# 定義(静的)と principal 導出(動的)をつなぎ、Engine に決定を委譲する
|
|
6
|
+
class << self
|
|
7
|
+
def definition = (@definition ||= Definition.new)
|
|
8
|
+
def registry = (@registry ||= ActionRegistry.new)
|
|
9
|
+
def engine = (@engine ||= Engine.new(definition, registry: registry))
|
|
10
|
+
|
|
11
|
+
def define(&block)
|
|
12
|
+
DefinitionBuilder.new(definition, registry).instance_eval(&block)
|
|
13
|
+
definition.validate!(registry)
|
|
14
|
+
@engine = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def resolver(&block) = @resolver = block
|
|
18
|
+
|
|
19
|
+
def derive(type_pattern, &block)
|
|
20
|
+
type = type_pattern.delete_suffix(":*").delete_suffix(":")
|
|
21
|
+
((@derives ||= {})[type] ||= []) << block
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def relation(name, resource:, &block)
|
|
25
|
+
builder = RelationBuilder.new
|
|
26
|
+
builder.instance_eval(&block)
|
|
27
|
+
unless builder.check_block && builder.scope_block
|
|
28
|
+
raise DefinitionError, "relation は check と scope をペアで宣言する: #{name}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
((@relations ||= {})[resource] ||= []) <<
|
|
32
|
+
Relation.new(name: name.to_s, check: builder.check_block, scope: builder.scope_block)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# 与えられた principal 集合に derive の 1 段展開を適用する。
|
|
36
|
+
# 戻り値は { principal => 由来の連鎖 (Array) }
|
|
37
|
+
def expand_principals(base)
|
|
38
|
+
out = {}
|
|
39
|
+
base.each { |p| out[p] = [] }
|
|
40
|
+
base.each do |p|
|
|
41
|
+
type, id = p.split(":", 2)
|
|
42
|
+
derive_hooks(type).each do |hook|
|
|
43
|
+
Array(hook.call(id)).each { |derived| out[derived] ||= [p] }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
out
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# principal 集合の導出。戻り値は { principal => 由来の連鎖 (Array) }
|
|
50
|
+
def principals_for(user, resource = nil)
|
|
51
|
+
with_relation_principals(expand_principals(Array(@resolver&.call(user))), user, resource)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 複数 action は AND(fail-fast)。docs/rails-usage.md 4.2
|
|
55
|
+
def decide(user, actions, resource = nil)
|
|
56
|
+
last = nil
|
|
57
|
+
decide_each(user, actions, resource) do |_action, decision|
|
|
58
|
+
last = decision
|
|
59
|
+
return decision unless decision.permit?
|
|
60
|
+
end
|
|
61
|
+
last
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def authorize!(user, actions, resource = nil)
|
|
65
|
+
count_enforcement!
|
|
66
|
+
decide_each(user, actions, resource) do |action, decision|
|
|
67
|
+
raise Denied.new(action, decision) unless decision.permit?
|
|
68
|
+
end
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def permit?(user, actions, resource = nil) = decide(user, actions, resource).permit?
|
|
73
|
+
|
|
74
|
+
# 由来つきの principal 集合(Hash)で判定し、determinants に由来(via)を付与する。
|
|
75
|
+
# Engine は仕様どおり principal の配列のみを受けるため、via の装飾はこの層で行う
|
|
76
|
+
def decide_with_provenance(principals, action)
|
|
77
|
+
decision = engine.decide(principals.keys, action)
|
|
78
|
+
decision.determinants.each do |determinant|
|
|
79
|
+
via = principals[determinant.principal]
|
|
80
|
+
determinant.via = via unless via.nil? || via.empty?
|
|
81
|
+
end
|
|
82
|
+
decision
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# action 検証の公開入口(Engine に委譲)。アダプタの宣言時 fail-fast にも使う
|
|
86
|
+
def validate_action!(action, error_class: Error)
|
|
87
|
+
engine.validate_action!(action, error_class: error_class)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# 一覧の scope 合成。Engine の plan を、登録された materializer(ORM アダプタ)
|
|
91
|
+
# またはインメモリの既定実装でコレクションへ写像する
|
|
92
|
+
def scoped(user, action, model:)
|
|
93
|
+
count_enforcement!
|
|
94
|
+
rels = relations_for(model)
|
|
95
|
+
plan = engine.scope_plan(principals_for(user).keys, action, relations: rels.map(&:name))
|
|
96
|
+
materializer_for(model).call(plan, rels, user, model)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ORM アダプタが ScopePlan の実体化を登録する。matcher が true を返した
|
|
100
|
+
# model に materializer が適用される(後着優先。どれにもマッチしなければ
|
|
101
|
+
# インメモリの既定実装)。定義状態とは独立した登録なので reset! では消えない
|
|
102
|
+
def register_scope_materializer(matcher, materializer)
|
|
103
|
+
(@scope_materializers ||= []).unshift([matcher, materializer])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# relation 名の集合を scope 呼び出し結果へ写像する共通ヘルパー
|
|
107
|
+
# (インメモリ実装と ORM アダプタで共有)
|
|
108
|
+
def relation_scopes(relations, names, user)
|
|
109
|
+
relations.select { |r| names.include?(r.name) }.map { |r| r.scope.call(user) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# このスレッドで enforcement(authorize! / scoped)が行われた回数。
|
|
113
|
+
# PEP ヘルパーの呼び忘れ検知(docs/rails-usage.md 4.4)が参照する
|
|
114
|
+
def enforcements = Thread.current[:placet_enforcements] || 0
|
|
115
|
+
|
|
116
|
+
# scope 合成の結果(通常は 1 ページ分)へ個体判定を再適用する二段構え。
|
|
117
|
+
# check / scope に乖離バグがあっても「見えてはいけないものが見える」方向には
|
|
118
|
+
# 倒れない(concept.md 11.5)。乖離レコードは on_recheck_divergence に通知して除外する。
|
|
119
|
+
# 静的 principal の導出(resolver + derive)はレコードに依存しないため 1 回だけ行う
|
|
120
|
+
def recheck(user, action, records)
|
|
121
|
+
base = expand_principals(Array(@resolver&.call(user)))
|
|
122
|
+
records.select do |record|
|
|
123
|
+
principals = with_relation_principals(base, user, record)
|
|
124
|
+
next true if engine.decide(principals.keys, action).permit?
|
|
125
|
+
|
|
126
|
+
if (handler = @on_recheck_divergence)
|
|
127
|
+
handler.call(user, action, record)
|
|
128
|
+
else
|
|
129
|
+
warn "placet: scope と check の乖離を検出: action=#{action} record=#{record.inspect}"
|
|
130
|
+
end
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# 乖離検出時のハンドラ(user, action, record を受け取る)。既定は警告出力
|
|
136
|
+
attr_accessor :on_recheck_divergence
|
|
137
|
+
|
|
138
|
+
def export = definition.to_canonical
|
|
139
|
+
|
|
140
|
+
# テスト・再読み込み用: 定義と登録を破棄する(materializer は定義状態ではないため対象外)
|
|
141
|
+
def reset!
|
|
142
|
+
@definition = nil
|
|
143
|
+
@registry = nil
|
|
144
|
+
@engine = nil
|
|
145
|
+
@resolver = nil
|
|
146
|
+
@derives = nil
|
|
147
|
+
@relations = nil
|
|
148
|
+
@on_recheck_divergence = nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# モデルに登録された relation の一覧(テストヘルパー・アダプタが参照する)
|
|
152
|
+
def relations_for(klass) = @relations ? @relations.fetch(klass, []) : []
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def derive_hooks(type) = @derives ? @derives.fetch(type, []) : []
|
|
157
|
+
def count_enforcement! = Thread.current[:placet_enforcements] = enforcements + 1
|
|
158
|
+
|
|
159
|
+
# principals_for / decide / authorize! で共有する評価ループ。
|
|
160
|
+
# principal 導出は action に依存しないため、複数 action の AND でも 1 回で済ませる
|
|
161
|
+
def decide_each(user, actions, resource)
|
|
162
|
+
principals = principals_for(user, resource)
|
|
163
|
+
Array(actions).each do |action|
|
|
164
|
+
yield action, decide_with_provenance(principals, action)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# 由来つき principal 集合(base)に、resource との関係の面を追加する
|
|
169
|
+
def with_relation_principals(base, user, resource)
|
|
170
|
+
return base unless resource
|
|
171
|
+
|
|
172
|
+
out = base.dup
|
|
173
|
+
relations_for(resource.class).each do |rel|
|
|
174
|
+
out["rel:#{rel.name}"] ||= [] if rel.check.call(user, resource)
|
|
175
|
+
end
|
|
176
|
+
out
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def materializer_for(model)
|
|
180
|
+
(@scope_materializers || []).each do |matcher, materializer|
|
|
181
|
+
return materializer if matcher.call(model)
|
|
182
|
+
end
|
|
183
|
+
method(:materialize_in_memory)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# 既定の実体化: インメモリコレクション(Array)に対する集合演算
|
|
187
|
+
def materialize_in_memory(plan, rels, user, model)
|
|
188
|
+
excluded = relation_scopes(rels, plan.exclude_relations, user).flatten(1)
|
|
189
|
+
case plan.kind
|
|
190
|
+
when "empty" then []
|
|
191
|
+
when "all" then model.all.to_a - excluded
|
|
192
|
+
else
|
|
193
|
+
relation_scopes(rels, plan.include_relations, user).flatten(1).uniq - excluded
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "placet"
|
|
4
|
+
|
|
5
|
+
module Placet
|
|
6
|
+
# テストスイートに include して使う検証ヘルパー(require "placet/test_helpers")。
|
|
7
|
+
# minitest / RSpec のどちらからでも使える
|
|
8
|
+
module TestHelpers
|
|
9
|
+
# relation の check と scope の整合性を property test として検証する:
|
|
10
|
+
# record ∈ scope(user) ⟺ check(user, record)
|
|
11
|
+
# 乖離(個体判定では見えるのに一覧に出ない / その逆)は「見えてはいけないものが
|
|
12
|
+
# 見える」事故の源泉なので、乖離を見つけた時点で詳細つきの例外を投げる
|
|
13
|
+
def verify_relation_consistency(name, resource:, users:, records:)
|
|
14
|
+
relation = Placet.relations_for(resource).find { |r| r.name == name.to_s }
|
|
15
|
+
raise Placet::Error, "relation が未定義: #{name} (resource: #{resource})" unless relation
|
|
16
|
+
|
|
17
|
+
users.each do |user|
|
|
18
|
+
scoped_records = relation.scope.call(user).to_a
|
|
19
|
+
records.each do |record|
|
|
20
|
+
in_scope = scoped_records.include?(record)
|
|
21
|
+
checked = !!relation.check.call(user, record)
|
|
22
|
+
next if in_scope == checked
|
|
23
|
+
|
|
24
|
+
raise Placet::Error,
|
|
25
|
+
"relation #{name} の check と scope が乖離: " \
|
|
26
|
+
"user=#{user.inspect} record=#{record.inspect} scope=#{in_scope} check=#{checked}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/placet/version.rb
CHANGED
data/lib/placet.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "placet/version"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
require_relative "placet/errors"
|
|
5
|
+
require_relative "placet/decision"
|
|
6
|
+
require_relative "placet/document"
|
|
7
|
+
require_relative "placet/action_registry"
|
|
8
|
+
require_relative "placet/engine"
|
|
9
|
+
require_relative "placet/dsl"
|
|
10
|
+
require_relative "placet/runtime"
|
|
11
|
+
require_relative "placet/explain"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: placet
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yugo TERADA
|
|
@@ -22,6 +22,15 @@ files:
|
|
|
22
22
|
- LICENSE
|
|
23
23
|
- README.md
|
|
24
24
|
- lib/placet.rb
|
|
25
|
+
- lib/placet/action_registry.rb
|
|
26
|
+
- lib/placet/decision.rb
|
|
27
|
+
- lib/placet/document.rb
|
|
28
|
+
- lib/placet/dsl.rb
|
|
29
|
+
- lib/placet/engine.rb
|
|
30
|
+
- lib/placet/errors.rb
|
|
31
|
+
- lib/placet/explain.rb
|
|
32
|
+
- lib/placet/runtime.rb
|
|
33
|
+
- lib/placet/test_helpers.rb
|
|
25
34
|
- lib/placet/version.rb
|
|
26
35
|
homepage: https://github.com/aspick/placet
|
|
27
36
|
licenses:
|