atlas_rb 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30f433b53fc97128762eadbf01f12f975111c2b6e597469acc19831e3ef02bba
4
- data.tar.gz: 3604a876f6061a2a4bf45953abae93faca9b7fdae27a9b264ec5e10526acaa0c
3
+ metadata.gz: d65fea7329731bd0fa0a397a19f5764a05e88397c522baf28721e4a08282e2d3
4
+ data.tar.gz: 7b2db70c9e1a7d9e8fad5194bcb045baeed562561b1c36a60c174e2c1fc5d0aa
5
5
  SHA512:
6
- metadata.gz: 62376e2b252c0269420ca89661bf7bb864b198f820076bd0c01210dc129adde111f218f2f33e7b3f88365a8b944cd5d2fda0758de050b9585c84ded5a1694309
7
- data.tar.gz: d498f94bff1f2e13fcdacbca9ca05dce38bbcfe80209de0f3339f31b69aff8e47492980079096f2f141748845aab30e271ba54fa19639368c005a37d1cc0a9d7
6
+ metadata.gz: 335c7d4bd02d6bdb6f297cf86857c042d515680fb8e36991d98a7a7bf4fdd1e52470f0dda4b40605f79d0618c7260a64f2125af2a3100a693c920b51ebc1c1f5
7
+ data.tar.gz: ce1bc4b16ce482975af0785138574aa6b42fc2cc05ddfee777adbef2834b733bdb93df5390cfc4e9a5012af4b9483cf5676f7213edc8e79afc0fd886d2b84545
data/.version CHANGED
@@ -1 +1 @@
1
- 1.2.1
1
+ 1.3.0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Added — `AtlasRb::Resource.find_many` (batch resolve by NOID)
6
+
7
+ A binding for Atlas's `POST /resources/find_many`. Resolves a set of NOIDs
8
+ to lightweight digests in **one** round-trip, replacing the `find`-per-id
9
+ fan-out that several Cerberus surfaces (breadcrumbs, linked-member lists,
10
+ load-destination pickers) paid on every render.
11
+
12
+ ```ruby
13
+ nodes = AtlasRb::Resource.find_many(["col-456", "col-457", "missing"])
14
+ by_noid = nodes.index_by { |n| n["noid"] }
15
+ by_noid["col-456"].title # => "Some Collection"
16
+ ```
17
+
18
+ - Each digest is `{ "id", "noid", "klass", "title", "thumbnail",
19
+ "tombstoned" }` — not the full typed payload. `title` / `thumbnail` are
20
+ `null` for resources off the Modsable backbone (FileSet/Blob).
21
+ - The ids ride in the request **body**, so the list isn't bounded by URL
22
+ length. Returns one `AtlasRb::Mash` per resolved resource.
23
+ - The result is **unordered** and **may be shorter than the input**:
24
+ unresolvable ids are dropped, tombstoned ones come back flagged
25
+ (`"tombstoned" => true`). Index by `"noid"` — don't assume positional
26
+ correspondence with the input.
27
+ - Resolves NOIDs (alternate ids) only; raw Valkyrie ids are not a supported
28
+ input.
29
+
30
+ ## 1.2.2
31
+
32
+ ### Added — `AtlasRb::AuditEvent.emit` (session-scoped audit events)
33
+
34
+ A new binding for Atlas's `POST /audit_events` endpoint, which records an
35
+ AuditEvent with a **null `resource_id`** — an event not tied to any
36
+ resource write. This is the gem's half of the impersonation audit trail
37
+ (acting-as / view-as): the session lifecycle lives entirely in the calling
38
+ application, and a view-as session performs no writes, so neither leaves a
39
+ per-resource event for `Resource.history` to surface.
40
+
41
+ ```ruby
42
+ AtlasRb::AuditEvent.emit(
43
+ action: "impersonation_started", # or "impersonation_ended"
44
+ actor_nuid: admin_nuid,
45
+ on_behalf_of_nuid: target_nuid,
46
+ mode: "acting_as" # or "view_as"
47
+ )
48
+ ```
49
+
50
+ - The recorded principals (`actor_nuid`, `on_behalf_of_nuid`), `mode`, and
51
+ an optional free-form `payload:` travel in the request **body**, not in
52
+ ambient headers — so the call is self-describing even when fired as a
53
+ session is being torn down (e.g. `impersonation_ended`).
54
+ - The request authenticates via the standard `connection` (system token),
55
+ with the `User: NUID` header pinned to `actor_nuid` so the server-side
56
+ admin gate holds regardless of ambient `Current` state.
57
+ - `on_behalf_of_nuid`, `mode`, and `payload` are omitted from the body when
58
+ blank, leaving room for future, mode-less session events. Atlas stamps
59
+ `occurred_at` server-side.
60
+ - Authorization errors (`401` / `403`) surface as raw Faraday responses,
61
+ matching `Resource.history`.
62
+
63
+ Depends on the matching Atlas-side `POST /audit_events` emit endpoint
64
+ (nullable resource scope, admin-gated); see the impersonation gap report.
65
+
3
66
  ## 1.2.1
4
67
 
5
68
  ### Added — typed errors for re-parent / linked-member rejections
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.2.1)
4
+ atlas_rb (1.3.0)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 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
@@ -272,6 +312,27 @@ The two mutations raise the same way `reparent` does — `LinkedMemberError`
272
312
  on a structural `422` (carrying the envelope's `error` code as `#code`) and
273
313
  `ForbiddenError` on a `403` — instead of swallowing the envelope.
274
314
 
315
+ ### Batch resolve (`Resource.find_many`)
316
+
317
+ When you have a *set* of NOIDs and only need each one's title / klass /
318
+ thumbnail — breadcrumb chains, linked-member lists, load-destination
319
+ pickers — resolve them in one round-trip instead of a `find`-per-id
320
+ fan-out:
321
+
322
+ ```ruby
323
+ nodes = AtlasRb::Resource.find_many(["col-456", "col-457", "missing"])
324
+ by_noid = nodes.index_by { |n| n["noid"] }
325
+ by_noid["col-456"].title # => "Some Collection"
326
+ ```
327
+
328
+ Each entry is a lightweight digest — `{ "id", "noid", "klass", "title",
329
+ "thumbnail", "tombstoned" }` — not the full typed payload. The ids travel
330
+ in the request body (no URL-length ceiling). The result is **unordered**
331
+ and **may be shorter than the input**: unresolvable ids are dropped and
332
+ tombstoned resources come back flagged (`"tombstoned" => true`), so index
333
+ by `"noid"` rather than assuming positional correspondence. NOIDs only —
334
+ raw Valkyrie ids are not a supported input.
335
+
275
336
  ## End-to-end example
276
337
 
277
338
  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
@@ -47,6 +47,44 @@ module AtlasRb
47
47
  "resource" => result.first[1])
48
48
  end
49
49
 
50
+ # Resolve many resources by NOID in a single round-trip.
51
+ #
52
+ # Wraps Atlas's `POST /resources/find_many`, which returns one lightweight
53
+ # digest per resolvable resource — `{ "id", "noid", "klass", "title",
54
+ # "thumbnail", "tombstoned" }` — rather than full typed payloads. Use it
55
+ # anywhere a set of ids would otherwise be resolved with a `find`-per-id
56
+ # fan-out (breadcrumb chains, linked-member lists, load-destination
57
+ # pickers): one HTTP call instead of N.
58
+ #
59
+ # The ids travel in the request **body**, so the list is not bounded by
60
+ # URL length. The result is **unordered** and **may be shorter than the
61
+ # input** — unresolvable ids are dropped silently, and tombstoned
62
+ # resources come back flagged (`"tombstoned" => true`) rather than
63
+ # omitted. Index the result by `"noid"`; do not assume positional
64
+ # correspondence with `ids`.
65
+ #
66
+ # @param ids [Array<String>] resource NOIDs to resolve. (Raw Valkyrie ids
67
+ # are not a supported input — the endpoint resolves alternate ids only.)
68
+ # @param nuid [String, nil] optional acting user's NUID, forwarded as the
69
+ # `User:` header. Required for cerberus-token requests; legacy bearer
70
+ # tokens still resolve without it.
71
+ # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
72
+ # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
73
+ # omitted.
74
+ # @return [Array<AtlasRb::Mash>] one digest Mash per resolved resource
75
+ # (dot- or string-keyed access); empty when nothing resolved.
76
+ #
77
+ # @example Resolve a set of collection titles in one call
78
+ # nodes = AtlasRb::Resource.find_many(["col-456", "col-457", "missing"])
79
+ # by_noid = nodes.index_by { |n| n["noid"] }
80
+ # by_noid["col-456"].title # => "Some Collection"
81
+ def self.find_many(ids, nuid: nil, on_behalf_of: nil)
82
+ JSON.parse(
83
+ connection({}, nuid, on_behalf_of: on_behalf_of)
84
+ .post('/resources/find_many', JSON.dump(ids: Array(ids)))&.body
85
+ ).map { |node| AtlasRb::Mash.new(node) }
86
+ end
87
+
50
88
  # Validate a MODS XML document against Atlas's schema *without* persisting it.
51
89
  #
52
90
  # Useful for surfacing validation errors in UIs before the user commits.
data/lib/atlas_rb.rb CHANGED
@@ -23,6 +23,7 @@ require_relative "atlas_rb/admin/work"
23
23
  require_relative "atlas_rb/admin/collection"
24
24
  require_relative "atlas_rb/admin/community"
25
25
  require_relative "atlas_rb/system/user"
26
+ require_relative "atlas_rb/audit_event"
26
27
 
27
28
  # Ruby client for the Atlas API — Northeastern University's institutional
28
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.1
4
+ version: 1.3.0
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-03 00:00:00.000000000 Z
11
+ date: 2026-06-06 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