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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 93e0688ad6097a3b6a36ed26db8c19d3416cb3c59beb3d95dd3cff61c6cdd176
4
- data.tar.gz: 1af0ff155d288412bd6ab8a95fc50da55cfccd02d605b5bae1cf28cebdf33973
3
+ metadata.gz: 29da2e41c70d17572c6c24002ba0ff25beb2ad6cdd940dee157a83133c86efaf
4
+ data.tar.gz: 9e16fed8e22867c37647a12de104135d78f57995ba5e6decd4929ac6a402bae7
5
5
  SHA512:
6
- metadata.gz: 44c8582605f238861c424a769fd4e788e08189edd969f3974d113a938f54ecce522e143ec0418cd813de958c1cbb6d9cd44b4796b6b9c190629af49a1d0417e9
7
- data.tar.gz: 8ad34a6b44d4472ba300f8ef7b6c9f30d5d8674f0f69f7711e094d202ba07f2b428607b463ce8261e21e2fa5bbcbca865da9de3fb3af8e28d6e8906810037a12
6
+ metadata.gz: 7ba5a0216a5cc5dacce4f6822078eb07d71aa4e14b31142c83dd823c0ec177efa92e1712eac0b235c125870611dcd4633817066c5ee70a98d6f85adbec614a7f
7
+ data.tar.gz: b176fc377d6d802e356c5f0a0fd487c6c899d4b5e323e3017eb773fd11e06dd8ebb415329a4c4e81aef5f55f0086602fc33a6cb82c4699709e0a3209c542a48d
data/.version CHANGED
@@ -1 +1 @@
1
- 1.2.0
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.0)
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.3)
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.7)
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
@@ -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")
@@ -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")
@@ -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
@@ -60,6 +60,7 @@ module AtlasRb
60
60
  headers: headers
61
61
  ) do |f|
62
62
  f.use AtlasRb::Middleware::RaiseOnStaleResource
63
+ f.use AtlasRb::Middleware::RaiseOnResourceError
63
64
  f.response :follow_redirects
64
65
  f.adapter Faraday.default_adapter
65
66
  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.0
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-01 00:00:00.000000000 Z
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