atlas_rb 1.2.0 → 1.2.2
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/.version +1 -1
- data/CHANGELOG.md +69 -0
- data/Gemfile.lock +3 -3
- data/README.md +66 -0
- data/lib/atlas_rb/audit_event.rb +109 -0
- data/lib/atlas_rb/collection.rb +6 -0
- data/lib/atlas_rb/community.rb +5 -0
- data/lib/atlas_rb/errors.rb +94 -0
- data/lib/atlas_rb/faraday_helper.rb +1 -0
- data/lib/atlas_rb/middleware/raise_on_resource_error.rb +79 -0
- data/lib/atlas_rb/work.rb +16 -0
- data/lib/atlas_rb.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29da2e41c70d17572c6c24002ba0ff25beb2ad6cdd940dee157a83133c86efaf
|
|
4
|
+
data.tar.gz: 9e16fed8e22867c37647a12de104135d78f57995ba5e6decd4929ac6a402bae7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7ba5a0216a5cc5dacce4f6822078eb07d71aa4e14b31142c83dd823c0ec177efa92e1712eac0b235c125870611dcd4633817066c5ee70a98d6f85adbec614a7f
|
|
7
|
+
data.tar.gz: b176fc377d6d802e356c5f0a0fd487c6c899d4b5e323e3017eb773fd11e06dd8ebb415329a4c4e81aef5f55f0086602fc33a6cb82c4699709e0a3209c542a48d
|
data/.version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.2.
|
|
1
|
+
1.2.2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,74 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
### Added — `AtlasRb::AuditEvent.emit` (session-scoped audit events)
|
|
6
|
+
|
|
7
|
+
A new binding for Atlas's `POST /audit_events` endpoint, which records an
|
|
8
|
+
AuditEvent with a **null `resource_id`** — an event not tied to any
|
|
9
|
+
resource write. This is the gem's half of the impersonation audit trail
|
|
10
|
+
(acting-as / view-as): the session lifecycle lives entirely in the calling
|
|
11
|
+
application, and a view-as session performs no writes, so neither leaves a
|
|
12
|
+
per-resource event for `Resource.history` to surface.
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
AtlasRb::AuditEvent.emit(
|
|
16
|
+
action: "impersonation_started", # or "impersonation_ended"
|
|
17
|
+
actor_nuid: admin_nuid,
|
|
18
|
+
on_behalf_of_nuid: target_nuid,
|
|
19
|
+
mode: "acting_as" # or "view_as"
|
|
20
|
+
)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
- The recorded principals (`actor_nuid`, `on_behalf_of_nuid`), `mode`, and
|
|
24
|
+
an optional free-form `payload:` travel in the request **body**, not in
|
|
25
|
+
ambient headers — so the call is self-describing even when fired as a
|
|
26
|
+
session is being torn down (e.g. `impersonation_ended`).
|
|
27
|
+
- The request authenticates via the standard `connection` (system token),
|
|
28
|
+
with the `User: NUID` header pinned to `actor_nuid` so the server-side
|
|
29
|
+
admin gate holds regardless of ambient `Current` state.
|
|
30
|
+
- `on_behalf_of_nuid`, `mode`, and `payload` are omitted from the body when
|
|
31
|
+
blank, leaving room for future, mode-less session events. Atlas stamps
|
|
32
|
+
`occurred_at` server-side.
|
|
33
|
+
- Authorization errors (`401` / `403`) surface as raw Faraday responses,
|
|
34
|
+
matching `Resource.history`.
|
|
35
|
+
|
|
36
|
+
Depends on the matching Atlas-side `POST /audit_events` emit endpoint
|
|
37
|
+
(nullable resource scope, admin-gated); see the impersonation gap report.
|
|
38
|
+
|
|
39
|
+
## 1.2.1
|
|
40
|
+
|
|
41
|
+
### Added — typed errors for re-parent / linked-member rejections
|
|
42
|
+
|
|
43
|
+
The re-parent and linked-member bindings no longer swallow Atlas's `4xx`
|
|
44
|
+
error envelope. Previously a `422`/`403` parsed fine but lacked the
|
|
45
|
+
success key (`"collection"` / `"work"` / `"community"`), so the binding
|
|
46
|
+
returned `nil` and discarded Atlas's machine-readable `error` / `message`
|
|
47
|
+
— callers could not tell an invalid move from a not-found from a
|
|
48
|
+
forbidden one.
|
|
49
|
+
|
|
50
|
+
- **`AtlasRb::ReparentError`** — raised on a `422` to a `.../parent` path
|
|
51
|
+
(`Collection`/`Community`/`Work.reparent`). Carries the envelope's
|
|
52
|
+
`error` discriminator as `#code` (`cycle`, `invalid_parent_type`,
|
|
53
|
+
`tombstoned_node`, `tombstoned_parent`, `parent_required`,
|
|
54
|
+
`parent_not_found`) plus `#resource_id` and `#message`.
|
|
55
|
+
- **`AtlasRb::LinkedMemberError`** — raised on a `422` to a
|
|
56
|
+
`.../linked_members` path (`Work.add_linked_member` /
|
|
57
|
+
`remove_linked_member`). Same shape (`#code`, `#resource_id`, `#message`).
|
|
58
|
+
- **`AtlasRb::ForbiddenError`** — raised on a `403` to either path.
|
|
59
|
+
Carries `#code`, `#action`, and `#subject` from the envelope.
|
|
60
|
+
|
|
61
|
+
All three subclass `AtlasRb::Error`. A new
|
|
62
|
+
`AtlasRb::Middleware::RaiseOnResourceError` (registered alongside
|
|
63
|
+
`RaiseOnStaleResource`) performs the translation, keyed on the request
|
|
64
|
+
**path + status** so it stays narrow: only the re-parent and linked-member
|
|
65
|
+
write paths are affected, and only `403`/`422` bodies carrying an `error`
|
|
66
|
+
discriminator. Other endpoints, other statuses, and the `tombstone`
|
|
67
|
+
endpoint's `code: "has_live_children"` body are untouched, and the `409`
|
|
68
|
+
optimistic-lock conflict still surfaces as `StaleResourceError`. Rescue is
|
|
69
|
+
opt-in — callers that don't discriminate see the success payload exactly as
|
|
70
|
+
before.
|
|
71
|
+
|
|
3
72
|
## 1.2.0
|
|
4
73
|
|
|
5
74
|
### Added — Tree/DAG foundation bindings
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
atlas_rb (1.2.
|
|
4
|
+
atlas_rb (1.2.2)
|
|
5
5
|
faraday (~> 2.7)
|
|
6
6
|
faraday-follow_redirects (~> 0.3.0)
|
|
7
7
|
faraday-multipart (~> 1)
|
|
@@ -19,11 +19,11 @@ GEM
|
|
|
19
19
|
faraday (>= 1, < 3)
|
|
20
20
|
faraday-multipart (1.2.0)
|
|
21
21
|
multipart-post (~> 2.0)
|
|
22
|
-
faraday-net_http (3.4.
|
|
22
|
+
faraday-net_http (3.4.4)
|
|
23
23
|
net-http (~> 0.5)
|
|
24
24
|
hashie (5.1.0)
|
|
25
25
|
logger
|
|
26
|
-
json (2.19.
|
|
26
|
+
json (2.19.8)
|
|
27
27
|
logger (1.7.0)
|
|
28
28
|
multipart-post (2.4.1)
|
|
29
29
|
net-http (0.9.1)
|
data/README.md
CHANGED
|
@@ -113,6 +113,7 @@ Community → Collection → Work
|
|
|
113
113
|
| `AtlasRb::Blob` | The binary bytes; supports streaming downloads. |
|
|
114
114
|
| `AtlasRb::Authentication` | NUID → user record / group lookup. |
|
|
115
115
|
| `AtlasRb::Resource` | Generic resolver, permissions lookup, and audit-event history. |
|
|
116
|
+
| `AtlasRb::AuditEvent` | Emit a session-scoped (resource-less) audit event, e.g. impersonation. |
|
|
116
117
|
| `AtlasRb::Reset` | Test-only — wipes Atlas state via `GET /reset`. |
|
|
117
118
|
|
|
118
119
|
## Namespace gradient: regular / Admin / System
|
|
@@ -205,6 +206,45 @@ result["events"].first["action"] # => "update"
|
|
|
205
206
|
Authorization errors (`401` / `403`) are not caught here — they surface as
|
|
206
207
|
raw Faraday responses for the calling application's rescue layer.
|
|
207
208
|
|
|
209
|
+
### Session-scoped audit events
|
|
210
|
+
|
|
211
|
+
`Resource.history` only reads events that hang off a resource write. Some
|
|
212
|
+
auditable events have no resource to attach to — most notably impersonation
|
|
213
|
+
sessions (acting-as / view-as): the session lifecycle lives in the calling
|
|
214
|
+
app, and a view-as session performs no writes at all, so it would otherwise
|
|
215
|
+
leave no audit trail. `AtlasRb::AuditEvent.emit` wraps Atlas's
|
|
216
|
+
`POST /audit_events` endpoint, which records an event with a **null
|
|
217
|
+
`resource_id`**.
|
|
218
|
+
|
|
219
|
+
The principals and metadata travel in the request body — not inferred from
|
|
220
|
+
ambient headers — so the call is self-describing even when fired as a
|
|
221
|
+
session is being torn down. The request is admin-gated server-side; the
|
|
222
|
+
`User: NUID` header is pinned to the `actor_nuid` you pass.
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
# An admin starts acting as another user:
|
|
226
|
+
AtlasRb::AuditEvent.emit(
|
|
227
|
+
action: "impersonation_started",
|
|
228
|
+
actor_nuid: Current.nuid, # the admin (also the User: header)
|
|
229
|
+
on_behalf_of_nuid: target_nuid, # the impersonated user
|
|
230
|
+
mode: "acting_as" # or "view_as"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# …and later ends the session:
|
|
234
|
+
AtlasRb::AuditEvent.emit(
|
|
235
|
+
action: "impersonation_ended",
|
|
236
|
+
actor_nuid: admin_nuid,
|
|
237
|
+
on_behalf_of_nuid: target_nuid,
|
|
238
|
+
mode: "acting_as"
|
|
239
|
+
)
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
`on_behalf_of_nuid`, `mode`, and `payload:` (free-form metadata) are
|
|
243
|
+
omitted from the body when blank, so `emit` can also carry future,
|
|
244
|
+
mode-less session events. Atlas stamps `occurred_at` server-side.
|
|
245
|
+
Authorization errors (`401` / `403`) surface as raw Faraday responses,
|
|
246
|
+
matching `Resource.history`.
|
|
247
|
+
|
|
208
248
|
### Re-parenting
|
|
209
249
|
|
|
210
250
|
`reparent` moves a resource to a new structural parent, binding Atlas's
|
|
@@ -227,6 +267,28 @@ the tree) — the same way `Community.create(nil)` makes a top-level
|
|
|
227
267
|
community. A `nil` destination for a Work or Collection is rejected by
|
|
228
268
|
Atlas.
|
|
229
269
|
|
|
270
|
+
When Atlas rejects a move, the binding raises a typed error carrying the
|
|
271
|
+
machine-readable `error` code from Atlas's envelope, rather than returning
|
|
272
|
+
`nil` — so callers can message the *specific* reason:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
begin
|
|
276
|
+
AtlasRb::Collection.reparent("col-456", "c-999")
|
|
277
|
+
rescue AtlasRb::ReparentError => e
|
|
278
|
+
e.code # => "cycle" / "invalid_parent_type" / "tombstoned_parent" / …
|
|
279
|
+
e.message # => human-readable description from Atlas
|
|
280
|
+
rescue AtlasRb::ForbiddenError => e
|
|
281
|
+
e.code # => the authz failure (HTTP 403)
|
|
282
|
+
end
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
`ReparentError` (HTTP 422, structural rules: `cycle`, `invalid_parent_type`,
|
|
286
|
+
`tombstoned_node`, `tombstoned_parent`, `parent_required`,
|
|
287
|
+
`parent_not_found`) and `ForbiddenError` (HTTP 403, authorization) both
|
|
288
|
+
subclass `AtlasRb::Error`. The `409` optimistic-lock conflict still surfaces
|
|
289
|
+
as `StaleResourceError`, unchanged. Rescue is opt-in — callers that don't
|
|
290
|
+
care keep the success payload as before.
|
|
291
|
+
|
|
230
292
|
### Linked members (the DAG overlay)
|
|
231
293
|
|
|
232
294
|
A Work has exactly one structural parent (`a_member_of`, set by `create` /
|
|
@@ -246,6 +308,10 @@ array (mirroring `Collection.children`); the two mutations return the list
|
|
|
246
308
|
Resolving those Collections' full contents is a Cerberus/Solr concern —
|
|
247
309
|
this gem never queries the index.
|
|
248
310
|
|
|
311
|
+
The two mutations raise the same way `reparent` does — `LinkedMemberError`
|
|
312
|
+
on a structural `422` (carrying the envelope's `error` code as `#code`) and
|
|
313
|
+
`ForbiddenError` on a `403` — instead of swallowing the envelope.
|
|
314
|
+
|
|
249
315
|
## End-to-end example
|
|
250
316
|
|
|
251
317
|
JSON responses come back as `AtlasRb::Mash` (a `Hashie::Mash` subclass), so
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtlasRb
|
|
4
|
+
# Session-scoped AuditEvent emit — record an audit event that is **not**
|
|
5
|
+
# tied to a resource write.
|
|
6
|
+
#
|
|
7
|
+
# ## Why this exists
|
|
8
|
+
#
|
|
9
|
+
# Every other AuditEvent in Atlas is a side effect of a resource mutation
|
|
10
|
+
# (`create`, `update`, `tombstone`, …) and is read back per-resource via
|
|
11
|
+
# {AtlasRb::Resource.history} (`GET /resources/<id>/history`). Some
|
|
12
|
+
# auditable events have **no resource to hang on**: impersonation sessions.
|
|
13
|
+
# An admin starting/ending an acting-as or view-as session is a security
|
|
14
|
+
# event worth recording, but view-as performs no writes at all, and the
|
|
15
|
+
# session lifecycle lives entirely in the calling application (a cookie),
|
|
16
|
+
# so there is no resource mutation to attach the event to.
|
|
17
|
+
#
|
|
18
|
+
# This binding wraps Atlas's `POST /audit_events` emit endpoint, which
|
|
19
|
+
# writes an AuditEvent with a **null `resource_id`** (a session-scoped
|
|
20
|
+
# event). The recorded principals are passed explicitly in the body rather
|
|
21
|
+
# than inferred from request headers, so the call is self-describing and
|
|
22
|
+
# does not depend on the ambient {AtlasRb.config} identity being intact at
|
|
23
|
+
# emit time (it may not be — e.g. an `impersonation_ended` emit fires as
|
|
24
|
+
# the session is being torn down).
|
|
25
|
+
#
|
|
26
|
+
# ## Authorization
|
|
27
|
+
#
|
|
28
|
+
# The endpoint is system-token + admin-gated. The request authenticates via
|
|
29
|
+
# the standard {FaradayHelper#connection} — the `Authorization: Bearer`
|
|
30
|
+
# token plus a `User: NUID <admin>` header pinned to `actor_nuid` — and
|
|
31
|
+
# Atlas gates the emit to admin operators. `actor_nuid` is passed
|
|
32
|
+
# explicitly (rather than left to {AtlasRb.config}.default_nuid) so the
|
|
33
|
+
# admin gate holds even when the ambient session identity is mid-teardown,
|
|
34
|
+
# as it is for an `impersonation_ended` emit. The same `actor_nuid` is the
|
|
35
|
+
# principal recorded on the event.
|
|
36
|
+
#
|
|
37
|
+
# @example Recording the start of an acting-as session (from Cerberus)
|
|
38
|
+
# AtlasRb::AuditEvent.emit(
|
|
39
|
+
# action: "impersonation_started",
|
|
40
|
+
# actor_nuid: Current.nuid, # the admin
|
|
41
|
+
# on_behalf_of_nuid: target_nuid, # the impersonated user
|
|
42
|
+
# mode: "acting_as"
|
|
43
|
+
# )
|
|
44
|
+
#
|
|
45
|
+
# @example Recording the end of a view-as session
|
|
46
|
+
# AtlasRb::AuditEvent.emit(
|
|
47
|
+
# action: "impersonation_ended",
|
|
48
|
+
# actor_nuid: admin_nuid,
|
|
49
|
+
# on_behalf_of_nuid: target_nuid,
|
|
50
|
+
# mode: "view_as"
|
|
51
|
+
# )
|
|
52
|
+
class AuditEvent
|
|
53
|
+
extend AtlasRb::FaradayHelper
|
|
54
|
+
|
|
55
|
+
# Atlas REST endpoint for the session-scoped emit.
|
|
56
|
+
# @api private
|
|
57
|
+
ROUTE = "/audit_events"
|
|
58
|
+
|
|
59
|
+
# Emit a session-scoped AuditEvent (one with no resource scope).
|
|
60
|
+
#
|
|
61
|
+
# Wraps `POST /audit_events`. The principals and metadata travel in the
|
|
62
|
+
# JSON body — not in request headers — so the recorded event does not
|
|
63
|
+
# depend on the ambient {AtlasRb.config} identity. The request still
|
|
64
|
+
# authenticates as the configured caller (system token + the admin
|
|
65
|
+
# `User:` header); Atlas admin-gates the endpoint server-side.
|
|
66
|
+
#
|
|
67
|
+
# Atlas stamps `occurred_at` server-side, so it is not part of the body.
|
|
68
|
+
#
|
|
69
|
+
# Authorization errors (`401` / `403`) are intentionally **not** caught
|
|
70
|
+
# here — they surface as raw Faraday responses for the calling
|
|
71
|
+
# application's rescue layer to translate, matching
|
|
72
|
+
# {AtlasRb::Resource.history}.
|
|
73
|
+
#
|
|
74
|
+
# @param action [String] the audit action, e.g. `"impersonation_started"`
|
|
75
|
+
# or `"impersonation_ended"`.
|
|
76
|
+
# @param actor_nuid [String] NUID of the principal performing the action
|
|
77
|
+
# (the admin, in the impersonation flow).
|
|
78
|
+
# @param on_behalf_of_nuid [String, nil] NUID of the attribution target
|
|
79
|
+
# (the impersonated user). Omitted from the body when `nil`.
|
|
80
|
+
# @param mode [String, nil] session mode, e.g. `"acting_as"` or
|
|
81
|
+
# `"view_as"`. Omitted from the body when `nil` so the binding can also
|
|
82
|
+
# carry future, mode-less session events.
|
|
83
|
+
# @param payload [Hash] optional free-form metadata to record alongside
|
|
84
|
+
# the event. Omitted from the body when empty.
|
|
85
|
+
# @return [AtlasRb::Mash] the parsed envelope returned by
|
|
86
|
+
# `POST /audit_events` (the created event).
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# AtlasRb::AuditEvent.emit(
|
|
90
|
+
# action: "impersonation_started",
|
|
91
|
+
# actor_nuid: "000000001",
|
|
92
|
+
# on_behalf_of_nuid: "000000123",
|
|
93
|
+
# mode: "acting_as"
|
|
94
|
+
# )
|
|
95
|
+
def self.emit(action:, actor_nuid:, on_behalf_of_nuid: nil, mode: nil, payload: {})
|
|
96
|
+
body = {
|
|
97
|
+
action: action,
|
|
98
|
+
actor_nuid: actor_nuid,
|
|
99
|
+
on_behalf_of_nuid: on_behalf_of_nuid,
|
|
100
|
+
mode: mode,
|
|
101
|
+
payload: (payload unless payload.nil? || payload.empty?)
|
|
102
|
+
}.compact
|
|
103
|
+
|
|
104
|
+
AtlasRb::Mash.new(JSON.parse(
|
|
105
|
+
connection({}, actor_nuid).post(ROUTE, JSON.dump(body))&.body
|
|
106
|
+
))
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/atlas_rb/collection.rb
CHANGED
|
@@ -88,6 +88,12 @@ module AtlasRb
|
|
|
88
88
|
# @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
|
|
89
89
|
# conflict that exhausted its internal retry budget (HTTP 409 with
|
|
90
90
|
# `error: "stale_resource"`).
|
|
91
|
+
# @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
|
|
92
|
+
# grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
|
|
93
|
+
# `tombstoned_parent`, `parent_required`, `parent_not_found`). The
|
|
94
|
+
# envelope's `error` code is exposed as `#code`.
|
|
95
|
+
# @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
|
|
96
|
+
# authorization grounds (HTTP 403).
|
|
91
97
|
#
|
|
92
98
|
# @example
|
|
93
99
|
# AtlasRb::Collection.reparent("col-456", "c-999")
|
data/lib/atlas_rb/community.rb
CHANGED
|
@@ -95,6 +95,11 @@ module AtlasRb
|
|
|
95
95
|
# @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
|
|
96
96
|
# conflict that exhausted its internal retry budget (HTTP 409 with
|
|
97
97
|
# `error: "stale_resource"`).
|
|
98
|
+
# @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
|
|
99
|
+
# grounds (HTTP 422 — `cycle`, `tombstoned_node`, `tombstoned_parent`,
|
|
100
|
+
# `parent_not_found`). The envelope's `error` code is exposed as `#code`.
|
|
101
|
+
# @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
|
|
102
|
+
# authorization grounds (HTTP 403).
|
|
98
103
|
#
|
|
99
104
|
# @example Move under another Community
|
|
100
105
|
# AtlasRb::Community.reparent("c-123", "c-999")
|
data/lib/atlas_rb/errors.rb
CHANGED
|
@@ -36,4 +36,98 @@ module AtlasRb
|
|
|
36
36
|
@action = action
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
# Raised when Atlas rejects a re-parent (`PATCH /<type>/:id/parent`) with a
|
|
41
|
+
# structural `422` carrying a machine-readable `error` discriminator —
|
|
42
|
+
# `tombstoned_node`, `tombstoned_parent`, `parent_required`,
|
|
43
|
+
# `invalid_parent_type`, `cycle`, or `parent_not_found`.
|
|
44
|
+
#
|
|
45
|
+
# Mirrors {StaleResourceError}: a narrow translation of one wire signal
|
|
46
|
+
# callers need to discriminate on. Without it the binding's `["collection"]`
|
|
47
|
+
# / `["work"]` / `["community"]` unwrap silently returns `nil` on a 422,
|
|
48
|
+
# discarding Atlas's `error`/`message` and leaving the caller unable to tell
|
|
49
|
+
# an invalid move from a not-found from a forbidden one.
|
|
50
|
+
#
|
|
51
|
+
# Callers key on {#code} for specific messaging, falling back to {#message}:
|
|
52
|
+
#
|
|
53
|
+
# rescue AtlasRb::ReparentError => e
|
|
54
|
+
# flash.now[:alert] = t("reparent.errors.#{e.code}", default: e.message)
|
|
55
|
+
#
|
|
56
|
+
# @note Authorization failures surface as {ForbiddenError} (HTTP 403), not
|
|
57
|
+
# this — even on a re-parent path.
|
|
58
|
+
class ReparentError < Error
|
|
59
|
+
# @return [String, nil] the machine-readable error code from the envelope
|
|
60
|
+
# (e.g. `"cycle"`), suitable for keying an i18n map.
|
|
61
|
+
attr_reader :code
|
|
62
|
+
|
|
63
|
+
# @return [String, nil] the rejected resource's ID, from the envelope.
|
|
64
|
+
attr_reader :resource_id
|
|
65
|
+
|
|
66
|
+
# @param message [String] human-readable rejection description.
|
|
67
|
+
# @param code [String, nil] the envelope's `error` discriminator.
|
|
68
|
+
# @param resource_id [String, nil] the rejected resource's ID.
|
|
69
|
+
def initialize(message, code: nil, resource_id: nil)
|
|
70
|
+
super(message)
|
|
71
|
+
@code = code
|
|
72
|
+
@resource_id = resource_id
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Raised when Atlas rejects a linked-member write
|
|
77
|
+
# (`POST` / `DELETE /works/:id/linked_members`) with a `422` carrying a
|
|
78
|
+
# machine-readable `error` discriminator. The linked-member sibling of
|
|
79
|
+
# {ReparentError}; same shape, same rationale (the binding would otherwise
|
|
80
|
+
# discard the envelope on a non-2xx).
|
|
81
|
+
#
|
|
82
|
+
# rescue AtlasRb::LinkedMemberError => e
|
|
83
|
+
# flash.now[:alert] = t("linked_member.errors.#{e.code}", default: e.message)
|
|
84
|
+
#
|
|
85
|
+
# @note Authorization failures surface as {ForbiddenError} (HTTP 403).
|
|
86
|
+
class LinkedMemberError < Error
|
|
87
|
+
# @return [String, nil] the machine-readable error code from the envelope,
|
|
88
|
+
# suitable for keying an i18n map.
|
|
89
|
+
attr_reader :code
|
|
90
|
+
|
|
91
|
+
# @return [String, nil] the rejected resource's ID, from the envelope.
|
|
92
|
+
attr_reader :resource_id
|
|
93
|
+
|
|
94
|
+
# @param message [String] human-readable rejection description.
|
|
95
|
+
# @param code [String, nil] the envelope's `error` discriminator.
|
|
96
|
+
# @param resource_id [String, nil] the rejected resource's ID.
|
|
97
|
+
def initialize(message, code: nil, resource_id: nil)
|
|
98
|
+
super(message)
|
|
99
|
+
@code = code
|
|
100
|
+
@resource_id = resource_id
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Raised when Atlas refuses a re-parent or linked-member write with an
|
|
105
|
+
# HTTP `403`, whose envelope is `{ "error", "action", "subject" }`. Lets
|
|
106
|
+
# callers distinguish "you may not do this" from a structural rejection
|
|
107
|
+
# ({ReparentError} / {LinkedMemberError}) or a not-found.
|
|
108
|
+
#
|
|
109
|
+
# @note Scoped to the re-parent / linked-member write paths — `403`s on
|
|
110
|
+
# other endpoints still surface as raw responses for the caller's own
|
|
111
|
+
# rescue layer, unchanged.
|
|
112
|
+
class ForbiddenError < Error
|
|
113
|
+
# @return [String, nil] the envelope's `error` value.
|
|
114
|
+
attr_reader :code
|
|
115
|
+
|
|
116
|
+
# @return [String, nil] the action that was forbidden (e.g. `"reparent"`).
|
|
117
|
+
attr_reader :action
|
|
118
|
+
|
|
119
|
+
# @return [String, nil] the subject (resource) the action was forbidden on.
|
|
120
|
+
attr_reader :subject
|
|
121
|
+
|
|
122
|
+
# @param message [String] human-readable authorization-failure description.
|
|
123
|
+
# @param code [String, nil] the envelope's `error` value.
|
|
124
|
+
# @param action [String, nil] the forbidden action.
|
|
125
|
+
# @param subject [String, nil] the subject the action was forbidden on.
|
|
126
|
+
def initialize(message, code: nil, action: nil, subject: nil)
|
|
127
|
+
super(message)
|
|
128
|
+
@code = code
|
|
129
|
+
@action = action
|
|
130
|
+
@subject = subject
|
|
131
|
+
end
|
|
132
|
+
end
|
|
39
133
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtlasRb
|
|
4
|
+
module Middleware
|
|
5
|
+
# Translates Atlas's structured re-parent / linked-member rejections into
|
|
6
|
+
# typed Ruby exceptions, so the resource bindings don't silently swallow
|
|
7
|
+
# the error envelope.
|
|
8
|
+
#
|
|
9
|
+
# The re-parent and linked-member bindings unwrap their success payload by
|
|
10
|
+
# a fixed key (`["collection"]` / `["work"]` / `["community"]`, or the
|
|
11
|
+
# bare linked-member array). On a `4xx` that key is absent, so the binding
|
|
12
|
+
# would return `nil` and discard Atlas's machine-readable `error` /
|
|
13
|
+
# `message`. This middleware keys on the **request path + status** and
|
|
14
|
+
# raises a typed error carrying the envelope through, parallel to
|
|
15
|
+
# {RaiseOnStaleResource}.
|
|
16
|
+
#
|
|
17
|
+
# It is intentionally narrow — it only fires on the re-parent
|
|
18
|
+
# (`.../parent`) and linked-member (`.../linked_members...`) write paths,
|
|
19
|
+
# and only on `403` / `422` bodies carrying an `error` discriminator.
|
|
20
|
+
# Everything else (other paths, other statuses, a `422` whose body uses a
|
|
21
|
+
# different discriminator such as `tombstone`'s `code: "has_live_children"`)
|
|
22
|
+
# passes through untouched, so atlas_rb stays a thin Faraday binding that
|
|
23
|
+
# translates only the wire signals callers genuinely need to discriminate.
|
|
24
|
+
#
|
|
25
|
+
# Mapping:
|
|
26
|
+
# - `403` (either path) → {AtlasRb::ForbiddenError} (`error`/`action`/`subject`)
|
|
27
|
+
# - `422` on `.../parent` → {AtlasRb::ReparentError} (`error`/`resource_id`)
|
|
28
|
+
# - `422` on `.../linked_members...` → {AtlasRb::LinkedMemberError}
|
|
29
|
+
class RaiseOnResourceError < Faraday::Middleware
|
|
30
|
+
# @param env [Faraday::Env] the completed response environment.
|
|
31
|
+
# @raise [AtlasRb::ForbiddenError] on a 403 to a re-parent / linked-member path.
|
|
32
|
+
# @raise [AtlasRb::ReparentError] on a 422 to a re-parent path.
|
|
33
|
+
# @raise [AtlasRb::LinkedMemberError] on a 422 to a linked-member path.
|
|
34
|
+
# @return [void]
|
|
35
|
+
def on_complete(env)
|
|
36
|
+
return unless env.status == 403 || env.status == 422
|
|
37
|
+
|
|
38
|
+
path = env.url&.path.to_s
|
|
39
|
+
reparent = path.end_with?("/parent")
|
|
40
|
+
linked = path.include?("/linked_members")
|
|
41
|
+
return unless reparent || linked
|
|
42
|
+
|
|
43
|
+
body = parse_json(env.body)
|
|
44
|
+
return unless body.is_a?(Hash) && body["error"]
|
|
45
|
+
|
|
46
|
+
if env.status == 403
|
|
47
|
+
raise AtlasRb::ForbiddenError.new(
|
|
48
|
+
body["message"] || "Atlas refused the request",
|
|
49
|
+
code: body["error"],
|
|
50
|
+
action: body["action"],
|
|
51
|
+
subject: body["subject"]
|
|
52
|
+
)
|
|
53
|
+
elsif reparent
|
|
54
|
+
raise AtlasRb::ReparentError.new(
|
|
55
|
+
body["message"] || "Atlas rejected the re-parent",
|
|
56
|
+
code: body["error"],
|
|
57
|
+
resource_id: body["resource_id"]
|
|
58
|
+
)
|
|
59
|
+
else
|
|
60
|
+
raise AtlasRb::LinkedMemberError.new(
|
|
61
|
+
body["message"] || "Atlas rejected the linked-member write",
|
|
62
|
+
code: body["error"],
|
|
63
|
+
resource_id: body["resource_id"]
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def parse_json(body)
|
|
71
|
+
return body if body.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
JSON.parse(body.to_s)
|
|
74
|
+
rescue JSON::ParserError
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/atlas_rb/work.rb
CHANGED
|
@@ -156,6 +156,12 @@ module AtlasRb
|
|
|
156
156
|
# @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
|
|
157
157
|
# conflict that exhausted its internal retry budget (HTTP 409 with
|
|
158
158
|
# `error: "stale_resource"`).
|
|
159
|
+
# @raise [AtlasRb::ReparentError] if Atlas rejects the move on structural
|
|
160
|
+
# grounds (HTTP 422 — `cycle`, `invalid_parent_type`, `tombstoned_node`,
|
|
161
|
+
# `tombstoned_parent`, `parent_required`, `parent_not_found`). The
|
|
162
|
+
# envelope's `error` code is exposed as `#code`.
|
|
163
|
+
# @raise [AtlasRb::ForbiddenError] if Atlas refuses the move on
|
|
164
|
+
# authorization grounds (HTTP 403).
|
|
159
165
|
#
|
|
160
166
|
# @example
|
|
161
167
|
# AtlasRb::Work.reparent("w-789", "col-999")
|
|
@@ -439,6 +445,11 @@ module AtlasRb
|
|
|
439
445
|
# @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
|
|
440
446
|
# conflict that exhausted its internal retry budget (HTTP 409 with
|
|
441
447
|
# `error: "stale_resource"`).
|
|
448
|
+
# @raise [AtlasRb::LinkedMemberError] if Atlas rejects the link on
|
|
449
|
+
# structural grounds (HTTP 422). The envelope's `error` code is exposed
|
|
450
|
+
# as `#code`.
|
|
451
|
+
# @raise [AtlasRb::ForbiddenError] if Atlas refuses the link on
|
|
452
|
+
# authorization grounds (HTTP 403).
|
|
442
453
|
#
|
|
443
454
|
# @example
|
|
444
455
|
# AtlasRb::Work.add_linked_member("w-789", "col-456")
|
|
@@ -472,6 +483,11 @@ module AtlasRb
|
|
|
472
483
|
# @raise [AtlasRb::StaleResourceError] if Atlas reports an optimistic-lock
|
|
473
484
|
# conflict that exhausted its internal retry budget (HTTP 409 with
|
|
474
485
|
# `error: "stale_resource"`).
|
|
486
|
+
# @raise [AtlasRb::LinkedMemberError] if Atlas rejects the removal on
|
|
487
|
+
# structural grounds (HTTP 422). The envelope's `error` code is exposed
|
|
488
|
+
# as `#code`.
|
|
489
|
+
# @raise [AtlasRb::ForbiddenError] if Atlas refuses the removal on
|
|
490
|
+
# authorization grounds (HTTP 403).
|
|
475
491
|
#
|
|
476
492
|
# @example
|
|
477
493
|
# AtlasRb::Work.remove_linked_member("w-789", "col-456")
|
data/lib/atlas_rb.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "atlas_rb/version"
|
|
|
7
7
|
require_relative "atlas_rb/errors"
|
|
8
8
|
require_relative "atlas_rb/configuration"
|
|
9
9
|
require_relative "atlas_rb/middleware/raise_on_stale_resource"
|
|
10
|
+
require_relative "atlas_rb/middleware/raise_on_resource_error"
|
|
10
11
|
require_relative "atlas_rb/faraday_helper"
|
|
11
12
|
require_relative "atlas_rb/mash"
|
|
12
13
|
require_relative "atlas_rb/authentication"
|
|
@@ -22,6 +23,7 @@ require_relative "atlas_rb/admin/work"
|
|
|
22
23
|
require_relative "atlas_rb/admin/collection"
|
|
23
24
|
require_relative "atlas_rb/admin/community"
|
|
24
25
|
require_relative "atlas_rb/system/user"
|
|
26
|
+
require_relative "atlas_rb/audit_event"
|
|
25
27
|
|
|
26
28
|
# Ruby client for the Atlas API — Northeastern University's institutional
|
|
27
29
|
# digital repository.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atlas_rb
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Cliff
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: faraday
|
|
@@ -119,6 +119,7 @@ files:
|
|
|
119
119
|
- lib/atlas_rb/admin/collection.rb
|
|
120
120
|
- lib/atlas_rb/admin/community.rb
|
|
121
121
|
- lib/atlas_rb/admin/work.rb
|
|
122
|
+
- lib/atlas_rb/audit_event.rb
|
|
122
123
|
- lib/atlas_rb/authentication.rb
|
|
123
124
|
- lib/atlas_rb/blob.rb
|
|
124
125
|
- lib/atlas_rb/collection.rb
|
|
@@ -129,6 +130,7 @@ files:
|
|
|
129
130
|
- lib/atlas_rb/faraday_helper.rb
|
|
130
131
|
- lib/atlas_rb/file_set.rb
|
|
131
132
|
- lib/atlas_rb/mash.rb
|
|
133
|
+
- lib/atlas_rb/middleware/raise_on_resource_error.rb
|
|
132
134
|
- lib/atlas_rb/middleware/raise_on_stale_resource.rb
|
|
133
135
|
- lib/atlas_rb/resource.rb
|
|
134
136
|
- lib/atlas_rb/system/user.rb
|