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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40264b6092cdd9603f06a99d7a319bc53bfc3e309ff1457dce7f943f22ea3b70
4
- data.tar.gz: e35bb7aa9e234acb23f57dbac7a0c01457746f772f548d62d84c90471b1ef279
3
+ metadata.gz: 2d415c5785fb1644baaf3c3ab610b931b059fb41794e03436556cb660953cf86
4
+ data.tar.gz: 806a4ad95bf60a3d38da95035a26eeffc99e76cd6fd3efb193058862c7319045
5
5
  SHA512:
6
- metadata.gz: 2a842d373a4144dff69a586e1424774e7af86196f393f015bb6388013565f2557ce938d8d26452d192f634bf16297adf46d3ef3cd259de8a3e7a465fbc839d28
7
- data.tar.gz: 0cfa85a2a33f3de96cd5366940a93f183f655e8ee58263bff0a920b4b5e386b2511c2a57a78785f818045e9826072cc4819188894285afe7deaef07554a36072
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 は、AWS IAM の権限モデル(principal への policy アタッチ、explicit deny の優先、implicit deny)を簡略化し、一般のアプリケーションに導入できるようにすることを目指す権限管理ライブラリです。評価器の内部構造は XACML のアーキテクチャ(PEP / PDP / PIP / PAP の役割分離)を参考にしています。
5
+ placet Ruby ランタイム(リファレンス実装)です。コア評価エンジン(PDP)・Ruby DSL・principal 導出(resolver / derive / relation)・scope 合成を実装し、[spec/conformance](../../spec/conformance) の適合性テストをすべてパスします。
6
6
 
7
- 現在は**コンセプト策定フェーズ**であり、実装はまだありません。設計の全体と各判断の経緯は [docs/concept.md](docs/concept.md) にまとまっています。
7
+ ## インストール
8
8
 
9
- ## モデルの概要
10
-
11
- - アクセスユーザーは、`user:42` / `role:editor` / `tenant:acme` のような複数の **principal**(主体の「面」)を持ち、アプリケーション DB の情報から導出される
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
- `role:editor` を持つユーザーの `post:update` は許可され、凍結されたユーザー(`flag:suspended` の面を持つユーザー)は allow の有無にかかわらず全操作が拒否されます。
14
+ Rails 統合(宣言 DSL・ActiveRecord への scope 合成)には [placet-rails](../rails) を併用してください。動く例は [examples/sinatra](../../examples/sinatra) を参照。
35
15
 
36
- ## 設計上の特徴
16
+ テストの実行:
37
17
 
38
- - **結合則は deny-overrides のみ** — 設定による差し替えを提供しない。Policy を読めば結果が一意に予測できる
39
- - **動的な状態は principal 導出で吸収する** — Policy と attachment は静的でデプロイ時に固定。実行時に変わるのはユーザーの principal 集合だけ
40
- - **リソース個体認可は関係 principal(簡易 ReBAC)** 「作成者本人のみ編集可」を `rel:owner` のような関係の面として表現し、評価器の仕様は変えない
41
- - **一覧フィルタリングを仕様に含む** — 「閲覧できるものだけを返す」を、関係ごとの scope の和・差として DB クエリに合成する
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
- - [x] コンセプトと設計判断の整理([docs/concept.md](docs/concept.md))
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
- placet はラテン語で「可とする」。大学や教会の採決で用いられる placet / non placet(可 / 否)に由来します。読みは「プラケット」です。
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Placet
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
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
- raise NotImplementedError,
6
- "placet is not implemented yet — this version is a placeholder to reserve the gem name. " \
7
- "See https://github.com/aspick/placet for the concept and roadmap."
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.1
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: