atlas_rb
Ruby client for the Atlas API — Northeastern University's institutional digital repository.
The gem wraps Atlas's REST endpoints in a small set of class-method-only modules, one per resource type. There is no client object to instantiate; calls are made directly on the resource class:
AtlasRb::Work.find("w-789")
Installation
Add to your Gemfile:
gem "atlas_rb"
Then bundle install, or install standalone with gem install atlas_rb.
Configuration
Environment variables
Every regular-path request reads these environment variables:
| Variable | Purpose |
|---|---|
ATLAS_URL |
Base URL of the Atlas API (e.g. https://atlas.example.edu). |
ATLAS_JWT |
Optional. A personal-access JWT minted by Atlas's POST /nuid. When set, switches to BYO-JWT mode. |
The default relay path authenticates by signing a short-lived assertion (see
Relay-signing); configure a signing key
for it. ATLAS_JWT, if set, takes precedence.
ENV["ATLAS_URL"] = "https://atlas.example.edu"
# + a configured signing key (relay) or ENV["ATLAS_JWT"] (BYO-JWT)
Ambient identity (default_nuid / default_on_behalf_of)
Every resource method that talks to Atlas accepts a nuid: kwarg (the
acting user) and an on_behalf_of: kwarg (the user the call is being
made for, used by acting-as / view-as flows). On the relay-signing path
nuid is signed into the assertion sub and on_behalf_of rides as a
signed obo claim.
Rather than threading them at every call site, register callables once on app boot and let the gem read them as defaults:
# config/initializers/atlas_rb.rb (Rails)
AtlasRb.configure do |config|
config.default_nuid = -> { Current.nuid }
config.default_on_behalf_of = -> { Current.on_behalf_of }
end
The lambdas run at request time in whatever thread / fiber is
making the call, so they pick up per-request Current.* values that
ApplicationController set up via Devise + Rails 7's
ActiveSupport::CurrentAttributes. Background jobs work the same way
(ActiveJob ↔ CurrentAttributes integration restores the values on
perform).
Caller-passed kwargs always win over the configured defaults:
# Uses Current.nuid:
AtlasRb::Work.find("w-789")
# Uses "X" — explicit kwarg overrides the default:
AtlasRb::Work.find("w-789", nuid: "X")
On the relay-signing path the acting nuid is required (it becomes the
assertion sub): if neither the call site nor the registered default_nuid
supplies one, the transport raises AtlasRb::ConfigurationError.
BYO-JWT mode (standalone scripts)
The relay-signing path above is what a Rails host (Cerberus) uses: a signed
assertion naming the acting person. For standalone scripts — a librarian
automating their own workflow — Atlas also accepts a personal-access JWT,
minted by Cerberus post-SSO via POST /nuid and handed to the user. Set it as
ATLAS_JWT and the gem switches transport modes:
ENV["ATLAS_URL"] = "https://atlas.example.edu"
ENV["ATLAS_JWT"] = "<token minted for you by Cerberus>" # 1-week TTL
# Identity is carried by the token — no nuid plumbing needed:
AtlasRb::Work.find("w-789")
In BYO-JWT mode:
- The JWT is the bearer, taking precedence over relay-signing.
- No
User:header is sent — the token already encodes the acting user, and anynuid:kwarg ordefault_nuidis ignored on this path. On-Behalf-Ofis suppressed — acting-as is a Cerberus-relay-only concept; Atlas rejects it on the JWT path with a 403. Anyon_behalf_of:kwarg ordefault_on_behalf_ofis dropped.
To rotate or revoke, ask Cerberus to regenerate your token (Atlas rotates
the user's jti, invalidating outstanding tokens — single-token model).
Relay-signing (the default relay path)
The relay authenticates with a proven identity: it signs a short-lived
assertion with its own private key (iss=cerberus, aud=atlas, sub = the
acting nuid, ES256), which Atlas verifies against the matching public key. No
shared secret, no asserted User: header — identity is the signed sub.
Configure a signing key (and the kid Atlas indexes its public key by) — value
or callable, so a Rails host reads it from credentials at request time:
AtlasRb.configure do |config|
config.assertion_signing_key = -> { Rails.application.credentials.cerberus_signing_key }
config.assertion_signing_kid = -> { Rails.application.credentials.cerberus_signing_kid }
end
With no signing key configured (and no ATLAS_JWT), the relay has no credential
and connection / multipart raise AtlasRb::ConfigurationError.
Two things to know:
- Acting-as rides a signed
oboclaim. Anon_behalf_ofrequest is signed withsub= the operator andobo= the target, inside the signature — so the target can't be forged onto a stolen assertion, and noOn-Behalf-Ofheader is sent. Atlas admin-gates the operator and ignores any header obo on this path. ATLAS_JWTstill wins. A personal token (BYO-JWT) takes precedence over relay-signing.
System-path credentials
Calls under AtlasRb::System::* (currently just SSO user provisioning)
authenticate as the seeded Atlas :system fixture, not as a real user.
They use a separate bearer token, looked up from
Rails.application.credentials.atlas_system_token. Storing it in
encrypted credentials rather than ENV halves the blast radius of a
.env leak — the user token and the system token can't both leak
through the same channel.
# config/credentials.yml.enc (Cerberus side)
atlas_system_token: <token-Atlas-side-recognises-as-:system>
The system NUID itself is hardcoded as AtlasRb::System::NUID = "000000000", matching Atlas's seeded :system fixture row.
Resource hierarchy
Community → Collection → Work
↓
FileSet
↓
Blob
| Class | Represents |
|---|---|
AtlasRb::Community |
Top-level org unit; may nest sub-Communities. |
AtlasRb::Collection |
Holds Works; lives directly under a Community. |
AtlasRb::Work |
Bibliographic unit (article, thesis, dataset…); MODS metadata lives here. |
AtlasRb::FileSet |
Classified slot under a Work (e.g. "primary", "supplemental"). |
AtlasRb::Blob |
The binary bytes; supports streaming downloads. |
AtlasRb::Authentication |
NUID → user record / group lookup. |
AtlasRb::Resource |
Generic resolver, permissions lookup, and audit-event history. |
AtlasRb::AuditEvent |
Emit a session-scoped (resource-less) audit event, e.g. impersonation. |
AtlasRb::Reset |
Test-only — wipes Atlas state via GET /reset. |
Namespace gradient: regular / Admin / System
Operations are split across three namespaces, calibrated to the blast radius and the kind of authentication they need:
| Namespace | What it does | Auth | Friction |
|---|---|---|---|
AtlasRb::* |
Regular CRUD (find / list / create / update / tombstone / metadata, etc.) | Relay-signing (signed assertion, sub = acting NUID) |
None — these are the daily-use paths. |
AtlasRb::Admin::* |
Hard delete (destroy) and un-tombstone (restore) for Work / Collection / Community. |
Same as regular — a real operator is acting. | destroy requires confirm: :i_understand. |
AtlasRb::System::* |
System-context provisioning (currently just SSO user find-or-create). | System token (Rails.application.credentials.atlas_system_token) + User: NUID 000000000. |
The namespace itself is the marker — there is no way to call these as a non-system principal. |
# Regular daily use — picks up Current.nuid via the configured default:
AtlasRb::Work.find("w-789")
AtlasRb::Work.tombstone("w-789") # withdrawal (reversible)
# Operator-only, with a friction marker:
AtlasRb::Admin::Work.destroy("w-789", confirm: :i_understand)
AtlasRb::Admin::Work.restore("w-789")
# System-only — authenticates as Atlas's :system fixture:
AtlasRb::System::User.find_or_create(
nuid: "001234567",
groups: ["northeastern:staff", "drs:editors"]
)
The AtlasRb::System::* path never consults AtlasRb.config.default_nuid
or default_on_behalf_of — there is no ambient user context on system
calls.
A note on create argument shapes
The CRUD-twin classes look the same but pass different parent IDs:
AtlasRb::Community.create(nil) # top-level community (parent_id: nil)
AtlasRb::Community.create("c-123") # sub-community of c-123
AtlasRb::Collection.create("c-123") # collection under community c-123
AtlasRb::Work.create("col-456") # work under collection col-456 (collection_id, not parent_id)
AtlasRb::FileSet.create("w-789", "primary") # file_set under work w-789, classification "primary"
AtlasRb::Blob.create("w-789", path, name) # blob under work w-789 with original filename preserved
Community.create, Collection.create, and Work.create each accept an
optional depositor: kwarg — the NUID to stamp as the resource's intellectual
owner, independent of who authorizes the call. Omitted, Atlas falls through to
the acting user.
Work.create, FileSet.create, and Blob.create each accept an optional
idempotency_key: kwarg for retry-safe bulk-deposit jobs. The caller
generates the UUID; the Atlas server enforces uniqueness scoped to the
acting user. A repeat call with the same key returns the originally-created
resource (or 410 if it has since been tombstoned). The gem does not
generate keys, cache responses, or retry — those concerns belong to the
calling job runner (e.g. Cerberus's Solid Queue).
key = SecureRandom.uuid
AtlasRb::Work.create("col-456", idempotency_key: key)
AtlasRb::FileSet.create("w-789", "primary", idempotency_key: key)
AtlasRb::Blob.create("w-789", path, name, idempotency_key: key)
Listing and monitoring Works
Work.list exposes the paginated GET /works index, with an optional
in_progress: filter for finding deposits that haven't yet been marked
complete. Work.complete flips a Work's in_progress flag to false
once a bulk-deposit job confirms all expected children have been deposited.
AtlasRb::Work.list(in_progress: true) # stuck deposits
AtlasRb::Work.list(in_progress: false, page: 2) # completed deposits, page 2
AtlasRb::Work.complete("w-789") # mark w-789 done
Audit-event history
Resource.history wraps Atlas's GET /resources/<id>/history endpoint
and returns the full envelope — resource_id plus a reverse-chronological
events array — as an AtlasRb::Mash. It is type-agnostic: pass any
Community, Collection, Work, FileSet, or Blob ID. Pagination is not yet
supported on the server side; the endpoint returns the full history in
one shot.
result = AtlasRb::Resource.history("w-789")
result["resource_id"] # => "w-789"
result["events"].first["action"] # => "update"
Authorization errors (401 / 403) are not caught here — they surface as
raw Faraday responses for the calling application's rescue layer.
Session-scoped audit events
Resource.history only reads events that hang off a resource write. Some
auditable events have no resource to attach to — most notably impersonation
sessions (acting-as / view-as): the session lifecycle lives in the calling
app, and a view-as session performs no writes at all, so it would otherwise
leave no audit trail. AtlasRb::AuditEvent.emit wraps Atlas's
POST /audit_events endpoint, which records an event with a null
resource_id.
The principals and metadata travel in the request body — not inferred from
ambient headers — so the call is self-describing even when fired as a
session is being torn down. The request is admin-gated server-side; the
User: NUID header is pinned to the actor_nuid you pass.
# An admin starts acting as another user:
AtlasRb::AuditEvent.emit(
action: "impersonation_started",
actor_nuid: Current.nuid, # the admin (also the User: header)
on_behalf_of_nuid: target_nuid, # the impersonated user
mode: "acting_as" # or "view_as"
)
# …and later ends the session:
AtlasRb::AuditEvent.emit(
action: "impersonation_ended",
actor_nuid: admin_nuid,
on_behalf_of_nuid: target_nuid,
mode: "acting_as"
)
on_behalf_of_nuid, mode, and payload: (free-form metadata) are
omitted from the body when blank, so emit can also carry future,
mode-less session events. Atlas stamps occurred_at server-side.
Authorization errors (401 / 403) surface as raw Faraday responses,
matching Resource.history.
MODS version history
Every descriptive-metadata edit retains the prior MODS XML on the server.
Resource.mods_versions lists the retained versions; Resource.mods_version
fetches the raw MODS XML as of one of them — together enough to render a
line-diff between any two MODS states. Both are type-agnostic (pass any
Community, Collection, or Work ID).
history = AtlasRb::Resource.mods_versions("w-789")
history["resource_id"] # => "w-789"
history["versions"].first["version_id"] # => "v5" (newest first)
history["versions"].first["actor_nuid"] # => "000000002" (or nil)
# Fetch two versions and diff them:
old_xml = AtlasRb::Resource.mods_version("w-789", "v3")
new_xml = AtlasRb::Resource.mods_version("w-789", "v5")
mods_versions returns the full envelope (resource_id + a
reverse-chronological versions array) as an AtlasRb::Mash; each
descriptor mirrors the audit-event shape (version_id, created,
actor_nuid, on_behalf_of_nuid, source, note), so the two streams
render with the same helpers. Actor attribution is correlated from the
audit log and may be null. The endpoint is admin-gated server-side
(it exposes edit attribution).
mods_version returns the raw XML body — like Work.mods, not a Mash.
Only XML is version-recoverable (the JSON access copy is overwritten in
place), so the server serves historical XML; kind: is accepted for parity
with Work.mods but XML is the only retained format.
Version labels are opaque, sortable OCFL vN strings, not a 1-based
counter — a Blob's preservation envelope occupies earlier versions, so the
first MODS version is typically v3. Treat them as identifiers to feed back
into mods_version. A resource with no MODS returns { "versions" => [] };
401 / 403 surface as raw Faraday responses, matching Resource.history.
Re-parenting
reparent moves a resource to a new structural parent, binding Atlas's
PATCH /<type>/:id/parent endpoint. It mirrors create's "single parent
id" shape and returns the updated resource (same shape as find), so the
caller sees the new a_member_of without a follow-up GET. Atlas enforces
the structural rules (type, cycle, tombstone guards) server-side and
synchronously cascades the ancestry index over descendants; rule
violations come back as 422.
AtlasRb::Collection.reparent("col-456", "c-999") # move collection to community c-999
AtlasRb::Work.reparent("w-789", "col-999") # move work to collection col-999
AtlasRb::Community.reparent("c-123", "c-999") # nest community under c-999
AtlasRb::Community.reparent("c-123", nil) # promote community to top of tree
Only Community.reparent accepts a nil destination (move to the top of
the tree) — the same way Community.create(nil) makes a top-level
community. A nil destination for a Work or Collection is rejected by
Atlas.
When Atlas rejects a move, the binding raises a typed error carrying the
machine-readable error code from Atlas's envelope, rather than returning
nil — so callers can message the specific reason:
begin
AtlasRb::Collection.reparent("col-456", "c-999")
rescue AtlasRb::ReparentError => e
e.code # => "cycle" / "invalid_parent_type" / "tombstoned_parent" / …
e. # => human-readable description from Atlas
rescue AtlasRb::ForbiddenError => e
e.code # => the authz failure (HTTP 403)
end
ReparentError (HTTP 422, structural rules: cycle, invalid_parent_type,
tombstoned_node, tombstoned_parent, parent_required,
parent_not_found) and ForbiddenError (HTTP 403, authorization) both
subclass AtlasRb::Error. The 409 optimistic-lock conflict still surfaces
as StaleResourceError, unchanged. Rescue is opt-in — callers that don't
care keep the success payload as before.
Linked members (the DAG overlay)
A Work has exactly one structural parent (a_member_of, set by create /
reparent) but may additionally be a linked member of any number of
other Collections (a_linked_member_of). The linked-member bindings on
Work manage that overlay without ever moving the Work:
AtlasRb::Work.linked_members("w-789") # => ["col-456", "col-457"]
AtlasRb::Work.add_linked_member("w-789", "col-456") # => ["col-456"] (updated list)
AtlasRb::Work.remove_linked_member("w-789", "col-456") # => [] (updated list)
All three return the Work's current linked Collection noids as a bare
array (mirroring Collection.children); the two mutations return the list
after the change, so no follow-up linked_members GET is needed.
Resolving those Collections' full contents is a Cerberus/Solr concern —
this gem never queries the index.
The two mutations raise the same way reparent does — LinkedMemberError
on a structural 422 (carrying the envelope's error code as #code) and
ForbiddenError on a 403 — instead of swallowing the envelope.
Batch resolve (Resource.find_many)
When you have a set of NOIDs and only need each one's title / klass /
thumbnail — breadcrumb chains, linked-member lists, load-destination
pickers — resolve them in one round-trip instead of a find-per-id
fan-out:
nodes = AtlasRb::Resource.find_many(["col-456", "col-457", "missing"])
by_noid = nodes.index_by { |n| n["noid"] }
by_noid["col-456"].title # => "Some Collection"
Each entry is a lightweight digest — { "id", "noid", "klass", "title", "thumbnail", "tombstoned" } — not the full typed payload. The ids travel
in the request body (no URL-length ceiling). The result is unordered
and may be shorter than the input: unresolvable ids are dropped and
tombstoned resources come back flagged ("tombstoned" => true), so index
by "noid" rather than assuming positional correspondence. NOIDs only —
raw Valkyrie ids are not a supported input.
End-to-end example
JSON responses come back as AtlasRb::Mash (a Hashie::Mash subclass), so
you can use dot access — community.id — or string-keyed access —
community["id"] — interchangeably. Both return the same value, so existing
string-keyed callers keep working.
require "atlas_rb"
ENV["ATLAS_URL"] = "https://atlas.example.edu"
# + a configured signing key (relay) or ENV["ATLAS_JWT"] (BYO-JWT)
# 1. Build the org structure (each create can optionally seed MODS metadata).
community = AtlasRb::Community.create(nil, "/tmp/community-mods.xml")
collection = AtlasRb::Collection.create(community.id, "/tmp/coll-mods.xml")
work = AtlasRb::Work.create(collection.id, "/tmp/work-mods.xml")
# 2. Upload a binary attached to the work, preserving the user-facing filename.
blob = AtlasRb::Blob.create(work.id, "/tmp/upload.tmp", "thesis.pdf")
# 3. List everything attached to the work.
AtlasRb::Work.assets(work.id)
# 4. Stream the binary back without buffering it in memory.
File.open("out.pdf", "wb") do |f|
headers = AtlasRb::Blob.content(blob.id) { |chunk| f.write(chunk) }
puts headers["content-type"]
end
# 5. Look up the acting user and their groups.
AtlasRb::Authentication.login("001234567")
AtlasRb::Authentication.groups("001234567")
Generated documentation
Full API reference, including @param / @return / @example for every
method, is generated with YARD:
bundle exec yard doc
open doc/index.html
yard stats --list-undoc should report 100% coverage.
Development
bin/setup # install dependencies
bin/console # IRB with atlas_rb loaded
bundle exec rspec # run tests
bundle exec rubocop # lint
To cut a release, bump the version in .version (which lib/atlas_rb/version.rb
reads at load time) and run bundle exec rake release.
License
MIT — see LICENSE.txt.