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 any nuid: kwarg or default_nuid is ignored on this path.
  • On-Behalf-Of is suppressed — acting-as is a Cerberus-relay-only concept; Atlas rejects it on the JWT path with a 403. Any on_behalf_of: kwarg or default_on_behalf_of is 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 obo claim. An on_behalf_of request is signed with sub = the operator and obo = the target, inside the signature — so the target can't be forged onto a stolen assertion, and no On-Behalf-Of header is sent. Atlas admin-gates the operator and ignores any header obo on this path.
  • ATLAS_JWT still 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.message  # => 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.("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.