tina4ruby 3.13.105 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ab6d7ce15da93e814204665a8690be0cc2ac58dc1152fa75f050b91fcb0c4f0
4
- data.tar.gz: 35fc27eb9d71c17a0049d983d34ed6c1584e014338192e5ace087e5e2bec0746
3
+ metadata.gz: ab8e75446e8ed41f28be0078b1f16de1e1c0be39ff83cefea98f2a5cc9a78cc2
4
+ data.tar.gz: b8d786867f6a37c87bbc0e242a615c7df8962ae8fd188d8bb8c381061ecba319
5
5
  SHA512:
6
- metadata.gz: f2e66595ddcf7eca7d498428149ca38a83caf4871d8d5e29e31ef9d72f7f38f2ed9c3afe638ba2891bf9ffda5b00dbbf54883ddc704bf06787f9f06060b0603b
7
- data.tar.gz: c45ab7892609d2be7e0b3dc432a8d54bef9dd8281e3594198b9fa93e9170ceeb60372624c69d49978884ef596cb7ff35eeb720216c4d240a176e372ffa33d803
6
+ metadata.gz: 7badcea95d972dfbc0171ed400722626d7d6565ff1b9328b1183dcedebe591c2c4fb2510fa67f8851614c4cd866c5b4db021351fdeea1bd016ce639ea1cb185f
7
+ data.tar.gz: b22a17a9907c81b37f1c22cb2d40e63c77eb62802f9db33ae749a747aa3d5eac5427523b2d5cec0a7ebda2b5d3c62ba88219fd906f0fab62da748a076f4b01e2
data/CHANGELOG.md CHANGED
@@ -6,6 +6,15 @@ 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
+
9
18
  ## 3.13.105
10
19
 
11
20
  Bug release. Route inspection stops touching the app; Firebird's migration
@@ -26,6 +35,65 @@ filename that broke Windows checkouts.
26
35
  Firebird driver returns for the `migration_name` column.
27
36
  - Uses the atomic sequence table pattern already in place for other engines.
28
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
+
29
97
  ## 3.13.103
30
98
 
31
99
  ### Metrics reports what it can prove
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tina4
4
- VERSION = "3.13.105"
4
+ VERSION = "3.13.107"
5
5
  end
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.105
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-19 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rack