atlas_rb 1.9.2 → 1.9.3

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: 235448544b8690ecf3a941cc259ab5f049442f609766666297c571e336ac3eae
4
- data.tar.gz: d3dc2e59aab57397176e959eec0a28478d5a15dbc0ebe2be459c775a42ec00ce
3
+ metadata.gz: 25a2216a5796eae1514b062b18135d9f071c731f006b50e8f72d6064380f9923
4
+ data.tar.gz: d8d564ef0d476aa32046c5a4294776b73217e0c4a932b5b9895f22f6843c4371
5
5
  SHA512:
6
- metadata.gz: 786316240b620bed1a107503c2162d0add7979bbc70574a9f0d1b7cbc473e616e28001d8db9abc75edc972c544211207cbd93f1eee93e674f365d2b0cbecc5e0
7
- data.tar.gz: aa6f59d62916ce00cb59ae8cca4e8e029ff96ff4c1fcb40041a4c4fd27d675dfcf98b7a2a04cca5a6f9876bdec846415196d09499f91ee170abb636964b22d25
6
+ metadata.gz: 685a8bf54eeffcf7590a26147455deeca64751e7161155978607c6bf6b68b29b9c982b9b6ffbca1bdbc004dcdeec0b9d1f5fe5955b17e5e9009b74c709e91f51
7
+ data.tar.gz: ef296b4c28970b3ec6d3b3ec35647a90407636d174d4a6ee9d1663640fd8c9b3853f4b0371ae6c76d89774fcfe7947d86152b6e717d1ae6b0fe8a509ea8392d0
data/.version CHANGED
@@ -1 +1 @@
1
- 1.9.2
1
+ 1.9.3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.3
4
+
5
+ ### Added — `depositor:` on `Collection.create` and `Community.create`
6
+
7
+ `AtlasRb::Collection.create(parent_id, depositor: "000000099")` (and the same
8
+ on `Community.create`) stamps a container's intellectual owner, matching what
9
+ `Work.create` has always supported. Atlas already read the param; only the
10
+ bindings couldn't pass it.
11
+
12
+ The case it unblocks is seeding an institutional tree: the caller acts as an
13
+ admin (the only identity whose wildcard carries a whole seed sequence) while
14
+ attributing the containers to the anonymous NUID, since nobody personally owns
15
+ them and access is via Grouper groups. That matters now that a depositor
16
+ carries edit rights on their own resource — a container stamped with a real
17
+ person's NUID would hand them edit over that whole subtree.
18
+
19
+ Omitted, the depositor still falls through to the acting user, so existing
20
+ callers are unaffected.
21
+
22
+ ### Added — typed errors for Atlas's container-create `403` and ACL-write `422`
23
+
24
+ Atlas gained two refusals that the bindings previously swallowed, because
25
+ `RaiseOnResourceError` was scoped to the re-parent / linked-member /
26
+ Compilation / derivative-permissions / upload paths:
27
+
28
+ - **A refused create.** `POST /{works,collections,communities}` now `403`s when
29
+ the caller holds no edit rights on the destination container. The binding's
30
+ `["collection"]` unwrap returned `nil`, so the caller's next `.id` raised
31
+ `NoMethodError` — an unhandled 500 instead of an authorization failure. Now
32
+ {AtlasRb::ForbiddenError}, carrying `action: "create_child"` and the parent
33
+ container's class as `subject`.
34
+ - **A refused ACL write.** `PATCH /{type}/:id` with `metadata[permissions]`
35
+ now `422`s with `visibility_exceeds_parent` when the read audience would
36
+ exceed the structural container. The parsed envelope looked like a success
37
+ payload, so a user's visibility edit was discarded silently. Now
38
+ {AtlasRb::PermissionsError}, keyed on the `error` code rather than the path —
39
+ the same endpoint's other `422`s (tombstone's `has_live_children`) still pass
40
+ through untouched, which a path rule could not distinguish.
41
+
42
+ The create branch matches the three paths exactly and only on `POST`, so member
43
+ actions under the same prefix and the index `GET` are unaffected.
44
+
3
45
  ## 1.9.2
4
46
 
5
47
  ### Added — `read_only:` on `System::Token.mint`
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.9.2)
4
+ atlas_rb (1.9.3)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
data/README.md CHANGED
@@ -222,6 +222,11 @@ AtlasRb::FileSet.create("w-789", "primary") # file_set under work w-789, classif
222
222
  AtlasRb::Blob.create("w-789", path, name) # blob under work w-789 with original filename preserved
223
223
  ```
224
224
 
225
+ `Community.create`, `Collection.create`, and `Work.create` each accept an
226
+ optional `depositor:` kwarg — the NUID to stamp as the resource's intellectual
227
+ owner, independent of who authorizes the call. Omitted, Atlas falls through to
228
+ the acting user.
229
+
225
230
  `Work.create`, `FileSet.create`, and `Blob.create` each accept an optional
226
231
  `idempotency_key:` kwarg for retry-safe bulk-deposit jobs. The caller
227
232
  generates the UUID; the Atlas server enforces uniqueness scoped to the
@@ -57,14 +57,26 @@ module AtlasRb
57
57
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
58
58
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
59
59
  # omitted.
60
+ # @param depositor [String, nil] NUID to stamp as the Collection's
61
+ # intellectual owner. Omit it and Atlas falls through to the acting user.
62
+ # Supply it to attribute a container to someone other than whoever is
63
+ # authorizing the call — e.g. seeding an institutional tree as an admin
64
+ # while attributing it to the anonymous NUID, since nobody personally owns
65
+ # those containers and access to them is via Grouper groups. The depositor
66
+ # is immutable post-create; there is no setter on the update surface.
60
67
  # @return [Hash] the created Collection payload (post-update if
61
68
  # `xml_path` was supplied).
62
69
  #
63
70
  # @example A featured showcase collection
64
71
  # AtlasRb::Collection.create("c-123", featured: true)
65
- def self.create(id, xml_path = nil, featured: false, nuid: nil, on_behalf_of: nil)
72
+ #
73
+ # @example An institutional container owned by nobody
74
+ # AtlasRb::Collection.create("c-123", depositor: "000000099")
75
+ def self.create(id, xml_path = nil, featured: false, nuid: nil, on_behalf_of: nil, depositor: nil)
76
+ params = { parent_id: id, featured: featured }
77
+ params[:depositor] = depositor if depositor
66
78
  result = AtlasRb::Mash.new(JSON.parse(
67
- connection({ parent_id: id, featured: featured }, nuid, on_behalf_of: on_behalf_of).post(ROUTE)&.body
79
+ connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)&.body
68
80
  ))["collection"]
69
81
  return result if xml_path.to_s.empty?
70
82
 
@@ -52,6 +52,13 @@ module AtlasRb
52
52
  # @param on_behalf_of [String, nil] optional NUID for the `On-Behalf-Of`
53
53
  # header. Falls through to {AtlasRb.config}.default_on_behalf_of when
54
54
  # omitted.
55
+ # @param depositor [String, nil] NUID to stamp as the Community's
56
+ # intellectual owner. Omit it and Atlas falls through to the acting user.
57
+ # Supply it to attribute a container to someone other than whoever is
58
+ # authorizing the call — e.g. seeding an institutional tree as an admin
59
+ # while attributing it to the anonymous NUID, since nobody personally owns
60
+ # those containers and access to them is via Grouper groups. The depositor
61
+ # is immutable post-create; there is no setter on the update surface.
55
62
  # @return [Hash] the created Community payload (post-update if `xml_path`
56
63
  # was supplied).
57
64
  #
@@ -60,9 +67,14 @@ module AtlasRb
60
67
  #
61
68
  # @example Sub-community seeded from MODS
62
69
  # AtlasRb::Community.create("c-parent", "/tmp/dept-mods.xml")
63
- def self.create(id = nil, xml_path = nil, nuid: nil, on_behalf_of: nil)
70
+ #
71
+ # @example An institutional container owned by nobody
72
+ # AtlasRb::Community.create("c-parent", depositor: "000000099")
73
+ def self.create(id = nil, xml_path = nil, nuid: nil, on_behalf_of: nil, depositor: nil)
74
+ params = { parent_id: id }
75
+ params[:depositor] = depositor if depositor
64
76
  result = AtlasRb::Mash.new(JSON.parse(
65
- connection({ parent_id: id }, nuid, on_behalf_of: on_behalf_of).post(ROUTE)&.body
77
+ connection(params, nuid, on_behalf_of: on_behalf_of).post(ROUTE)&.body
66
78
  ))["community"]
67
79
  return result if xml_path.to_s.empty?
68
80
 
@@ -201,15 +201,58 @@ module AtlasRb
201
201
  end
202
202
  end
203
203
 
204
- # Raised when Atlas refuses a re-parent, linked-member, or Compilation
205
- # request with an HTTP `403`, whose envelope is
204
+ # Raised when Atlas refuses an ACL write on a resource
205
+ # (`PATCH /{works,collections,communities}/:id` with `metadata[permissions]`)
206
+ # because it breaks a rights invariant. Today the one code is
207
+ # `visibility_exceeds_parent`: a resource may be no more visible than its
208
+ # structural container, so opening a Work to `public` inside a restricted
209
+ # Collection is refused (the fix is to widen the container).
210
+ #
211
+ # This is NOT an authorization failure — the caller may well hold full edit
212
+ # rights on the resource, and a depositor choosing "Public" on their own item
213
+ # under a private collection trips it in ordinary use. Without the typed
214
+ # error the binding's `["collection"]` / `["work"]` unwrap returns an envelope
215
+ # the caller reads as success, so the user's visibility edit is silently
216
+ # discarded.
217
+ #
218
+ # rescue AtlasRb::PermissionsError => e
219
+ # flash.now[:alert] = t("permissions.errors.#{e.code}", default: e.message)
220
+ #
221
+ # @note Keyed on the envelope's `error` code, not the request path, so other
222
+ # `422`s on the same endpoint (e.g. `tombstone`'s `has_live_children`) pass
223
+ # through untouched.
224
+ class PermissionsError < Error
225
+ # @return [String, nil] the machine-readable error code from the envelope,
226
+ # suitable for keying an i18n map.
227
+ attr_reader :code
228
+
229
+ # @return [String, nil] the rejected resource's ID, from the envelope.
230
+ attr_reader :resource_id
231
+
232
+ # @param message [String] human-readable rejection description.
233
+ # @param code [String, nil] the envelope's `error` discriminator.
234
+ # @param resource_id [String, nil] the rejected resource's ID.
235
+ def initialize(message, code: nil, resource_id: nil)
236
+ super(message)
237
+ @code = code
238
+ @resource_id = resource_id
239
+ end
240
+ end
241
+
242
+ # Raised when Atlas refuses a re-parent, linked-member, Compilation, or
243
+ # container-create request with an HTTP `403`, whose envelope is
206
244
  # `{ "error", "action", "subject" }`. Lets callers distinguish "you may
207
245
  # not do this" from a structural rejection ({ReparentError} /
208
246
  # {LinkedMemberError} / {CompilationError}) or a not-found.
209
247
  #
210
- # @note Scoped to the re-parent / linked-member write paths and the
211
- # Compilation surface `403`s on other endpoints still surface as raw
212
- # responses for the caller's own rescue layer, unchanged.
248
+ # On a create the `action` is `create_child` and the `subject` is the parent
249
+ # container's class: the caller holds no edit rights on the container the new
250
+ # child would land in.
251
+ #
252
+ # @note Scoped to the re-parent / linked-member write paths, the Compilation
253
+ # surface, and the container-create endpoints — `403`s on other endpoints
254
+ # still surface as raw responses for the caller's own rescue layer,
255
+ # unchanged.
213
256
  class ForbiddenError < Error
214
257
  # @return [String, nil] the envelope's `error` value.
215
258
  attr_reader :code
@@ -17,22 +17,29 @@ module AtlasRb
17
17
  # It is intentionally narrow — it only fires on the re-parent
18
18
  # (`.../parent`) and linked-member (`.../linked_members...`) write paths,
19
19
  # the Compilation surface (`/compilations...`), the derivative-permissions
20
- # write (`.../derivative_permissions`), and binary uploads (`/files...`,
21
- # `/file_sets...`), and only on `403` / `422` bodies carrying
22
- # an `error` discriminator. The upload branch is further gated on a fixity
23
- # discriminator ({FIXITY_CODES}), so a `422` on those paths with any other
24
- # `error` (or `403`s on uploads, which stay raw) passes through untouched.
20
+ # write (`.../derivative_permissions`), the container-create endpoints
21
+ # ({CREATE_PATHS}), and binary uploads (`/files...`, `/file_sets...`), and
22
+ # only on `403` / `422` bodies carrying an `error` discriminator. The upload
23
+ # branch is further gated on a fixity discriminator ({FIXITY_CODES}), so a
24
+ # `422` on those paths with any other `error` (or `403`s on uploads, which
25
+ # stay raw) passes through untouched.
25
26
  # Everything else (other paths, other statuses, a `422` whose body uses a
26
27
  # different discriminator such as `tombstone`'s `code: "has_live_children"`)
27
28
  # passes through untouched, so atlas_rb stays a thin Faraday binding that
28
29
  # translates only the wire signals callers genuinely need to discriminate.
29
30
  #
31
+ # The ACL branch is keyed on the `error` code ({PERMISSIONS_CODES}) rather
32
+ # than a path, because the write it guards is the plain resource `PATCH`
33
+ # whose other rejections (tombstone's `has_live_children`) must keep passing
34
+ # through — a path rule couldn't tell them apart.
35
+ #
30
36
  # Mapping:
31
- # - `403` on a re-parent/linked/Compilation/derivative-permissions path → {AtlasRb::ForbiddenError}
37
+ # - `403` on a re-parent/linked/Compilation/derivative-permissions/create path → {AtlasRb::ForbiddenError}
32
38
  # - `422` on `.../parent` → {AtlasRb::ReparentError} (`error`/`resource_id`)
33
39
  # - `422` on `.../linked_members...` → {AtlasRb::LinkedMemberError}
34
40
  # - `422` on `/compilations...` → {AtlasRb::CompilationError}
35
41
  # - `422` on `.../derivative_permissions` → {AtlasRb::DerivativePermissionsError}
42
+ # - `422` + an ACL discriminator anywhere → {AtlasRb::PermissionsError}
36
43
  # - `422` + a fixity discriminator on `/files...` / `/file_sets...` →
37
44
  # {AtlasRb::FixityMismatchError}
38
45
  class RaiseOnResourceError < Faraday::Middleware
@@ -40,12 +47,25 @@ module AtlasRb
40
47
  # `error` on those paths passes through (Atlas owns these as a wire contract).
41
48
  FIXITY_CODES = %w[fixity_mismatch unsupported_digest_algorithm].freeze
42
49
 
50
+ # ACL-invariant `422` discriminators, translated wherever they appear:
51
+ # Atlas raises them from the shared metadata-PATCH funnel, so they can
52
+ # arrive on any resource's `PATCH /{type}/:id`.
53
+ PERMISSIONS_CODES = %w[visibility_exceeds_parent].freeze
54
+
55
+ # The container-create endpoints, matched exactly (no trailing id) and only
56
+ # on `POST`, so neither a member action under the same prefix
57
+ # (`/collections/:id/tombstone`, whose `422` must pass through) nor the
58
+ # index `GET` on the same path can be caught by mistake. A `403` here means
59
+ # the caller holds no edit rights on the destination container.
60
+ CREATE_PATHS = %w[/works /collections /communities].freeze
61
+
43
62
  # @param env [Faraday::Env] the completed response environment.
44
- # @raise [AtlasRb::ForbiddenError] on a 403 to a re-parent/linked/Compilation path.
63
+ # @raise [AtlasRb::ForbiddenError] on a 403 to a re-parent/linked/Compilation/create path.
45
64
  # @raise [AtlasRb::ReparentError] on a 422 to a re-parent path.
46
65
  # @raise [AtlasRb::LinkedMemberError] on a 422 to a linked-member path.
47
66
  # @raise [AtlasRb::CompilationError] on a 422 to a Compilation path.
48
67
  # @raise [AtlasRb::DerivativePermissionsError] on a 422 to a derivative-permissions path.
68
+ # @raise [AtlasRb::PermissionsError] on a 422 carrying an ACL-invariant discriminator.
49
69
  # @raise [AtlasRb::FixityMismatchError] on a 422 + fixity discriminator to an upload path.
50
70
  # @return [void]
51
71
  def on_complete(env)
@@ -56,15 +76,27 @@ module AtlasRb
56
76
  linked = path.include?("/linked_members")
57
77
  compilation = path.start_with?("/compilations")
58
78
  deriv_perms = path.end_with?("/derivative_permissions")
79
+ create = env.method.to_s == "post" && CREATE_PATHS.include?(path.chomp("/"))
59
80
  upload = path.start_with?("/files") || path.start_with?("/file_sets")
60
- return unless reparent || linked || compilation || deriv_perms || upload
61
81
 
62
82
  body = parse_json(env.body)
63
83
  return unless body.is_a?(Hash) && body["error"]
64
84
 
85
+ # Path-independent: the ACL invariants ride the shared metadata PATCH,
86
+ # so the code is the only reliable signal.
87
+ if env.status == 422 && PERMISSIONS_CODES.include?(body["error"])
88
+ raise AtlasRb::PermissionsError.new(
89
+ body["message"] || "Atlas rejected the permissions write",
90
+ code: body["error"],
91
+ resource_id: body["resource_id"]
92
+ )
93
+ end
94
+
95
+ return unless reparent || linked || compilation || deriv_perms || create || upload
96
+
65
97
  if env.status == 403
66
98
  # 403s on upload paths stay raw — acting-as/authz isn't an upload concern here.
67
- return unless reparent || linked || compilation || deriv_perms
99
+ return unless reparent || linked || compilation || deriv_perms || create
68
100
 
69
101
  raise AtlasRb::ForbiddenError.new(
70
102
  body["message"] || "Atlas refused the request",
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.9.2
4
+ version: 1.9.3
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-07-29 00:00:00.000000000 Z
11
+ date: 2026-07-31 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday