permissify-rb 0.5.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 +7 -0
- data/CHANGELOG.md +89 -0
- data/EXAMPLES.md +443 -0
- data/LICENSE.txt +21 -0
- data/README.md +71 -0
- data/lib/permissify/decision.rb +31 -0
- data/lib/permissify/decision_context.rb +59 -0
- data/lib/permissify/dsl.rb +55 -0
- data/lib/permissify/errors.rb +41 -0
- data/lib/permissify/fact_source.rb +22 -0
- data/lib/permissify/predicate.rb +49 -0
- data/lib/permissify/rails.rb +118 -0
- data/lib/permissify/railtie.rb +33 -0
- data/lib/permissify/registry.rb +51 -0
- data/lib/permissify/version.rb +6 -0
- data/lib/permissify-rb.rb +6 -0
- data/lib/permissify.rb +129 -0
- metadata +66 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 03fdfddc37d49ba5df2d5ccd30c48b4c8c7e7a3b145a4c1a2f54f89021a5a84b
|
|
4
|
+
data.tar.gz: c3acf7eeb111944a9006753c2d2f00652519bbebcb27cd14c43d123221c89257
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 63da49ac077f45b9d63c7febc3a399f9e2e30c807bbd0f34aa7d3522ee407f1596f3f422cd387b1ec133ebb5df37fd30ae0120138b9f78f5a40c3e6f31c6d442
|
|
7
|
+
data.tar.gz: 8dfda3541e0f8a4d863022d13ac5949748af54662155fbf005eecfa8c0e5b80a7f3ecfc3d309f94ef7586ab16becb308b661e95c5dd136d326d236512ccf2b71
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are recorded here. The release workflow refuses to publish
|
|
4
|
+
a version with no entry — see RELEASING.md.
|
|
5
|
+
|
|
6
|
+
Pre-1.0: minor versions may break, and the changelog says so when they do.
|
|
7
|
+
|
|
8
|
+
## [0.5.0] - 2026-07-24
|
|
9
|
+
|
|
10
|
+
Zero-config Rails Railtie.
|
|
11
|
+
|
|
12
|
+
- First public release on RubyGems, as `permissify-rb` (the name `permissify`
|
|
13
|
+
belongs to an unrelated gem). `require "permissify"` and the `Permissify`
|
|
14
|
+
constant are unchanged; `lib/permissify-rb.rb` only requires `permissify`, so
|
|
15
|
+
`Bundler.require` works with `gem "permissify-rb"`.
|
|
16
|
+
|
|
17
|
+
- `Permissify::Railtie` — loaded automatically when Rails is present (a conditional
|
|
18
|
+
require in `lib/permissify.rb`, so pure-Ruby hosts keep their stdlib-only footprint).
|
|
19
|
+
It removes all host loader glue:
|
|
20
|
+
- definitions live in `app/permissify/*.rb` (each calls `Permissify.define`);
|
|
21
|
+
- the directory is ignored by Zeitwerk (files register, they don't define constants);
|
|
22
|
+
- definitions register on boot and re-register on every reload via `to_prepare`;
|
|
23
|
+
- `permissify/rails` (the controller concern) is required automatically.
|
|
24
|
+
No initializer is needed. Mirrors the Predicate gem's Railtie so both load identically.
|
|
25
|
+
|
|
26
|
+
## [0.4.0] - 2026-07-24
|
|
27
|
+
|
|
28
|
+
Optional Predicate fact source.
|
|
29
|
+
|
|
30
|
+
- `require "permissify/predicate"` → `Permissify::PredicateFactSource` — sources a
|
|
31
|
+
family of stable domain-validity facts from a Predicate entity through the
|
|
32
|
+
`FactSource` port, with no load-time dependency on the `predicate` gem (the entity
|
|
33
|
+
is injected). `facts:` declares the owned names; anything else returns `Missing` so
|
|
34
|
+
inline facts and other sources still win; an owned fact whose entity raises denies
|
|
35
|
+
observably with `:fact_error`.
|
|
36
|
+
- Documents the split loudly: Predicate is a memoized *domain* layer beneath
|
|
37
|
+
authorization — for stable facts only, never for volatile roles/ownership.
|
|
38
|
+
- Contract tests run with and without Predicate (a faithful `call(name, state)` stub),
|
|
39
|
+
including sourced-vs-inline parity.
|
|
40
|
+
|
|
41
|
+
## [0.3.0] - 2026-07-24
|
|
42
|
+
|
|
43
|
+
Optional Rails adapter.
|
|
44
|
+
|
|
45
|
+
- `require "permissify/rails"` + `include Permissify::Controller` — an optional,
|
|
46
|
+
dependency-free controller concern:
|
|
47
|
+
- `permissify_authorize(resource, action:, resource_key:, environment:)` — raises
|
|
48
|
+
`NotAuthorized` on deny, returns the resource on allow.
|
|
49
|
+
- `permissify_scope(relation, …)` — delegates to `authorized_scope` (DB-level).
|
|
50
|
+
- `verify_permissify_authorized` / `verify_permissify_scoped` — after_action guards
|
|
51
|
+
that fail loud when an action forgot to authorize or scope.
|
|
52
|
+
- `permissify_actor` (defaults to `current_user`) and `permissify_environment` are
|
|
53
|
+
overridable; `permissify_resource_key` infers the registry key from the model name.
|
|
54
|
+
- Names are distinct from Pundit's so both can run side by side during a shadow migration.
|
|
55
|
+
- Adapter tests run without booting Rails (a fake controller supplies the contract).
|
|
56
|
+
|
|
57
|
+
- `EXAMPLES.md` — comprehensive, test-backed examples for every feature.
|
|
58
|
+
- RDoc comments across the public API and an `rdoc` Rake task; generated API docs
|
|
59
|
+
build into `doc/` (gitignored) and are published to kuickr, not committed.
|
|
60
|
+
|
|
61
|
+
## [0.2.0] - 2026-07-23
|
|
62
|
+
|
|
63
|
+
Authorized scopes + fact-source parity.
|
|
64
|
+
|
|
65
|
+
- `scope(:permission) { |actor, relation, environment| … }` DSL registration.
|
|
66
|
+
- `Permissify.authorized_scope(actor:, action:, relation:, resource_key:)` — the
|
|
67
|
+
database-scope port. Delegates to the registered SQL-capable builder and raises
|
|
68
|
+
`NoScope` when none exists (never returns every row by default). Collections are
|
|
69
|
+
never filtered in Ruby as a security boundary.
|
|
70
|
+
- FactSource parity test: the same permission yields the same decision whether a
|
|
71
|
+
fact is registered inline or supplied by a `FactSource` (the swap-the-source
|
|
72
|
+
guarantee), plus scope delegation and no-scope tests.
|
|
73
|
+
|
|
74
|
+
## [0.1.0] - 2026-07-23
|
|
75
|
+
|
|
76
|
+
Standalone core skeleton.
|
|
77
|
+
|
|
78
|
+
- Ruby-only core, no Rails or Predicate runtime dependency.
|
|
79
|
+
- `Permissify.define` DSL: `fact`, `permission`, explicit `action ... maps_to:`.
|
|
80
|
+
- `Registry` (boot-time, synchronized) holds facts, permissions, action aliases.
|
|
81
|
+
- Typed `DecisionContext` with `fact`, `all?`, `any?`, `none?` and a check trace.
|
|
82
|
+
- Immutable structured `Decision` (`allowed`, `permission`, `reason`, `checks`,
|
|
83
|
+
`metadata`).
|
|
84
|
+
- `FactSource` port (`fetch` + `Missing` sentinel + `Null` default).
|
|
85
|
+
- Safety contracts: default-deny for unknown resource/action/permission/fact;
|
|
86
|
+
evaluation errors deny observably; no superuser bypass; no action fallback.
|
|
87
|
+
- `Permissify.decide`, `allow?`, and `authorize!` (raises `NotAuthorized`).
|
|
88
|
+
- Minitest suite covering allow/deny, unknown-*, missing-fact, error-denies,
|
|
89
|
+
no-superuser, duplicate-definition.
|
data/EXAMPLES.md
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
# Permissify — Comprehensive Examples
|
|
2
|
+
|
|
3
|
+
Concrete, runnable examples for every feature of Permissify. Each example maps to
|
|
4
|
+
behavior covered by the automated tests in `test/` — `test/permissify_test.rb` and
|
|
5
|
+
`test/scope_and_fact_source_test.rb`.
|
|
6
|
+
|
|
7
|
+
Permissify answers one question — *can this **actor** perform this **action** on this
|
|
8
|
+
**resource**?* — and returns one explicit, observable `Decision`. It is default-deny,
|
|
9
|
+
has no superuser bypass, no generated `":#{action}able"` fallback, and needs no
|
|
10
|
+
framework.
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
1. [Defining a resource](#1-defining-a-resource)
|
|
15
|
+
2. [Making a decision](#2-making-a-decision)
|
|
16
|
+
3. [The Decision object](#3-the-decision-object)
|
|
17
|
+
4. [Boolean projection — `allow?`](#4-boolean-projection--allow)
|
|
18
|
+
5. [Enforcement — `authorize!`](#5-enforcement--authorize)
|
|
19
|
+
6. [Combinators — `all?` / `any?` / `none?`](#6-combinators--all--any--none)
|
|
20
|
+
7. [Environment](#7-environment)
|
|
21
|
+
8. [Authorized scopes — authorization as a query](#8-authorized-scopes--authorization-as-a-query)
|
|
22
|
+
9. [The FactSource port](#9-the-factsource-port)
|
|
23
|
+
10. [Safety contracts](#10-safety-contracts)
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Real-World Use Cases
|
|
28
|
+
|
|
29
|
+
11. [Migrating a Pundit policy](#11-migrating-a-pundit-policy)
|
|
30
|
+
12. [Optional Predicate as a domain-fact source](#12-optional-predicate-as-a-domain-fact-source)
|
|
31
|
+
13. [Rails integration](#13-rails-integration)
|
|
32
|
+
14. [Sourcing many domain facts — `PredicateFactSource`](#14-sourcing-many-domain-facts--predicatefactsource)
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 1. Defining a resource
|
|
37
|
+
|
|
38
|
+
A resource is a bundle of named **facts**, **permissions** that compose those facts,
|
|
39
|
+
and **actions** that alias to a permission. The DSL is evaluated at boot.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
Permissify.define(:article) do
|
|
43
|
+
fact(:draft) { |ctx| ctx.resource.status == "draft" }
|
|
44
|
+
fact(:owned) { |ctx| ctx.resource.user_id == ctx.actor.id }
|
|
45
|
+
|
|
46
|
+
permission(:editable) { |ctx| ctx.all?(:draft, :owned) }
|
|
47
|
+
|
|
48
|
+
action :edit, maps_to: :editable
|
|
49
|
+
action :update, maps_to: :editable
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `fact` blocks receive a `DecisionContext` and return a boolean.
|
|
54
|
+
- `permission` blocks receive the same context and read facts via combinators.
|
|
55
|
+
- `action ..., maps_to:` is the **only** way an action resolves to a permission —
|
|
56
|
+
there is no implicit `":#{action}able"` naming convention.
|
|
57
|
+
- Duplicate fact / permission / action / scope names raise `Permissify::DefinitionError`
|
|
58
|
+
at definition time.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 2. Making a decision
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
article = Struct.new(:status, :user_id).new("draft", 7)
|
|
66
|
+
author = Struct.new(:id).new(7)
|
|
67
|
+
stranger = Struct.new(:id).new(9)
|
|
68
|
+
|
|
69
|
+
Permissify.decide(actor: author, action: :edit, resource: article, resource_key: :article).allowed?
|
|
70
|
+
# => true
|
|
71
|
+
|
|
72
|
+
Permissify.decide(actor: stranger, action: :edit, resource: article, resource_key: :article).allowed?
|
|
73
|
+
# => false (owned is false)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`resource_key` selects the definition; `resource` is the row/object under test;
|
|
77
|
+
`action` is the verb. All four are required keywords.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 3. The Decision object
|
|
82
|
+
|
|
83
|
+
`decide` always returns an immutable `Permissify::Decision` — never a bare boolean,
|
|
84
|
+
never `nil`.
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
decision = Permissify.decide(actor: author, action: :edit, resource: article, resource_key: :article)
|
|
88
|
+
|
|
89
|
+
decision.allowed? # => true
|
|
90
|
+
decision.denied? # => false
|
|
91
|
+
decision.permission # => :editable
|
|
92
|
+
decision.reason # => :allowed (or :denied, :unknown_action, :missing_fact, …)
|
|
93
|
+
decision.checks # => [[:all?, [:draft, :owned], true]] trace of combinator calls
|
|
94
|
+
decision.metadata # => { resource_key: :article, action: :edit }
|
|
95
|
+
|
|
96
|
+
decision.to_h
|
|
97
|
+
# => { allowed: true, permission: :editable, reason: :allowed,
|
|
98
|
+
# checks: [...], metadata: { resource_key: :article, action: :edit } }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The `reason` distinguishes an ordinary policy `:denied` from a structural denial
|
|
102
|
+
(`:unknown_resource`, `:unknown_action`, `:unknown_permission`, `:missing_fact`,
|
|
103
|
+
`:fact_error`) — so a "no" is always explainable.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 4. Boolean projection — `allow?`
|
|
108
|
+
|
|
109
|
+
When you only need the yes/no:
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
Permissify.allow?(actor: author, action: :edit, resource: article, resource_key: :article)
|
|
113
|
+
# => true
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`allow?` is exactly `decide(...).allowed?` — same arguments, same safety contract.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 5. Enforcement — `authorize!`
|
|
121
|
+
|
|
122
|
+
At an enforcement boundary, raise on deny. The `Decision` is attached to the error.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
begin
|
|
126
|
+
Permissify.authorize!(actor: stranger, action: :edit, resource: article, resource_key: :article)
|
|
127
|
+
rescue Permissify::NotAuthorized => e
|
|
128
|
+
e.decision.reason # => :denied
|
|
129
|
+
e.decision.permission # => :editable
|
|
130
|
+
end
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
On allow, `authorize!` returns the `Decision`.
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## 6. Combinators — `all?` / `any?` / `none?`
|
|
138
|
+
|
|
139
|
+
Inside a permission, read facts through combinators. Each call is recorded in
|
|
140
|
+
`decision.checks`.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
Permissify.define(:document) do
|
|
144
|
+
fact(:published) { |ctx| ctx.resource.published }
|
|
145
|
+
fact(:owner) { |ctx| ctx.resource.owner_id == ctx.actor.id }
|
|
146
|
+
fact(:locked) { |ctx| ctx.resource.locked }
|
|
147
|
+
|
|
148
|
+
permission(:viewable) { |ctx| ctx.any?(:published, :owner) }
|
|
149
|
+
permission(:editable) { |ctx| ctx.all?(:owner) && ctx.none?(:locked) }
|
|
150
|
+
|
|
151
|
+
action :show, maps_to: :viewable
|
|
152
|
+
action :edit, maps_to: :editable
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `all?(*facts)` — true when every fact is true.
|
|
157
|
+
- `any?(*facts)` — true when at least one is true.
|
|
158
|
+
- `none?(*facts)` — true when every fact is false.
|
|
159
|
+
|
|
160
|
+
Facts are memoized per decision, so referencing the same fact twice evaluates it once.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## 7. Environment
|
|
165
|
+
|
|
166
|
+
Pass request- or tenant-scoped context that facts can read, without stuffing it onto
|
|
167
|
+
the actor or resource.
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
Permissify.define(:report) do
|
|
171
|
+
fact(:same_tenant) { |ctx| ctx.resource.tenant_id == ctx.environment[:tenant_id] }
|
|
172
|
+
permission(:viewable) { |ctx| ctx.all?(:same_tenant) }
|
|
173
|
+
action :show, maps_to: :viewable
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
Permissify.decide(actor: user, action: :show, resource: report,
|
|
177
|
+
resource_key: :report, environment: { tenant_id: 42 })
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## 8. Authorized scopes — authorization as a query
|
|
183
|
+
|
|
184
|
+
For collections, never filter in Ruby. Register a scope builder and let the database
|
|
185
|
+
do the narrowing. A missing scope raises `Permissify::NoScope` — it **never** returns
|
|
186
|
+
every row by default.
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
Permissify.define(:article) do
|
|
190
|
+
permission(:viewable) { |_ctx| true }
|
|
191
|
+
action :index, maps_to: :viewable
|
|
192
|
+
|
|
193
|
+
# builder receives (actor, relation, environment) and returns the narrowed relation
|
|
194
|
+
scope(:viewable) { |actor, relation, _env| relation.where(user_id: actor.id) }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
Permissify.authorized_scope(actor: current_user, action: :index,
|
|
198
|
+
relation: Article.all, resource_key: :article)
|
|
199
|
+
# => Article.where(user_id: current_user.id)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
`relation` is anything the host passes — an ActiveRecord relation in a Rails app, or a
|
|
203
|
+
plain Array in a test — so the port stays framework-free:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
rows = [{ id: 1, owner_id: 7 }, { id: 2, owner_id: 9 }, { id: 3, owner_id: 7 }]
|
|
207
|
+
Permissify.define(:row) do
|
|
208
|
+
permission(:viewable) { |_| true }
|
|
209
|
+
action :index, maps_to: :viewable
|
|
210
|
+
scope(:viewable) { |actor, relation, _env| relation.select { |r| r[:owner_id] == actor[:id] } }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
Permissify.authorized_scope(actor: { id: 7 }, action: :index, relation: rows, resource_key: :row)
|
|
214
|
+
# => [{ id: 1, owner_id: 7 }, { id: 3, owner_id: 7 }]
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## 9. The FactSource port
|
|
220
|
+
|
|
221
|
+
Facts don't have to be defined inline. A `FactSource` supplies them externally — and
|
|
222
|
+
the **same permission yields the same decision** whether a fact is inline or sourced
|
|
223
|
+
(the swap-the-source guarantee).
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
class MyFactSource
|
|
227
|
+
# return the boolean, or FactSource::Missing when this source doesn't know the fact
|
|
228
|
+
def fetch(name, ctx)
|
|
229
|
+
return ctx.resource[:owner_id] == ctx.actor[:id] if name == :owned
|
|
230
|
+
|
|
231
|
+
Permissify::FactSource::Missing
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
Permissify.define(:doc) do
|
|
236
|
+
permission(:editable) { |ctx| ctx.all?(:owned) } # :owned is NOT defined inline
|
|
237
|
+
action :edit, maps_to: :editable
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
Permissify.decide(actor: { id: 7 }, action: :edit, resource: { owner_id: 7 },
|
|
241
|
+
resource_key: :doc, fact_source: MyFactSource.new).allowed?
|
|
242
|
+
# => true
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
The default `FactSource::Null` returns `Missing` for everything; a fact that is neither
|
|
246
|
+
inline nor sourced raises `MissingFact`, which denies with reason `:missing_fact`.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## 10. Safety contracts
|
|
251
|
+
|
|
252
|
+
Everything unknown, and every evaluation error, **denies** — observably.
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
# Unknown resource
|
|
256
|
+
Permissify.decide(actor: u, action: :edit, resource: r, resource_key: :nope).reason
|
|
257
|
+
# => :unknown_resource
|
|
258
|
+
|
|
259
|
+
# Unknown action (no matching `action ..., maps_to:`)
|
|
260
|
+
Permissify.decide(actor: u, action: :teleport, resource: r, resource_key: :article).reason
|
|
261
|
+
# => :unknown_action
|
|
262
|
+
|
|
263
|
+
# A fact that raises does not blow up the request — it denies
|
|
264
|
+
Permissify.define(:brittle) do
|
|
265
|
+
fact(:boom) { |_| raise "kaboom" }
|
|
266
|
+
permission(:editable) { |ctx| ctx.all?(:boom) }
|
|
267
|
+
action :edit, maps_to: :editable
|
|
268
|
+
end
|
|
269
|
+
Permissify.decide(actor: u, action: :edit, resource: r, resource_key: :brittle).reason
|
|
270
|
+
# => :fact_error
|
|
271
|
+
|
|
272
|
+
# No superuser bypass, no ":#{action}able" fallback — an action only resolves through
|
|
273
|
+
# an explicit `action ..., maps_to:` mapping.
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 11. Migrating a Pundit policy
|
|
279
|
+
|
|
280
|
+
A real StockLive path. Pundit is **data-modelled** — one policy class per record, a
|
|
281
|
+
method per controller action, role checks repeated. Permissify is **domain-modelled** —
|
|
282
|
+
named facts and permissions, composed once.
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
# BEFORE — Pundit: 14 methods, two repeated role checks
|
|
286
|
+
class ContactPolicy < ApplicationPolicy
|
|
287
|
+
def index? = user.assessor?
|
|
288
|
+
# …9 more, all user.assessor?…
|
|
289
|
+
def destroy? = user.assessor? && user.staff?
|
|
290
|
+
def approve? = user.assessor? && user.staff?
|
|
291
|
+
class Scope < Scope
|
|
292
|
+
def resolve = scope.where(user: user)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# AFTER — Permissify: two facts, two permissions, explicit aliases + scope
|
|
297
|
+
Permissify.define(:contact) do
|
|
298
|
+
fact(:assessor) { |ctx| ctx.actor.assessor? }
|
|
299
|
+
fact(:staff) { |ctx| ctx.actor.staff? }
|
|
300
|
+
|
|
301
|
+
permission(:viewable) { |ctx| ctx.all?(:assessor) }
|
|
302
|
+
permission(:manageable) { |ctx| ctx.all?(:assessor, :staff) }
|
|
303
|
+
|
|
304
|
+
action :index, maps_to: :viewable
|
|
305
|
+
action :destroy, maps_to: :manageable
|
|
306
|
+
action :approve, maps_to: :manageable
|
|
307
|
+
|
|
308
|
+
scope(:viewable) { |actor, relation, _env| relation.where(user: actor) }
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
The 14 repeated `user.assessor?` collapse into one named fact; the `assessor? && staff?`
|
|
313
|
+
guards become one `:manageable` permission; the Pundit `Scope` becomes an explicit
|
|
314
|
+
`authorized_scope`.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 12. Optional Predicate as a domain-fact source
|
|
319
|
+
|
|
320
|
+
Permissify needs no other library. But where a **domain-validity** rule already lives in
|
|
321
|
+
[Predicate](https://github.com/StockLive-Group/predicate) — stable facts that are safe to
|
|
322
|
+
memoize — Permissify can read its result as a plain fact. Authorization stays fresh; the
|
|
323
|
+
domain rule stays where it belongs.
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
# Domain layer (Predicate) — "is this article complete?" is a property of the article,
|
|
327
|
+
# not of authorization. Memoized because it is stable.
|
|
328
|
+
Predicate.define(:article) do
|
|
329
|
+
content_complete { |s| is_present?(s[:title]) && is_present?(s[:body]) && s[:media_ids].any? }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Authorization layer (Permissify) — reads the domain result as one fact
|
|
333
|
+
Permissify.define(:article) do
|
|
334
|
+
fact(:complete) do |ctx|
|
|
335
|
+
Predicate.for(:article).call(:content_complete,
|
|
336
|
+
title: ctx.resource.title, body: ctx.resource.body, media_ids: ctx.resource.media_ids)
|
|
337
|
+
end
|
|
338
|
+
fact(:owner) { |ctx| ctx.resource.user_id == ctx.actor.id }
|
|
339
|
+
|
|
340
|
+
permission(:publishable) { |ctx| ctx.all?(:owner, :complete) }
|
|
341
|
+
action :publish, maps_to: :publishable
|
|
342
|
+
end
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Predicate is an *optional* domain layer beneath authorization — never a required
|
|
346
|
+
dependency, and never the store for volatile authz facts (those stay plain Ruby, and
|
|
347
|
+
fresh).
|
|
348
|
+
|
|
349
|
+
---
|
|
350
|
+
|
|
351
|
+
## 13. Rails integration
|
|
352
|
+
|
|
353
|
+
The core needs no framework. In a Rails app, adding the gem is enough — a Railtie loads
|
|
354
|
+
automatically (only when Rails is present, so pure-Ruby hosts stay stdlib-only) and gives
|
|
355
|
+
you **zero-config** wiring:
|
|
356
|
+
|
|
357
|
+
- Drop resource definitions in `app/permissify/*.rb` (each calls `Permissify.define`). The
|
|
358
|
+
Railtie ignores that directory in Zeitwerk and registers the files on boot and on every
|
|
359
|
+
reload — **no initializer, no loader glue**.
|
|
360
|
+
- `Permissify::Controller` is required for you; just include it.
|
|
361
|
+
|
|
362
|
+
```ruby
|
|
363
|
+
# app/permissify/contact.rb — registered automatically, no initializer needed
|
|
364
|
+
Permissify.define(:contact) do
|
|
365
|
+
fact(:assessor) { |ctx| ctx.actor.assessor? }
|
|
366
|
+
permission(:viewable) { |ctx| ctx.all?(:assessor) }
|
|
367
|
+
action :index, maps_to: :viewable
|
|
368
|
+
scope(:viewable) { |actor, relation, _env| relation.where(user: actor) }
|
|
369
|
+
end
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```ruby
|
|
373
|
+
class ApplicationController < ActionController::Base
|
|
374
|
+
include Permissify::Controller
|
|
375
|
+
verify_permissify_authorized # fail loud if an action forgot to authorize
|
|
376
|
+
rescue_from Permissify::NotAuthorized, with: :forbidden
|
|
377
|
+
|
|
378
|
+
private
|
|
379
|
+
|
|
380
|
+
def forbidden = head(:forbidden)
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
class ContactsController < ApplicationController
|
|
384
|
+
def show
|
|
385
|
+
@contact = permissify_authorize(Contact.find(params[:id])) # raises on deny
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def index
|
|
389
|
+
@contacts = permissify_scope(Contact.all) # DB-level narrowing
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
- `permissify_authorize(resource, action:, resource_key:, environment:)` — returns the
|
|
395
|
+
resource on allow (so it wraps an assignment), raises `NotAuthorized` on deny. `action`
|
|
396
|
+
defaults to the controller `action_name`; `resource_key` is inferred from the model name
|
|
397
|
+
(`Contact` → `:contact`, `LivestockType` → `:livestock_type`) unless given.
|
|
398
|
+
- `permissify_scope(relation, …)` — delegates to `authorized_scope`; raises `NoScope` when
|
|
399
|
+
the resource has no registered scope.
|
|
400
|
+
- `verify_permissify_authorized` / `verify_permissify_scoped` — after_action guards so a
|
|
401
|
+
forgotten authorization fails loud instead of passing open.
|
|
402
|
+
- Override `permissify_actor` (defaults to `current_user`) and `permissify_environment`
|
|
403
|
+
(defaults to `{}`) to change the subject or supply tenant/request context.
|
|
404
|
+
|
|
405
|
+
The method names are deliberately distinct from Pundit's (`authorize`, `policy_scope`,
|
|
406
|
+
`verify_authorized`) so both can run side by side during a shadow migration.
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## 14. Sourcing many domain facts — `PredicateFactSource`
|
|
411
|
+
|
|
412
|
+
Section 12 reads a single Predicate result inline. When several **stable domain facts**
|
|
413
|
+
come from one Predicate entity, `require "permissify/predicate"` and wire them through the
|
|
414
|
+
`FactSource` port instead of writing a block per fact.
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
source = Permissify::PredicateFactSource.new(
|
|
418
|
+
entity: Predicate.for(:article), # responds to call(name, state) -> boolean
|
|
419
|
+
facts: [:content_complete, :media_ready],
|
|
420
|
+
state: ->(ctx) { { title: ctx.resource.title, body: ctx.resource.body,
|
|
421
|
+
media_ids: ctx.resource.media_ids } }
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
Permissify.define(:article) do
|
|
425
|
+
fact(:owner) { |ctx| ctx.resource.user_id == ctx.actor.id } # volatile → inline, fresh
|
|
426
|
+
permission(:publishable) { |ctx| ctx.all?(:owner, :content_complete) }
|
|
427
|
+
action :publish, maps_to: :publishable
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
Permissify.decide(actor: user, action: :publish, resource: article,
|
|
431
|
+
resource_key: :article, fact_source: source)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
- `facts:` declares exactly which names this source owns; any other name returns `Missing`,
|
|
435
|
+
so inline facts (like `:owner`) and other sources still win.
|
|
436
|
+
- An owned fact whose Predicate entity raises denies observably with `:fact_error` — never a
|
|
437
|
+
silent allow. A fact neither inline nor owned denies with `:missing_fact`.
|
|
438
|
+
- The entity is injected, so there is **no load-time dependency** on the `predicate` gem;
|
|
439
|
+
the adapter is testable with a stub and degrades safely when Predicate isn't wired.
|
|
440
|
+
|
|
441
|
+
**Use it only for stable facts.** Predicate always memoizes, so volatile authorization facts
|
|
442
|
+
(roles, ownership) must stay inline and fresh — Predicate is a domain layer *beneath*
|
|
443
|
+
authorization, never a store for authz state.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 StockLive-Group
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Permissify
|
|
2
|
+
|
|
3
|
+
Activity-based authorization for Ruby. One explicit question, a structured
|
|
4
|
+
answer, default-deny by construction. No framework required.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
Permissify.define(:article) do
|
|
8
|
+
fact(:draft) { |ctx| ctx.resource.status == "draft" }
|
|
9
|
+
fact(:owned) { |ctx| ctx.resource.user_id == ctx.actor.id }
|
|
10
|
+
|
|
11
|
+
permission(:editable) { |ctx| ctx.all?(:draft, :owned) }
|
|
12
|
+
|
|
13
|
+
action :edit, maps_to: :editable
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
decision = Permissify.decide(actor: user, action: :edit,
|
|
17
|
+
resource: article, resource_key: :article)
|
|
18
|
+
decision.allowed? # => true / false
|
|
19
|
+
decision.reason # => :allowed / :denied / :unknown_action / :missing_fact / ...
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## What it guarantees
|
|
23
|
+
|
|
24
|
+
- **Default-deny.** Unknown resource, action, permission, or fact — and any
|
|
25
|
+
evaluation error — all deny. Nothing is allowed by omission.
|
|
26
|
+
- **No superuser bypass.** Elevated access is an ordinary named fact used inside a
|
|
27
|
+
permission, so it is explainable and testable.
|
|
28
|
+
- **No magic action fallback.** Action aliases are explicit and finite; there is
|
|
29
|
+
no generated `:"#{action}able"`.
|
|
30
|
+
- **Structured decisions.** Every evaluation returns an immutable `Decision`
|
|
31
|
+
(`allowed`, `permission`, `reason`, `checks`, `metadata`). Booleans are
|
|
32
|
+
projections of it.
|
|
33
|
+
- **Ruby-only core.** Rails and [Predicate](https://github.com/StockLive-Group/predicate)
|
|
34
|
+
are optional adapters, loaded only when their host constants are present.
|
|
35
|
+
|
|
36
|
+
## FactSource
|
|
37
|
+
|
|
38
|
+
Facts a definition does not register inline are resolved through a `FactSource`
|
|
39
|
+
(`fetch(name, context)` → value or `FactSource::Missing`). A missing fact denies.
|
|
40
|
+
Predicate integration is simply an alternative `FactSource`; swapping it must not
|
|
41
|
+
change any decision.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
Published on RubyGems as `permissify-rb` (the name `permissify` belongs to an
|
|
46
|
+
unrelated gem). The library is still required as `permissify`.
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# Gemfile
|
|
50
|
+
gem "permissify-rb"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
require "permissify" # core
|
|
55
|
+
require "permissify/rails" # optional controller concern (auto-loaded under Rails)
|
|
56
|
+
require "permissify/predicate" # optional Predicate fact source
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Status
|
|
60
|
+
|
|
61
|
+
`0.5.0` — the core (Registry, DSL, `DecisionContext`, `Decision`, `FactSource`,
|
|
62
|
+
default-deny), database-level authorized scopes, the optional Rails controller
|
|
63
|
+
adapter and zero-config Railtie, and the optional Predicate fact source are
|
|
64
|
+
implemented and tested. See [CHANGELOG.md](CHANGELOG.md).
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bundle install
|
|
68
|
+
bundle exec rake # boundary check, workflows, suite
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Releases follow [RELEASING.md](RELEASING.md).
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Permissify
|
|
4
|
+
# The immutable result of every evaluation. Boolean helpers (`can?`) are
|
|
5
|
+
# projections of this; enforcement (`authorize!`) reads its reason and checks.
|
|
6
|
+
# It carries only structured, safe identifiers — never arbitrary actor/resource
|
|
7
|
+
# attributes.
|
|
8
|
+
class Decision
|
|
9
|
+
attr_reader :permission, :reason, :checks, :metadata
|
|
10
|
+
|
|
11
|
+
# reason is one of:
|
|
12
|
+
# :allowed :denied :unknown_resource :unknown_action
|
|
13
|
+
# :unknown_permission :missing_fact :fact_error
|
|
14
|
+
def initialize(allowed:, permission:, reason:, checks: [], metadata: {})
|
|
15
|
+
@allowed = allowed ? true : false
|
|
16
|
+
@permission = permission
|
|
17
|
+
@reason = reason
|
|
18
|
+
@checks = checks.freeze
|
|
19
|
+
@metadata = metadata.freeze
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def allowed? = @allowed
|
|
24
|
+
def denied? = !@allowed
|
|
25
|
+
|
|
26
|
+
def to_h
|
|
27
|
+
{ allowed: @allowed, permission: @permission, reason: @reason,
|
|
28
|
+
checks: @checks, metadata: @metadata }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Permissify
|
|
4
|
+
# The typed input a permission/fact block evaluates against. It is explicit on
|
|
5
|
+
# purpose: it does not serialize the resource, inject magic keys, or hash object
|
|
6
|
+
# graphs. Facts read `actor`, `resource`, and `environment` directly, and compose
|
|
7
|
+
# other facts through `fact`, `all?`, `any?`, `none?`.
|
|
8
|
+
class DecisionContext
|
|
9
|
+
attr_reader :actor, :resource, :action, :environment
|
|
10
|
+
|
|
11
|
+
def initialize(actor:, resource:, action:, resource_def:, environment:, fact_source:)
|
|
12
|
+
@actor = actor
|
|
13
|
+
@resource = resource
|
|
14
|
+
@action = action
|
|
15
|
+
@resource_def = resource_def
|
|
16
|
+
@environment = environment
|
|
17
|
+
@fact_source = fact_source
|
|
18
|
+
@cache = {}
|
|
19
|
+
@checks = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Resolve a named fact to a boolean. Inline-registered facts win; otherwise the
|
|
23
|
+
# FactSource is consulted, and an unresolvable fact raises MissingFact (which
|
|
24
|
+
# the pipeline turns into a default-deny). Results are memoized per context.
|
|
25
|
+
def fact(name)
|
|
26
|
+
key = name.to_sym
|
|
27
|
+
return @cache[key] if @cache.key?(key)
|
|
28
|
+
|
|
29
|
+
@cache[key] = compute(key)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all?(*names) = names.all? { |n| record(n) }
|
|
33
|
+
def any?(*names) = names.any? { |n| record(n) }
|
|
34
|
+
def none?(*names) = names.none? { |n| record(n) }
|
|
35
|
+
|
|
36
|
+
# The ordered trace of composed checks, attached to the Decision.
|
|
37
|
+
def checks = @checks
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def record(name)
|
|
42
|
+
result = fact(name)
|
|
43
|
+
@checks << { name: name.to_sym, result: result }
|
|
44
|
+
result
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def compute(name)
|
|
48
|
+
block = @resource_def.facts[name]
|
|
49
|
+
if block
|
|
50
|
+
block.call(self) ? true : false
|
|
51
|
+
else
|
|
52
|
+
value = @fact_source.fetch(name, self)
|
|
53
|
+
raise MissingFact, name if value.equal?(FactSource::Missing)
|
|
54
|
+
|
|
55
|
+
value ? true : false
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Permissify
|
|
4
|
+
# Evaluated (via instance_eval) inside `Permissify.define`. Every call writes an
|
|
5
|
+
# immutable entry into a ResourceDefinition. Duplicate names and missing blocks
|
|
6
|
+
# fail loudly at boot; there is no implicit behaviour.
|
|
7
|
+
class DSL
|
|
8
|
+
def initialize(definition)
|
|
9
|
+
@definition = definition
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fact(name, &block)
|
|
13
|
+
raise DefinitionError, "fact(:#{name}) requires a block" unless block
|
|
14
|
+
|
|
15
|
+
key = name.to_sym
|
|
16
|
+
guard_unique!(@definition.facts, key, "fact")
|
|
17
|
+
@definition.facts[key] = block
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def permission(name, &block)
|
|
21
|
+
raise DefinitionError, "permission(:#{name}) requires a block" unless block
|
|
22
|
+
|
|
23
|
+
key = name.to_sym
|
|
24
|
+
guard_unique!(@definition.permissions, key, "permission")
|
|
25
|
+
@definition.permissions[key] = block
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Explicit, finite action aliases. There is no generated ":#{action}able"
|
|
29
|
+
# fallback — an unregistered action denies.
|
|
30
|
+
def action(name, maps_to:)
|
|
31
|
+
key = name.to_sym
|
|
32
|
+
guard_unique!(@definition.actions, key, "action alias")
|
|
33
|
+
@definition.actions[key] = maps_to.to_sym
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# A SQL-capable scope for a permission: a block ->(actor, relation, environment)
|
|
37
|
+
# that returns a narrowed relation. authorized_scope requires one — collections
|
|
38
|
+
# are never filtered in Ruby as a security boundary.
|
|
39
|
+
def scope(permission_name, &block)
|
|
40
|
+
raise DefinitionError, "scope(:#{permission_name}) requires a block" unless block
|
|
41
|
+
|
|
42
|
+
key = permission_name.to_sym
|
|
43
|
+
guard_unique!(@definition.scopes, key, "scope")
|
|
44
|
+
@definition.scopes[key] = block
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def guard_unique!(collection, key, kind)
|
|
50
|
+
return unless collection.key?(key)
|
|
51
|
+
|
|
52
|
+
raise DefinitionError, "duplicate #{kind} :#{key} on #{@definition.key}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Permissify
|
|
4
|
+
# Base class for every error raised by Permissify.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised at registration time for an invalid definition (duplicate name,
|
|
8
|
+
# missing block, bad alias).
|
|
9
|
+
class DefinitionError < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised internally when a referenced fact cannot be resolved. It is caught by
|
|
12
|
+
# the decision pipeline and converted into a default-deny Decision — it never
|
|
13
|
+
# escapes as a silent allow.
|
|
14
|
+
class MissingFact < Error
|
|
15
|
+
attr_reader :name
|
|
16
|
+
|
|
17
|
+
def initialize(name)
|
|
18
|
+
@name = name.to_sym
|
|
19
|
+
super("missing fact: #{@name}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised by `authorize!` when a decision denies. The full Decision is attached
|
|
24
|
+
# so callers/rescue handlers can inspect the reason and checks.
|
|
25
|
+
class NotAuthorized < Error
|
|
26
|
+
attr_reader :decision
|
|
27
|
+
|
|
28
|
+
def initialize(decision)
|
|
29
|
+
@decision = decision
|
|
30
|
+
super("not authorized: #{decision.permission} (#{decision.reason})")
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Raised by authorized_scope when no SQL-capable scope builder is registered for
|
|
35
|
+
# the resolved permission. Missing scopes fail loud — never a fallback to all rows.
|
|
36
|
+
class NoScope < Error
|
|
37
|
+
def initialize(resource_key, target)
|
|
38
|
+
super("no authorized_scope registered for #{resource_key}##{target}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Permissify
|
|
4
|
+
# A FactSource supplies named facts the DSL did not register inline. It responds
|
|
5
|
+
# to `fetch(name, context)` and returns a value, or `FactSource::Missing` when it
|
|
6
|
+
# cannot supply the fact. A missing fact denies the decision — it is never a
|
|
7
|
+
# silent allow. Predicate integration is just an alternative FactSource.
|
|
8
|
+
module FactSource
|
|
9
|
+
# Sentinel meaning "this source cannot answer that fact".
|
|
10
|
+
Missing = Object.new
|
|
11
|
+
def Missing.inspect = "Permissify::FactSource::Missing"
|
|
12
|
+
def Missing.to_s = inspect
|
|
13
|
+
Missing.freeze
|
|
14
|
+
|
|
15
|
+
# Default source: answers nothing, so any fact not registered inline denies.
|
|
16
|
+
class Null
|
|
17
|
+
def fetch(_name, _context)
|
|
18
|
+
Missing
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../permissify"
|
|
4
|
+
|
|
5
|
+
module Permissify
|
|
6
|
+
# Optional adapter: source a family of facts from a Predicate entity through the
|
|
7
|
+
# FactSource port, without a load-time dependency on the `predicate` gem.
|
|
8
|
+
#
|
|
9
|
+
# Predicate ALWAYS memoizes, so this belongs to STABLE domain-validity facts —
|
|
10
|
+
# "is this article complete?", "does this lot have media?" — never to volatile
|
|
11
|
+
# authorization facts like roles or ownership, which must stay fresh as inline
|
|
12
|
+
# facts. Predicate is a domain layer *beneath* authorization; Permissify reads its
|
|
13
|
+
# result as a plain boolean and still owns the decision.
|
|
14
|
+
#
|
|
15
|
+
# source = Permissify::PredicateFactSource.new(
|
|
16
|
+
# entity: Predicate.for(:article), # responds to call(name, state) -> bool
|
|
17
|
+
# facts: [:content_complete], # the facts this source owns
|
|
18
|
+
# state: ->(ctx) { { title: ctx.resource.title, body: ctx.resource.body,
|
|
19
|
+
# media_ids: ctx.resource.media_ids } }
|
|
20
|
+
# )
|
|
21
|
+
#
|
|
22
|
+
# Permissify.decide(actor:, action:, resource:, resource_key:, fact_source: source)
|
|
23
|
+
#
|
|
24
|
+
# The entity is injected (never looked up here), so the adapter is fully testable
|
|
25
|
+
# with a stub and works whether or not Predicate is installed.
|
|
26
|
+
class PredicateFactSource
|
|
27
|
+
# +entity+:: any object responding to +call(fact_name, state_hash) -> boolean+
|
|
28
|
+
# (exactly +Predicate.for(:x)+).
|
|
29
|
+
# +facts+:: the fact names this source owns. A name outside this set returns
|
|
30
|
+
# FactSource::Missing so inline facts and other sources still win.
|
|
31
|
+
# +state+:: a callable +(context) -> Hash+ building the Predicate state from the
|
|
32
|
+
# DecisionContext (reads +context.resource+, +context.actor+, …).
|
|
33
|
+
def initialize(entity:, facts:, state:)
|
|
34
|
+
@entity = entity
|
|
35
|
+
@facts = Array(facts).map(&:to_sym)
|
|
36
|
+
@state = state
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Return the boolean for an owned fact, or FactSource::Missing for anything else.
|
|
40
|
+
# An error raised by the entity for an owned fact propagates — the decision
|
|
41
|
+
# pipeline converts it into a default-deny with reason :fact_error.
|
|
42
|
+
def fetch(name, context)
|
|
43
|
+
key = name.to_sym
|
|
44
|
+
return FactSource::Missing unless @facts.include?(key)
|
|
45
|
+
|
|
46
|
+
@entity.call(key, @state.call(context)) ? true : false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../permissify"
|
|
4
|
+
|
|
5
|
+
module Permissify
|
|
6
|
+
# Optional Rails integration. It adds nothing to the core and imposes no runtime
|
|
7
|
+
# dependency — `require "permissify/rails"` only when a Rails app wants it, then:
|
|
8
|
+
#
|
|
9
|
+
# class ApplicationController < ActionController::Base
|
|
10
|
+
# include Permissify::Controller
|
|
11
|
+
# verify_permissify_authorized # fail loud if an action forgot to authorize
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# class ContactsController < ApplicationController
|
|
15
|
+
# def show
|
|
16
|
+
# @contact = permissify_authorize(Contact.find(params[:id])) # raises on deny
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# def index
|
|
20
|
+
# @contacts = permissify_scope(Contact.all) # DB-level narrowing
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# The names are deliberately distinct from Pundit's (`authorize`, `policy_scope`,
|
|
25
|
+
# `verify_authorized`) so both can run side by side during a shadow migration —
|
|
26
|
+
# see the Pundit-eradication plan.
|
|
27
|
+
module Controller
|
|
28
|
+
def self.included(base)
|
|
29
|
+
base.extend(ClassMethods)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module ClassMethods
|
|
33
|
+
# Fail loud (after_action) if an action returned without calling
|
|
34
|
+
# permissify_authorize. A forgotten authorization must never pass silently.
|
|
35
|
+
def verify_permissify_authorized(**options)
|
|
36
|
+
after_action(:permissify_verify_authorized!, **options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fail loud if an action returned without calling permissify_scope.
|
|
40
|
+
def verify_permissify_scoped(**options)
|
|
41
|
+
after_action(:permissify_verify_scoped!, **options)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The subject under authorization. Defaults to +current_user+; override to
|
|
46
|
+
# authorize something else (an API token, an org membership, …).
|
|
47
|
+
def permissify_actor
|
|
48
|
+
current_user
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Request/tenant context passed to facts as +environment+. Override to supply
|
|
52
|
+
# tenant ids, feature flags, request metadata, etc. Defaults to empty.
|
|
53
|
+
def permissify_environment
|
|
54
|
+
{}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Enforce authorization for +resource+. Returns +resource+ on allow so it can
|
|
58
|
+
# wrap an assignment; raises Permissify::NotAuthorized on deny (rescue it in the
|
|
59
|
+
# host, e.g. rescue_from Permissify::NotAuthorized).
|
|
60
|
+
#
|
|
61
|
+
# +action+:: defaults to the controller action_name
|
|
62
|
+
# +resource_key+:: defaults to an inferred key (see permissify_resource_key)
|
|
63
|
+
def permissify_authorize(resource, action: action_name, resource_key: nil, environment: nil)
|
|
64
|
+
@_permissify_authorized = true
|
|
65
|
+
Permissify.authorize!(
|
|
66
|
+
actor: permissify_actor,
|
|
67
|
+
action: action.to_sym,
|
|
68
|
+
resource: resource,
|
|
69
|
+
resource_key: resource_key || permissify_resource_key(resource),
|
|
70
|
+
environment: environment || permissify_environment
|
|
71
|
+
)
|
|
72
|
+
resource
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Narrow +relation+ to what the actor may see, at the database level. Delegates
|
|
76
|
+
# to Permissify.authorized_scope and raises Permissify::NoScope when the resource
|
|
77
|
+
# has no registered scope (it never returns every row by default).
|
|
78
|
+
def permissify_scope(relation, action: action_name, resource_key: nil, environment: nil)
|
|
79
|
+
@_permissify_scoped = true
|
|
80
|
+
Permissify.authorized_scope(
|
|
81
|
+
actor: permissify_actor,
|
|
82
|
+
action: action.to_sym,
|
|
83
|
+
relation: relation,
|
|
84
|
+
resource_key: resource_key || permissify_resource_key(relation),
|
|
85
|
+
environment: environment || permissify_environment
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Infer the registry key from a record, relation, or class by its model name:
|
|
92
|
+
# Contact => :contact, LivestockType => :livestock_type. Override for custom maps.
|
|
93
|
+
def permissify_resource_key(target)
|
|
94
|
+
name =
|
|
95
|
+
if target.respond_to?(:model_name) then target.model_name.name
|
|
96
|
+
elsif target.class.respond_to?(:model_name) then target.class.model_name.name
|
|
97
|
+
elsif target.is_a?(Module) then target.name
|
|
98
|
+
else target.class.name
|
|
99
|
+
end
|
|
100
|
+
demodulized = name.to_s.split("::").last
|
|
101
|
+
demodulized.gsub(/([a-z\d])([A-Z])/, '\1_\2').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').downcase.to_sym
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def permissify_verify_authorized!
|
|
105
|
+
return if @_permissify_authorized
|
|
106
|
+
|
|
107
|
+
raise Permissify::Error,
|
|
108
|
+
"#{self.class}##{action_name} returned without calling permissify_authorize"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def permissify_verify_scoped!
|
|
112
|
+
return if @_permissify_scoped
|
|
113
|
+
|
|
114
|
+
raise Permissify::Error,
|
|
115
|
+
"#{self.class}##{action_name} returned without calling permissify_scope"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
require_relative "../permissify"
|
|
5
|
+
require_relative "rails" # make Permissify::Controller available in Rails apps
|
|
6
|
+
|
|
7
|
+
module Permissify
|
|
8
|
+
# Optional Rails integration, loaded automatically when Rails is present (see the
|
|
9
|
+
# conditional require at the bottom of lib/permissify.rb). It keeps host setup to
|
|
10
|
+
# zero — no initializer, no loader glue:
|
|
11
|
+
#
|
|
12
|
+
# * drop definitions in app/permissify/*.rb (each calls Permissify.define)
|
|
13
|
+
# * they register on boot and re-register on every code reload
|
|
14
|
+
#
|
|
15
|
+
# This mirrors the Predicate gem's Railtie so both frameworks load the same way.
|
|
16
|
+
class Railtie < Rails::Railtie
|
|
17
|
+
DEFINITIONS_DIR = "app/permissify"
|
|
18
|
+
|
|
19
|
+
# Definition files call Permissify.define and do NOT define constants matching
|
|
20
|
+
# their paths, so the main Zeitwerk autoloader must ignore the directory.
|
|
21
|
+
initializer "permissify.ignore_definitions", before: :set_autoload_paths do |app|
|
|
22
|
+
path = app.root.join(DEFINITIONS_DIR)
|
|
23
|
+
app.autoloaders.main.ignore(path) if path.exist?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register every definition on boot, and re-register on each reload. `load`
|
|
27
|
+
# (not `require`) so edits are picked up by Rails' reloader in development.
|
|
28
|
+
config.to_prepare do
|
|
29
|
+
Permissify.reset!
|
|
30
|
+
Dir[Rails.root.join(DEFINITIONS_DIR, "**", "*.rb")].sort.each { |file| load file }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Permissify
|
|
4
|
+
# The boot-time store of definitions. It holds, per resource key, the named
|
|
5
|
+
# facts, named permissions, and explicit action aliases. The DSL is only a
|
|
6
|
+
# registration script that writes here; the Registry has the authority.
|
|
7
|
+
# Mutation is synchronized and expected only at boot/reload boundaries.
|
|
8
|
+
class Registry
|
|
9
|
+
# One resource's registered facts, permissions, and action aliases.
|
|
10
|
+
class ResourceDefinition
|
|
11
|
+
attr_reader :key, :facts, :permissions, :actions, :scopes
|
|
12
|
+
|
|
13
|
+
def initialize(key)
|
|
14
|
+
@key = key
|
|
15
|
+
@facts = {}
|
|
16
|
+
@permissions = {}
|
|
17
|
+
@actions = {}
|
|
18
|
+
@scopes = {}
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@resources = {}
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def define(resource_key, &block)
|
|
28
|
+
raise DefinitionError, "define requires a block" unless block
|
|
29
|
+
|
|
30
|
+
key = resource_key.to_sym
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
definition = (@resources[key] ||= ResourceDefinition.new(key))
|
|
33
|
+
DSL.new(definition).instance_eval(&block)
|
|
34
|
+
end
|
|
35
|
+
self
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resource(resource_key)
|
|
39
|
+
return nil unless resource_key
|
|
40
|
+
|
|
41
|
+
@resources[resource_key.to_sym]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resource_keys = @resources.keys
|
|
45
|
+
|
|
46
|
+
def clear
|
|
47
|
+
@mutex.synchronize { @resources.clear }
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/permissify.rb
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "permissify/version"
|
|
4
|
+
require_relative "permissify/errors"
|
|
5
|
+
require_relative "permissify/fact_source"
|
|
6
|
+
require_relative "permissify/decision"
|
|
7
|
+
require_relative "permissify/decision_context"
|
|
8
|
+
require_relative "permissify/registry"
|
|
9
|
+
require_relative "permissify/dsl"
|
|
10
|
+
|
|
11
|
+
# Permissify — activity-based authorization.
|
|
12
|
+
#
|
|
13
|
+
# Permissify.define(:article) do
|
|
14
|
+
# fact(:draft) { |ctx| ctx.resource.status == "draft" }
|
|
15
|
+
# fact(:owned) { |ctx| ctx.resource.user_id == ctx.actor.id }
|
|
16
|
+
# permission(:editable) { |ctx| ctx.all?(:draft, :owned) }
|
|
17
|
+
# action :edit, maps_to: :editable
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# decision = Permissify.decide(actor: user, action: :edit,
|
|
21
|
+
# resource: article, resource_key: :article)
|
|
22
|
+
# decision.allowed? # => true / false
|
|
23
|
+
#
|
|
24
|
+
# Safety contract: unknown resource / action / permission / fact and any
|
|
25
|
+
# evaluation error all DENY. There is no superuser bypass and no generated
|
|
26
|
+
# ":#{action}able" fallback — everything is explicit and observable.
|
|
27
|
+
module Permissify
|
|
28
|
+
class << self
|
|
29
|
+
def registry
|
|
30
|
+
@registry ||= Registry.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Reset the global registry — for tests and dev reloads.
|
|
34
|
+
def reset!
|
|
35
|
+
@registry = Registry.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Register a resource's facts, permissions, and action aliases. The block is
|
|
39
|
+
# evaluated by the DSL; see Permissify::DSL for the available declarations.
|
|
40
|
+
def define(resource_key, &block)
|
|
41
|
+
registry.define(resource_key, &block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Evaluate one authorization question and return an immutable Decision.
|
|
45
|
+
#
|
|
46
|
+
# +actor+:: the subject performing the action
|
|
47
|
+
# +action+:: the verb, resolved to a permission via an action alias
|
|
48
|
+
# +resource+:: the object under test
|
|
49
|
+
# +resource_key+:: selects the registered definition
|
|
50
|
+
# +environment+:: optional request/tenant context facts may read
|
|
51
|
+
# +fact_source+:: optional external FactSource for facts not registered inline
|
|
52
|
+
#
|
|
53
|
+
# Unknown resource/action/permission/fact and any evaluation error all DENY,
|
|
54
|
+
# each with a distinct Decision#reason. Never returns a bare boolean or nil.
|
|
55
|
+
def decide(actor:, action:, resource:, resource_key:, environment: {}, fact_source: FactSource::Null.new)
|
|
56
|
+
resource_def = registry.resource(resource_key)
|
|
57
|
+
return deny(:unknown_resource, nil, resource_key: resource_key) unless resource_def
|
|
58
|
+
|
|
59
|
+
action_key = action.to_sym
|
|
60
|
+
permission_name = resource_def.actions[action_key]
|
|
61
|
+
return deny(:unknown_action, nil, resource_key: resource_key, action: action_key) unless permission_name
|
|
62
|
+
|
|
63
|
+
permission_block = resource_def.permissions[permission_name]
|
|
64
|
+
return deny(:unknown_permission, permission_name, resource_key: resource_key) unless permission_block
|
|
65
|
+
|
|
66
|
+
context = DecisionContext.new(
|
|
67
|
+
actor: actor, resource: resource, action: action_key,
|
|
68
|
+
resource_def: resource_def, environment: environment, fact_source: fact_source
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
allowed = permission_block.call(context) ? true : false
|
|
73
|
+
rescue MissingFact => e
|
|
74
|
+
return deny(:missing_fact, permission_name, checks: context.checks, missing: e.name)
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
return deny(:fact_error, permission_name, checks: context.checks, error: e.class.name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Decision.new(
|
|
80
|
+
allowed: allowed,
|
|
81
|
+
permission: permission_name,
|
|
82
|
+
reason: allowed ? :allowed : :denied,
|
|
83
|
+
checks: context.checks,
|
|
84
|
+
metadata: { resource_key: resource_key, action: action_key }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Convenience boolean projection of a decision.
|
|
89
|
+
def allow?(**kwargs)
|
|
90
|
+
decide(**kwargs).allowed?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Enforcement: raises NotAuthorized (with the Decision attached) on deny.
|
|
94
|
+
def authorize!(**kwargs)
|
|
95
|
+
decision = decide(**kwargs)
|
|
96
|
+
raise NotAuthorized, decision unless decision.allowed?
|
|
97
|
+
|
|
98
|
+
decision
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Translate authorization into a database query. Delegates to a registered
|
|
102
|
+
# SQL-capable scope builder; raises NoScope if none exists (it never returns
|
|
103
|
+
# every row by default). The builder receives (actor, relation, environment)
|
|
104
|
+
# and returns the narrowed relation.
|
|
105
|
+
def authorized_scope(actor:, action:, relation:, resource_key:, environment: {})
|
|
106
|
+
resource_def = registry.resource(resource_key)
|
|
107
|
+
raise NoScope.new(resource_key, action) unless resource_def
|
|
108
|
+
|
|
109
|
+
permission_name = resource_def.actions[action.to_sym]
|
|
110
|
+
raise NoScope.new(resource_key, action) unless permission_name
|
|
111
|
+
|
|
112
|
+
builder = resource_def.scopes[permission_name]
|
|
113
|
+
raise NoScope.new(resource_key, permission_name) unless builder
|
|
114
|
+
|
|
115
|
+
builder.call(actor, relation, environment)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def deny(reason, permission, checks: [], **metadata)
|
|
121
|
+
Decision.new(allowed: false, permission: permission, reason: reason,
|
|
122
|
+
checks: checks, metadata: metadata)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Optional, zero-config Rails integration. Only loads when Rails is already
|
|
128
|
+
# present, so the core keeps its stdlib-only footprint in pure-Ruby hosts.
|
|
129
|
+
require_relative "permissify/railtie" if defined?(Rails::Railtie)
|
metadata
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: permissify-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.5.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- StockLive
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
Permissify answers `actor.can?(:action, resource)` by evaluating small named
|
|
14
|
+
facts and permissions registered at boot, and returns a structured Decision.
|
|
15
|
+
The core depends only on the Ruby standard library; Rails and Predicate are
|
|
16
|
+
optional adapters loaded when their host constants are available.
|
|
17
|
+
API docs (RDoc): https://kuickr.co/stocklive/permissify-rdocs
|
|
18
|
+
email:
|
|
19
|
+
- developers@stocklive.com.au
|
|
20
|
+
executables: []
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- CHANGELOG.md
|
|
25
|
+
- EXAMPLES.md
|
|
26
|
+
- LICENSE.txt
|
|
27
|
+
- README.md
|
|
28
|
+
- lib/permissify-rb.rb
|
|
29
|
+
- lib/permissify.rb
|
|
30
|
+
- lib/permissify/decision.rb
|
|
31
|
+
- lib/permissify/decision_context.rb
|
|
32
|
+
- lib/permissify/dsl.rb
|
|
33
|
+
- lib/permissify/errors.rb
|
|
34
|
+
- lib/permissify/fact_source.rb
|
|
35
|
+
- lib/permissify/predicate.rb
|
|
36
|
+
- lib/permissify/rails.rb
|
|
37
|
+
- lib/permissify/railtie.rb
|
|
38
|
+
- lib/permissify/registry.rb
|
|
39
|
+
- lib/permissify/version.rb
|
|
40
|
+
homepage: https://github.com/StockLive-Group/permissify
|
|
41
|
+
licenses:
|
|
42
|
+
- MIT
|
|
43
|
+
metadata:
|
|
44
|
+
homepage_uri: https://github.com/StockLive-Group/permissify
|
|
45
|
+
source_code_uri: https://github.com/StockLive-Group/permissify
|
|
46
|
+
changelog_uri: https://github.com/StockLive-Group/permissify/blob/main/CHANGELOG.md
|
|
47
|
+
documentation_uri: https://kuickr.co/stocklive/permissify-rdocs
|
|
48
|
+
rdoc_options: []
|
|
49
|
+
require_paths:
|
|
50
|
+
- lib
|
|
51
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
52
|
+
requirements:
|
|
53
|
+
- - ">="
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.1'
|
|
56
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
requirements: []
|
|
62
|
+
rubygems_version: 4.0.20
|
|
63
|
+
specification_version: 4
|
|
64
|
+
summary: 'Activity-based authorization: one explicit decision, default-deny, no framework
|
|
65
|
+
required.'
|
|
66
|
+
test_files: []
|