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 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Permissify
4
+ # The gem version (semantic versioning).
5
+ VERSION = "0.5.0"
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is published as `permissify-rb`; Bundler.require loads this file by
4
+ # that name. The library itself is `permissify` — this file must never do more
5
+ # than require it.
6
+ require "permissify"
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: []