tina4ruby 3.13.104 → 3.13.107
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +88 -0
- data/lib/tina4/cli.rb +7 -4
- data/lib/tina4/orm.rb +17 -5
- data/lib/tina4/queue.rb +36 -6
- data/lib/tina4/queue_backends/lite_backend.rb +18 -1
- data/lib/tina4/queue_backends/mongo_backend.rb +38 -10
- data/lib/tina4/rack_app.rb +57 -0
- data/lib/tina4/router.rb +30 -0
- data/lib/tina4/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ab8e75446e8ed41f28be0078b1f16de1e1c0be39ff83cefea98f2a5cc9a78cc2
|
|
4
|
+
data.tar.gz: b8d786867f6a37c87bbc0e242a615c7df8962ae8fd188d8bb8c381061ecba319
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7badcea95d972dfbc0171ed400722626d7d6565ff1b9328b1183dcedebe591c2c4fb2510fa67f8851614c4cd866c5b4db021351fdeea1bd016ce639ea1cb185f
|
|
7
|
+
data.tar.gz: b22a17a9907c81b37f1c22cb2d40e63c77eb62802f9db33ae749a747aa3d5eac5427523b2d5cec0a7ebda2b5d3c62ba88219fd906f0fab62da748a076f4b01e2
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,94 @@ number means the same thing everywhere.
|
|
|
6
6
|
**The authoritative release notes for every shipped version live in the documentation:**
|
|
7
7
|
https://tina4.com/ruby/36-releases
|
|
8
8
|
|
|
9
|
+
## 3.13.107
|
|
10
|
+
|
|
11
|
+
Feature: RBAC role and permission guards (parity across all four frameworks).
|
|
12
|
+
`role(...)` / `can(...)` route guards read the cryptographically-verified JWT
|
|
13
|
+
`roles` / `permissions` claims — OR within a guard, AND by stacking guards, with
|
|
14
|
+
granted-side wildcards (`posts.*`, `*`) and a legacy singular `role` claim coerced
|
|
15
|
+
to a list. A guarded route implies auth: no or invalid token → 401, authenticated
|
|
16
|
+
but unauthorised → 403; the handler never runs on a miss. Feature 138 / ADR-0058.
|
|
17
|
+
|
|
18
|
+
## 3.13.105
|
|
19
|
+
|
|
20
|
+
Bug release. Route inspection stops touching the app; Firebird's migration
|
|
21
|
+
ledger tolerates whatever case the driver hands back; PHP loses a colon-in-
|
|
22
|
+
filename that broke Windows checkouts.
|
|
23
|
+
|
|
24
|
+
### Route inspection scans, never boots
|
|
25
|
+
|
|
26
|
+
- `tina4 routes` now walks canonical route files and never executes the
|
|
27
|
+
application entrypoint or starts the server. Feature 115 / ADR-0058.
|
|
28
|
+
- Fixes the case where `tina4 routes --override` would boot the app on the
|
|
29
|
+
same port and kill whatever process was already holding it (tina4-python
|
|
30
|
+
issue #104).
|
|
31
|
+
|
|
32
|
+
### Firebird migration ledger is case-agnostic
|
|
33
|
+
|
|
34
|
+
- `tina4_migration` reads and writes work regardless of the case the
|
|
35
|
+
Firebird driver returns for the `migration_name` column.
|
|
36
|
+
- Uses the atomic sequence table pattern already in place for other engines.
|
|
37
|
+
|
|
38
|
+
### Queue and ORM audit bugs closed
|
|
39
|
+
|
|
40
|
+
- **`Model.clear_cache()` cascades to `db.cache_clear()`.** Under both
|
|
41
|
+
`TINA4_AUTO_CACHING=true` and `TINA4_DB_CACHE=true`, a manual `clear_cache()`
|
|
42
|
+
used to leave the DB-layer cache holding stale rows (PY-06-22).
|
|
43
|
+
- **`Queue.retry()` revives every dead letter.** The no-arg form used a
|
|
44
|
+
generator inside `any(...)` and short-circuited after the first success.
|
|
45
|
+
Now materialised so all N dead letters revive (PY-12-04).
|
|
46
|
+
- **`Job.retry()` unlinks the dead-letter file.** Iterating `dead_letters()`
|
|
47
|
+
and calling `.retry()` on each used to leave the failed directory carrying
|
|
48
|
+
every revived job, so the next `dead_letters()` call re-reported them
|
|
49
|
+
(PY-12-05).
|
|
50
|
+
- **MongoDB `retry_job(id)` searches the dead-letter namespace by data.id,
|
|
51
|
+
deletes the DL doc, and upserts the original back to pending.** The old
|
|
52
|
+
filter `{_id, self._topic, "failed"}` could never match a dead letter.
|
|
53
|
+
- **MongoDB `purge(status)` returns `deleted_count`, honours every status, and
|
|
54
|
+
routes dead/failed/dead_letter aliases to the `.dead_letter` namespace.**
|
|
55
|
+
Previously returned None and only handled `pending` — via `clear()`, which
|
|
56
|
+
nuked every doc under the topic regardless of status.
|
|
57
|
+
|
|
58
|
+
### Test-harness fixes
|
|
59
|
+
|
|
60
|
+
- **SIGTERM port probe checks both interfaces.** The graceful-shutdown test
|
|
61
|
+
probed only `0.0.0.0`; on macOS with the framework's default `127.0.0.1`
|
|
62
|
+
listener holding the port, that bind succeeded and the pre-assertion
|
|
63
|
+
concluded the port was free. Now probes both interfaces.
|
|
64
|
+
- **MySQL connect-timeout assertion parses the reported elapsed via regex**
|
|
65
|
+
and checks it against the configured bound instead of the outer clock;
|
|
66
|
+
mysql-connector's post-abort cleanup adds ~0.7s that the two stopwatches
|
|
67
|
+
legitimately disagreed on.
|
|
68
|
+
- **AI installer tests sandbox `HOME` per-test.** `install_context()` writes
|
|
69
|
+
the global skills bundle to `Path.home()/.claude/skills` by design; in the
|
|
70
|
+
test suite that raced other tests and hit stale root-owned files from prior
|
|
71
|
+
sudo runs. `monkeypatch.setenv("HOME", ...)` redirects the global install
|
|
72
|
+
to a throwaway dir.
|
|
73
|
+
|
|
74
|
+
### Developer skills — announce and 💩
|
|
75
|
+
|
|
76
|
+
- **Announce before you act.** All four framework developer skills now
|
|
77
|
+
instruct: say what you are about to do in one line before doing it —
|
|
78
|
+
Plan / Next / Done. Never write more than two files between announcements.
|
|
79
|
+
Never run a schema migration, install a dependency, or edit the boot file
|
|
80
|
+
without a preceding "About to:" line.
|
|
81
|
+
- **💩 stale-skill detection.** All four framework developer skills gained
|
|
82
|
+
`updated_for_version: 3.13.105` in the frontmatter and a self-check that
|
|
83
|
+
compares this to the latest published skill version at
|
|
84
|
+
`https://tina4.com/skills/<name>/version` (never against the project's
|
|
85
|
+
framework version — the framework version is the developer's call). If a
|
|
86
|
+
newer skill is available, 💩 rides beside 🤖 on every reply plus a one-time
|
|
87
|
+
update instruction.
|
|
88
|
+
|
|
89
|
+
### Doc parity
|
|
90
|
+
|
|
91
|
+
- `Queue.size()`, `Queue.failed()` and `Queue.dead_letters()` gained matching
|
|
92
|
+
in-source docstrings across Python, PHP, Ruby and Node: `size("failed")` /
|
|
93
|
+
`size("dead")` / `size("dead_letter")` are aliases for the dead-letter
|
|
94
|
+
count (== length of `dead_letters()`); `failed()` lists retryable-but-
|
|
95
|
+
attempted jobs, counted under `size("pending")`, NOT `size("failed")`.
|
|
96
|
+
|
|
9
97
|
## 3.13.103
|
|
10
98
|
|
|
11
99
|
### Metrics reports what it can prove
|
data/lib/tina4/cli.rb
CHANGED
|
@@ -1013,8 +1013,8 @@ module Tina4
|
|
|
1013
1013
|
|
|
1014
1014
|
def cmd_routes(_argv = nil)
|
|
1015
1015
|
require_relative "../tina4"
|
|
1016
|
-
Tina4.
|
|
1017
|
-
load_routes(Dir.pwd)
|
|
1016
|
+
Tina4::Env.load_env(Dir.pwd)
|
|
1017
|
+
load_routes(Dir.pwd, entrypoints: false)
|
|
1018
1018
|
|
|
1019
1019
|
puts "\nRegistered Routes:"
|
|
1020
1020
|
puts "-" * 60
|
|
@@ -2959,7 +2959,7 @@ module Tina4
|
|
|
2959
2959
|
|
|
2960
2960
|
# ── shared helpers ────────────────────────────────────────────────────
|
|
2961
2961
|
|
|
2962
|
-
def load_routes(root_dir)
|
|
2962
|
+
def load_routes(root_dir, entrypoints: true)
|
|
2963
2963
|
route_dirs = %w[src/routes routes src/api api src/orm orm]
|
|
2964
2964
|
route_dirs.each do |dir|
|
|
2965
2965
|
route_dir = File.join(root_dir, dir)
|
|
@@ -2967,7 +2967,10 @@ module Tina4
|
|
|
2967
2967
|
Dir.glob(File.join(route_dir, "**/*.rb")).sort.each { |f| load f }
|
|
2968
2968
|
end
|
|
2969
2969
|
|
|
2970
|
-
|
|
2970
|
+
return unless entrypoints
|
|
2971
|
+
|
|
2972
|
+
# Runtime and console boot load the application entrypoint. Inspection
|
|
2973
|
+
# commands opt out so they can never start a server or take over a port.
|
|
2971
2974
|
app_file = File.join(root_dir, "app.rb")
|
|
2972
2975
|
load app_file if File.exist?(app_file)
|
|
2973
2976
|
|
data/lib/tina4/orm.rb
CHANGED
|
@@ -480,13 +480,25 @@ module Tina4
|
|
|
480
480
|
|
|
481
481
|
# Invalidate every cached query that touches this model's table.
|
|
482
482
|
#
|
|
483
|
-
# Tag-scoped
|
|
484
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
#
|
|
483
|
+
# Tag-scoped in the ORM layer (a cached JOIN on another model that reads
|
|
484
|
+
# this table is busted too because it carries this table's tag; a query
|
|
485
|
+
# that never touches this table is left intact), then cascaded to the
|
|
486
|
+
# DB layer on this model's bound connection so an out-of-band write /
|
|
487
|
+
# deliberate refresh / race-with-another-process cannot leave stale rows
|
|
488
|
+
# in db.fetch's persistent cache. Called after every ORM write
|
|
489
|
+
# (save/delete/force_delete/restore) so a read-after-write never serves
|
|
490
|
+
# a stale/deleted row (CACHE-DEC-01). 3.13.105 (parity port of PY-06-22)
|
|
491
|
+
# added the DB-layer cascade — previously the two cache layers disagreed
|
|
492
|
+
# under TINA4_AUTO_CACHING=true + TINA4_DB_CACHE=true.
|
|
488
493
|
def clear_cache
|
|
489
494
|
query_cache.clear_tag(table_name.to_s.downcase)
|
|
495
|
+
begin
|
|
496
|
+
get_db.cache_clear
|
|
497
|
+
rescue StandardError
|
|
498
|
+
# A resolvable DB is not guaranteed at every clear_cache call site
|
|
499
|
+
# (module-import time in odd bootstraps, tests that mutate bindings);
|
|
500
|
+
# never let a cache-clear crash a save/delete.
|
|
501
|
+
end
|
|
490
502
|
nil
|
|
491
503
|
end
|
|
492
504
|
|
data/lib/tina4/queue.rb
CHANGED
|
@@ -171,7 +171,14 @@ module Tina4
|
|
|
171
171
|
@backend.close
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
# Get jobs that failed but are still
|
|
174
|
+
# Get jobs that failed at least once but are still being retried
|
|
175
|
+
# (0 < attempts < max_retries). These live in the pending queue under
|
|
176
|
+
# the auto-retry lifecycle (fail() re-queues them with an incremented
|
|
177
|
+
# attempts count and a retry_backoff delay) so pop() picks them up
|
|
178
|
+
# again. They are NOT counted by size("failed") — that alias counts
|
|
179
|
+
# the dead-letter store, matching dead_letters(). To include retryable-
|
|
180
|
+
# failed jobs in a total, use size("pending"). Terminal failures are
|
|
181
|
+
# returned by dead_letters().
|
|
175
182
|
def failed # -> list[dict]
|
|
176
183
|
refuse_operation!("failed()") unless @backend.respond_to?(:failed)
|
|
177
184
|
@backend.failed(@topic, max_retries: @max_retries)
|
|
@@ -179,13 +186,24 @@ module Tina4
|
|
|
179
186
|
|
|
180
187
|
# Retry a specific failed job by ID, or all dead-letter jobs if no id given.
|
|
181
188
|
# Returns true if re-queued.
|
|
189
|
+
#
|
|
190
|
+
# The no-arg branch is materialised by the backend (LiteBackend#retry_job
|
|
191
|
+
# iterates every dead-letter file with .each, MongoBackend materialises
|
|
192
|
+
# find(...).to_a before iterating) — no generator inside any() /
|
|
193
|
+
# short-circuit reduce can silently leave dead letters behind (parity port
|
|
194
|
+
# of PY-12-04, 3.13.105).
|
|
182
195
|
def retry(job_id = nil, delay_seconds: 0) # -> bool
|
|
183
196
|
refuse_operation!("retry()") unless @backend.respond_to?(:retry_job)
|
|
184
197
|
@backend.retry_job(@topic, job_id: job_id, delay_seconds: delay_seconds)
|
|
185
198
|
end
|
|
186
199
|
|
|
187
|
-
# Get
|
|
188
|
-
#
|
|
200
|
+
# Get jobs that exceeded max_retries — terminal failures.
|
|
201
|
+
#
|
|
202
|
+
# Same set counted by size("failed") / size("dead") / size("dead_letter")
|
|
203
|
+
# (three aliases for the dead-letter store). To LIST retryable-but-
|
|
204
|
+
# attempted jobs (attempts > 0 AND attempts < max_retries) that are still
|
|
205
|
+
# being auto-retried, use failed() — those live in the pending queue and
|
|
206
|
+
# are NOT dead letters. Pass max_retries to override the queue's default.
|
|
189
207
|
def dead_letters(max_retries: nil) # -> list[dict]
|
|
190
208
|
refuse_operation!("dead_letters()") unless @backend.respond_to?(:dead_letters)
|
|
191
209
|
@backend.dead_letters(@topic, max_retries: max_retries || @max_retries)
|
|
@@ -328,9 +346,21 @@ module Tina4
|
|
|
328
346
|
attach(@backend.find_by_id(topic || @topic, id))
|
|
329
347
|
end
|
|
330
348
|
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
#
|
|
349
|
+
# Count jobs by status.
|
|
350
|
+
#
|
|
351
|
+
# "pending" counts jobs waiting to be popped — INCLUDES retryable-but-
|
|
352
|
+
# attempted ones, because they live in the pending queue under the
|
|
353
|
+
# auto-retry lifecycle (see failed()).
|
|
354
|
+
# "reserved" counts jobs a consumer has popped but not yet
|
|
355
|
+
# completed/failed (in-flight against the visibility timeout).
|
|
356
|
+
# "completed" counts jobs the consumer has finished successfully
|
|
357
|
+
# (0 on the lite/file backend which deletes on complete; backends that
|
|
358
|
+
# track completion expose completed_count).
|
|
359
|
+
# "failed", "dead", "dead_letter" are ALIASES that all count the
|
|
360
|
+
# dead-letter store — jobs whose attempts >= max_retries and that have
|
|
361
|
+
# given up. Use dead_letters() to list them. Retryable-but-attempted
|
|
362
|
+
# jobs are NOT counted by size("failed"); use failed() to list them or
|
|
363
|
+
# size("pending") to include them in a total.
|
|
334
364
|
def size(status: "pending")
|
|
335
365
|
case status.to_s
|
|
336
366
|
when "pending"
|
|
@@ -164,9 +164,26 @@ module Tina4
|
|
|
164
164
|
|
|
165
165
|
# Explicit re-queue requested by the caller (job.retry()). Always
|
|
166
166
|
# re-enqueues regardless of the retry limit — a manual override, distinct
|
|
167
|
-
# from the automatic fail() path.
|
|
167
|
+
# from the automatic fail() path. Cleans up BOTH the reservation record
|
|
168
|
+
# and any dead-letter file for this id, so a caller who iterates
|
|
169
|
+
# dead_letters and calls job.retry on each doesn't leave the dead-letter
|
|
170
|
+
# directory carrying duplicates (3.13.105, parity port of PY-12-05).
|
|
171
|
+
# Aligns with retry_job(id) which had always unlinked the dead-letter
|
|
172
|
+
# file — two spellings of the same intent that previously diverged.
|
|
173
|
+
# Increments attempts, clears the error.
|
|
168
174
|
def retry(job, delay_seconds: 0)
|
|
169
175
|
clear_reservation(job.topic, job.id)
|
|
176
|
+
# Drop any dead-letter file for this id BEFORE the re-queue: if this
|
|
177
|
+
# job came from dead_letters it lives in @dead_letter_dir and would
|
|
178
|
+
# otherwise stay on disk while a fresh pending file appears in
|
|
179
|
+
# topic_path, so the next dead_letters call reports the job again
|
|
180
|
+
# and a consumer processes it twice.
|
|
181
|
+
begin
|
|
182
|
+
File.unlink(job_file(@dead_letter_dir, job.id))
|
|
183
|
+
rescue Errno::ENOENT
|
|
184
|
+
# No dead-letter file for this id (manual .retry on a live job) —
|
|
185
|
+
# nothing to clean up.
|
|
186
|
+
end
|
|
170
187
|
job.attempts += 1
|
|
171
188
|
requeue_job(job, delay_seconds: delay_seconds, error: nil)
|
|
172
189
|
end
|
|
@@ -291,8 +291,19 @@ module Tina4
|
|
|
291
291
|
.map { |doc| job_from_doc(doc) }
|
|
292
292
|
end
|
|
293
293
|
|
|
294
|
+
# Delete every doc in this queue whose status matches.
|
|
295
|
+
#
|
|
296
|
+
# 3.13.105 (parity port): route the dead-letter statuses ("dead",
|
|
297
|
+
# "failed", "dead_letter") to the ".dead_letter" topic namespace where
|
|
298
|
+
# they actually live — pre-fix, purge("dead") delete_many'd on
|
|
299
|
+
# {topic: topic, status: "dead"}, matching zero docs and silently
|
|
300
|
+
# returning 0 while the dead letters sat untouched in the sibling
|
|
301
|
+
# namespace. Every path scopes by status so a purge never nukes docs it
|
|
302
|
+
# was not asked to remove, and every path returns deleted_count so a
|
|
303
|
+
# caller can log/assert what was purged.
|
|
294
304
|
def purge(topic, status)
|
|
295
|
-
|
|
305
|
+
namespace = dead_status?(status) ? "#{topic}.dead_letter" : topic
|
|
306
|
+
result = collection.delete_many(topic: namespace, status: status.to_s)
|
|
296
307
|
result.deleted_count
|
|
297
308
|
end
|
|
298
309
|
|
|
@@ -326,20 +337,31 @@ module Tina4
|
|
|
326
337
|
result.modified_count
|
|
327
338
|
end
|
|
328
339
|
|
|
329
|
-
# Move
|
|
330
|
-
#
|
|
340
|
+
# Move dead-lettered jobs back to their main topic as pending.
|
|
341
|
+
#
|
|
342
|
+
# With +job_id+, revives that ONE dead letter and returns true when
|
|
343
|
+
# found (false otherwise). With no id, iterates every dead letter for
|
|
344
|
+
# the topic and revives ALL — materialised into an array before
|
|
345
|
+
# reducing, so a truthy first result never short-circuits away the
|
|
346
|
+
# rest (the class of bug the parity port of PY-12-04 pins). Ruby's
|
|
347
|
+
# dead-letter model stores the SAME document with topic flipped to
|
|
348
|
+
# "<topic>.dead_letter", so reviving is the reverse in-place update:
|
|
349
|
+
# flip the topic back, reset status/available_at/error. Mirrors
|
|
350
|
+
# LiteBackend#retry_job.
|
|
331
351
|
def retry_job(topic, job_id: nil, delay_seconds: 0)
|
|
332
352
|
filter = { topic: "#{topic}.dead_letter", status: "dead" }
|
|
333
353
|
filter[:_id] = job_id if job_id
|
|
334
|
-
|
|
335
|
-
return false
|
|
354
|
+
docs = collection.find(filter).to_a
|
|
355
|
+
return false if docs.empty?
|
|
336
356
|
|
|
337
357
|
available = delay_seconds.to_f > 0 ? (Time.now.utc + delay_seconds.to_f) : Time.now.utc
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
358
|
+
docs.each do |doc|
|
|
359
|
+
collection.update_one(
|
|
360
|
+
{ _id: doc["_id"] },
|
|
361
|
+
{ "$set" => { topic: topic, status: "pending", error: nil,
|
|
362
|
+
reserved_at: nil, available_at: available } }
|
|
363
|
+
)
|
|
364
|
+
end
|
|
343
365
|
true
|
|
344
366
|
end
|
|
345
367
|
|
|
@@ -358,6 +380,12 @@ module Tina4
|
|
|
358
380
|
|
|
359
381
|
private
|
|
360
382
|
|
|
383
|
+
DEAD_STATES = %w[failed dead dead_letter].freeze
|
|
384
|
+
|
|
385
|
+
def dead_status?(status)
|
|
386
|
+
DEAD_STATES.include?(status.to_s)
|
|
387
|
+
end
|
|
388
|
+
|
|
361
389
|
# A stored document as a plain Hash with string keys, CARRYING attempts
|
|
362
390
|
# and error.
|
|
363
391
|
#
|
data/lib/tina4/rack_app.rb
CHANGED
|
@@ -1178,6 +1178,9 @@ module Tina4
|
|
|
1178
1178
|
identity = sso.is_a?(Hash) ? sso["identity"] : nil
|
|
1179
1179
|
if identity.is_a?(Hash) && identity["issuer"] && identity["subject"]
|
|
1180
1180
|
env["tina4.auth_payload"] = identity
|
|
1181
|
+
deny = rbac_forbidden(route, identity)
|
|
1182
|
+
return deny if deny
|
|
1183
|
+
|
|
1181
1184
|
return nil
|
|
1182
1185
|
end
|
|
1183
1186
|
session_token = session.get("token")
|
|
@@ -1207,9 +1210,63 @@ module Tina4
|
|
|
1207
1210
|
return [401, { "content-type" => "application/json" }, [JSON.generate({ error: "Unauthorized" })]]
|
|
1208
1211
|
end
|
|
1209
1212
|
|
|
1213
|
+
# ── RBAC guards (Feature 138): authorization AFTER authentication ──
|
|
1214
|
+
# Auth has passed (401 ruled out above). If the route carries role/
|
|
1215
|
+
# permission guards, the verified payload must satisfy them, else 403.
|
|
1216
|
+
deny = rbac_forbidden(route, env["tina4.auth_payload"])
|
|
1217
|
+
return deny if deny
|
|
1218
|
+
|
|
1210
1219
|
nil
|
|
1211
1220
|
end
|
|
1212
1221
|
|
|
1222
|
+
# Build a 403 Rack tuple when a route's RBAC guards are not satisfied by the
|
|
1223
|
+
# verified payload, else nil. AND across guard groups, OR within a group.
|
|
1224
|
+
# Roles read the `roles` claim (legacy singular `role` coerced); permissions
|
|
1225
|
+
# read `permissions` with granted-side wildcards. Feature 138 / ADR-0058.
|
|
1226
|
+
def self.rbac_forbidden(route, payload)
|
|
1227
|
+
required_roles = route.respond_to?(:roles) ? Array(route.roles) : []
|
|
1228
|
+
required_perms = route.respond_to?(:perms) ? Array(route.perms) : []
|
|
1229
|
+
return nil if required_roles.empty? && required_perms.empty?
|
|
1230
|
+
|
|
1231
|
+
subject = payload.is_a?(Hash) ? payload : {}
|
|
1232
|
+
roles = _rbac_claim_list(subject, "roles", "role")
|
|
1233
|
+
required_roles.each do |group|
|
|
1234
|
+
return _rbac_forbidden_tuple unless group.any? { |r| roles.include?(r) }
|
|
1235
|
+
end
|
|
1236
|
+
perms = _rbac_claim_list(subject, "permissions", nil)
|
|
1237
|
+
required_perms.each do |group|
|
|
1238
|
+
return _rbac_forbidden_tuple unless group.any? { |req| _rbac_perm_granted(perms, req) }
|
|
1239
|
+
end
|
|
1240
|
+
nil
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
def self._rbac_forbidden_tuple
|
|
1244
|
+
[403, { "content-type" => "application/json" }, [JSON.generate({ error: "Forbidden" })]]
|
|
1245
|
+
end
|
|
1246
|
+
|
|
1247
|
+
# Read a claim as a list of strings; coerce a legacy singular string.
|
|
1248
|
+
def self._rbac_claim_list(subject, key, legacy)
|
|
1249
|
+
out = _rbac_coerce_list(subject[key])
|
|
1250
|
+
out = _rbac_coerce_list(subject[legacy]) if out.empty? && legacy
|
|
1251
|
+
out
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1254
|
+
def self._rbac_coerce_list(val)
|
|
1255
|
+
case val
|
|
1256
|
+
when String then val.empty? ? [] : [val]
|
|
1257
|
+
when Array then val.map(&:to_s).reject(&:empty?)
|
|
1258
|
+
else []
|
|
1259
|
+
end
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
# True if any GRANTED permission satisfies the concrete REQUIRED one.
|
|
1263
|
+
# `*` grants everything; `posts.*` grants `posts.<...>` on the dot boundary.
|
|
1264
|
+
def self._rbac_perm_granted(granted, required)
|
|
1265
|
+
granted.any? do |g|
|
|
1266
|
+
g == "*" || g == required || (g.end_with?(".*") && required.start_with?(g[0..-2]))
|
|
1267
|
+
end
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1213
1270
|
def self._read_rack_body(env)
|
|
1214
1271
|
# Route through the capped reader so the running per-chunk upload cap is
|
|
1215
1272
|
# enforced on the form-token / body path too: an over-limit body raises
|
data/lib/tina4/router.rb
CHANGED
|
@@ -8,6 +8,9 @@ module Tina4
|
|
|
8
8
|
attr_reader :method, :path, :handler, :auth_handler, :swagger_meta,
|
|
9
9
|
:path_regex, :param_names, :template
|
|
10
10
|
attr_accessor :auth_required, :cached
|
|
11
|
+
# RBAC guard groups (Feature 138 / ADR-0058). Each is an array of arrays:
|
|
12
|
+
# OR within a group, AND across groups (stacking).
|
|
13
|
+
attr_reader :roles, :perms
|
|
11
14
|
|
|
12
15
|
def initialize(method, path, handler, auth_handler: nil, swagger_meta: {}, middleware: [], template: nil)
|
|
13
16
|
@method = method.to_s.upcase.freeze
|
|
@@ -23,6 +26,8 @@ module Tina4
|
|
|
23
26
|
# PY-10-02. Call .no_auth on the route to opt out explicitly.
|
|
24
27
|
@auth_required = %w[POST PUT PATCH DELETE].include?(@method)
|
|
25
28
|
@cached = false
|
|
29
|
+
@roles = []
|
|
30
|
+
@perms = []
|
|
26
31
|
@param_names = []
|
|
27
32
|
@path_regex = compile_pattern(@path)
|
|
28
33
|
@param_names.freeze
|
|
@@ -42,6 +47,31 @@ module Tina4
|
|
|
42
47
|
self
|
|
43
48
|
end
|
|
44
49
|
|
|
50
|
+
# RBAC: require ONE of the named roles (OR). Reads the verified JWT `roles`
|
|
51
|
+
# claim. Stack .role/.can for AND. Implies auth. Feature 138 / ADR-0058.
|
|
52
|
+
# Returns self for chaining: Router.get("/admin") { ... }.role("admin")
|
|
53
|
+
def role(*names)
|
|
54
|
+
names = names.flatten.map(&:to_s).reject(&:empty?)
|
|
55
|
+
unless names.empty?
|
|
56
|
+
@roles << names
|
|
57
|
+
@auth_required = true
|
|
58
|
+
end
|
|
59
|
+
self
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# RBAC: require ONE of the named permissions (OR). Reads the verified JWT
|
|
63
|
+
# `permissions` claim; granted-side wildcards (`posts.*`, `*`) satisfy a
|
|
64
|
+
# concrete requirement. Stack for AND. Implies auth. Feature 138.
|
|
65
|
+
# Returns self for chaining: Router.delete("/p") { ... }.can("posts.delete")
|
|
66
|
+
def can(*permissions)
|
|
67
|
+
permissions = permissions.flatten.map(&:to_s).reject(&:empty?)
|
|
68
|
+
unless permissions.empty?
|
|
69
|
+
@perms << permissions
|
|
70
|
+
@auth_required = true
|
|
71
|
+
end
|
|
72
|
+
self
|
|
73
|
+
end
|
|
74
|
+
|
|
45
75
|
# Mark this route as cacheable.
|
|
46
76
|
# Returns self for chaining: Router.get("/path") { ... }.cache
|
|
47
77
|
def cache
|
data/lib/tina4/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tina4ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.13.
|
|
4
|
+
version: 3.13.107
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Tina4 Team
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rack
|