atlas_rb 1.8.11 → 1.9.1

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: f6edbe1b05256eaab4d296a11f358bb7c7c3f9eaf439be4d48c41e75cfea4951
4
- data.tar.gz: 8e042786b3c7636f7ad3676b9086a7db4ecfa8efed7ed1e91ac8ec4153dbb320
3
+ metadata.gz: fe879cca7166684164dce3dc7651f22b68c83bd36d4463aaf70d1078147b26f8
4
+ data.tar.gz: ab88520172776ef649214806b30b2153d1804e960672e7c6ed4237a33ce9eb22
5
5
  SHA512:
6
- metadata.gz: 44832489abc704196a42b92d85304e886cc3951e711ea33980c9357cb515a4f3e674d7c75db989b7de5c7fbdc9ed36dcfc51a5eceb1d697371d3971227a7e5fa
7
- data.tar.gz: 45d0ed33989b889a4a2f3ae3c9932b73f85daa3bc7f5ce9e859f7ea304bf97c71195694322e4e78cdcb537e8f50f37cd1b1d1fe6f7c76bc8458f780688e5c143
6
+ metadata.gz: 89879f3e03444288baa4e04004bd83e706ace04652eca337cb85d7e665816d49d4ce2d006a598c3505317bff78106e647b477bcbd77fdd0408eda6c0632fa8ca
7
+ data.tar.gz: de9bf34fe94ff2192733ac5c21040b6b1dc8d1b941270398a8edfa0231d4c41fa5ccc69ba17cb68797b40c1a0c6263b187811200c9de13166543cd4bc9632bd8
data/.version CHANGED
@@ -1 +1 @@
1
- 1.8.11
1
+ 1.9.1
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- atlas_rb (1.8.11)
4
+ atlas_rb (1.9.1)
5
5
  faraday (~> 2.7)
6
6
  faraday-follow_redirects (~> 0.3.0)
7
7
  faraday-multipart (~> 1)
@@ -17,7 +17,14 @@ module AtlasRb
17
17
 
18
18
  # Look up the Atlas user record for an NUID.
19
19
  #
20
+ # A NUID can hold several accounts (a person's staff/student logins). Pass
21
+ # `email:` to act as — and read back — a specific one; it rides as a signed
22
+ # `acct` claim so `GET /user` resolves that account. Omit it and Atlas
23
+ # returns the person's preferred account.
24
+ #
20
25
  # @param nuid [String] the user's Northeastern University ID.
26
+ # @param email [String, nil] optional account email to act as (the `acct`
27
+ # selector); nil resolves the preferred account.
21
28
  # @return [Hash] the user record returned by `GET /user`, including at
22
29
  # minimum `"id"`, `"name"`, and `"groups"`.
23
30
  # @raise [JSON::ParserError] if the response body is not valid JSON
@@ -26,10 +33,8 @@ module AtlasRb
26
33
  # @example
27
34
  # AtlasRb::Authentication.login("001234567")
28
35
  # # => { "id" => 42, "name" => "Jane Doe", "groups" => [...] }
29
- def self.login(nuid)
30
- # JSON.parse(connection({ nuid: nuid }).post('/token')&.body)["token"]
31
- # need hash - id, name, token => ...
32
- AtlasRb::Mash.new(JSON.parse(connection({}, nuid).get('/user')&.body))
36
+ def self.login(nuid, email: nil)
37
+ AtlasRb::Mash.new(JSON.parse(connection({}, nuid, account: email).get('/user')&.body))
33
38
  end
34
39
 
35
40
  # Fetch only the group memberships for an NUID.
@@ -3,8 +3,8 @@
3
3
  module AtlasRb
4
4
  # Holds gem-wide configuration registered via {AtlasRb.configure}.
5
5
  #
6
- # The configuration model is deliberately tiny: two slots, both of which
7
- # accept callables. The gem stays consumer-agnostic — it knows nothing
6
+ # The configuration model is deliberately tiny: a handful of slots, all of
7
+ # which accept callables. The gem stays consumer-agnostic — it knows nothing
8
8
  # about Rails, Devise, or any host application's request lifecycle — and
9
9
  # instead lets the consumer hand it lambdas that resolve the per-request
10
10
  # ambient context when a request is about to go out.
@@ -45,6 +45,15 @@ module AtlasRb
45
45
  # to send no `On-Behalf-Of:` header.
46
46
  attr_accessor :default_on_behalf_of
47
47
 
48
+ # @return [Proc, nil] callable returning the ambient account email when a
49
+ # call is made without an explicit `account:` kwarg. A person's NUID can
50
+ # hold several accounts (staff/student logins); this names which one is
51
+ # acting, signed into the assertion as an `acct` claim (companion to
52
+ # {#default_nuid}). Typically `-> { Current.account_email }` in a Rails
53
+ # host. `nil` (the default) signs no `acct` — Atlas then resolves the
54
+ # person's preferred account.
55
+ attr_accessor :default_account
56
+
48
57
  # Relay signing. When set, the regular relay path *signs* a short-lived
49
58
  # assertion (ES256, `sub` = acting nuid) — identity is proven, not asserted.
50
59
  # This is the relay credential: with no signing key configured (and no
@@ -76,6 +76,10 @@ module AtlasRb
76
76
  # claim (acting-as / view-as). When `nil`, falls through to
77
77
  # `AtlasRb.config.default_on_behalf_of&.call`; if that is also nil, no
78
78
  # `obo` claim is added.
79
+ # @param account [String, nil] optional account email carried as a signed
80
+ # `acct` claim, naming which of the NUID's accounts is acting. When `nil`,
81
+ # falls through to `AtlasRb.config.default_account&.call`; if that is also
82
+ # nil, no `acct` claim is added and Atlas resolves the preferred account.
79
83
  # @param idempotency_key [String, nil] optional UUID to send in the
80
84
  # `Idempotency-Key` header. Used by retry-safe create flows (currently
81
85
  # `POST /works`, `POST /file_sets`, `POST /files`) to deduplicate replays
@@ -92,8 +96,8 @@ module AtlasRb
92
96
  #
93
97
  # @example Fetching a community
94
98
  # AtlasRb::Community.connection({}).get('/communities/abc123')
95
- def connection(params, nuid=nil, on_behalf_of: nil, idempotency_key: nil, auth: :required)
96
- headers = auth_headers(nuid, on_behalf_of, optional: auth == :optional)
99
+ def connection(params, nuid=nil, on_behalf_of: nil, account: nil, idempotency_key: nil, auth: :required)
100
+ headers = auth_headers(nuid, on_behalf_of, account: account, optional: auth == :optional)
97
101
  .merge("Content-Type" => "application/json")
98
102
  headers["Idempotency-Key"] = idempotency_key if idempotency_key
99
103
 
@@ -135,8 +139,8 @@ module AtlasRb
135
139
  # "scan.pdf")
136
140
  # }
137
141
  # AtlasRb::Blob.multipart.post('/files/', payload)
138
- def multipart(nuid=nil, on_behalf_of: nil, idempotency_key: nil)
139
- headers = auth_headers(nuid, on_behalf_of)
142
+ def multipart(nuid=nil, on_behalf_of: nil, account: nil, idempotency_key: nil)
143
+ headers = auth_headers(nuid, on_behalf_of, account: account)
140
144
  headers["Idempotency-Key"] = idempotency_key if idempotency_key
141
145
 
142
146
  Faraday.new(
@@ -192,11 +196,13 @@ module AtlasRb
192
196
  # Used exclusively by classes under {AtlasRb::System}.
193
197
  #
194
198
  # @param params [Hash] query-string / body params.
199
+ # @param on_behalf_of [String, nil] optional NUID sent as a plain (not
200
+ # signed) `On-Behalf-Of` header — this path is already backend-only.
195
201
  # @return [Faraday::Connection] a system-authenticated connection.
196
202
  # @raise [RuntimeError] if the credential is not configured.
197
203
  # @raise [NameError] if `Rails` is not loaded (the gem assumes a
198
204
  # Rails host for system-path calls).
199
- def system_connection(params = {})
205
+ def system_connection(params = {}, on_behalf_of: nil)
200
206
  token = Rails.application.credentials.atlas_system_token ||
201
207
  raise("atlas_rb: Rails.application.credentials.atlas_system_token not configured")
202
208
 
@@ -205,6 +211,7 @@ module AtlasRb
205
211
  "Authorization" => "Bearer #{token}",
206
212
  "User" => "NUID #{AtlasRb::System::NUID}"
207
213
  }
214
+ headers["On-Behalf-Of"] = "NUID #{on_behalf_of}" if on_behalf_of
208
215
 
209
216
  Faraday.new(
210
217
  url: ENV.fetch("ATLAS_URL", nil),
@@ -212,6 +219,10 @@ module AtlasRb
212
219
  headers: headers
213
220
  ) do |f|
214
221
  instrument(f)
222
+ # Narrowly path-scoped (see each class) — a no-op for System::User /
223
+ # System::Token, and gives System::Work the same typed errors as #connection.
224
+ f.use AtlasRb::Middleware::RaiseOnStaleResource
225
+ f.use AtlasRb::Middleware::RaiseOnResourceError
215
226
  f.response :follow_redirects
216
227
  f.adapter Faraday.default_adapter
217
228
  end
@@ -243,14 +254,15 @@ module AtlasRb
243
254
  # is only for endpoints Atlas serves with `require_auth` skipped (`GET
244
255
  # /reset`); every normal endpoint leaves `optional` false so a
245
256
  # misconfiguration fails loudly rather than silently going unauthenticated.
246
- def auth_headers(nuid, on_behalf_of, optional: false)
257
+ def auth_headers(nuid, on_behalf_of, account: nil, optional: false)
247
258
  jwt = ENV.fetch("ATLAS_JWT", nil)
248
259
  return { "Authorization" => "Bearer #{jwt}" } if jwt
249
260
 
250
261
  nuid ||= AtlasRb.config.default_nuid&.call
251
262
  on_behalf_of ||= AtlasRb.config.default_on_behalf_of&.call
263
+ account ||= AtlasRb.config.default_account&.call
252
264
 
253
- headers = signed_relay_headers(nuid, on_behalf_of)
265
+ headers = signed_relay_headers(nuid, on_behalf_of, account)
254
266
  return headers if headers
255
267
  return {} if optional
256
268
 
@@ -262,14 +274,16 @@ module AtlasRb
262
274
  # A signed-assertion Authorization header (sub = acting nuid), or nil when
263
275
  # signing isn't configured or there is no acting nuid to put in `sub`.
264
276
  # Acting-as is carried IN the assertion as a signed `obo` claim (Atlas
265
- # honours it on the assertion path; a header obo is ignored there).
266
- def signed_relay_headers(nuid, on_behalf_of)
277
+ # honours it on the assertion path; a header obo is ignored there). When a
278
+ # NUID holds several accounts, `account` (email) rides as a signed `acct`
279
+ # claim naming which one is acting; absent, Atlas picks the preferred.
280
+ def signed_relay_headers(nuid, on_behalf_of, account = nil)
267
281
  return nil unless nuid
268
282
 
269
283
  key = assertion_signing_key
270
284
  return nil unless key
271
285
 
272
- { "Authorization" => "Bearer #{signed_assertion(nuid.to_s, key, on_behalf_of)}" }
286
+ { "Authorization" => "Bearer #{signed_assertion(nuid.to_s, key, on_behalf_of, account)}" }
273
287
  end
274
288
 
275
289
  # Mint a Cerberus relay assertion for `nuid`, signed ES256 with `key`. The
@@ -278,11 +292,12 @@ module AtlasRb
278
292
  # for an Atlas-side one-time cache. When `on_behalf_of` is given, it rides as
279
293
  # a SIGNED `obo` claim — acting-as that can't be forged onto a stolen
280
294
  # assertion (Atlas admin-gates the operator and ignores any header obo here).
281
- def signed_assertion(nuid, key, on_behalf_of = nil)
295
+ def signed_assertion(nuid, key, on_behalf_of = nil, account = nil)
282
296
  now = Time.now.to_i
283
297
  payload = { "iss" => ASSERTION_ISSUER, "aud" => ASSERTION_AUDIENCE, "sub" => nuid,
284
298
  "iat" => now, "exp" => now + ASSERTION_TTL, "jti" => SecureRandom.uuid,
285
- "obo" => on_behalf_of&.to_s }.compact # obo only when acting-as
299
+ "obo" => on_behalf_of&.to_s, # obo only when acting-as
300
+ "acct" => account&.to_s }.compact # acct only when a specific account is named
286
301
  JWT.encode(payload, key, "ES256", { kid: assertion_signing_kid })
287
302
  end
288
303
 
@@ -27,9 +27,10 @@ module AtlasRb
27
27
  NUID = "000000000"
28
28
 
29
29
  # SSO-callback user provisioning. Finds the Atlas `User` row keyed on
30
- # the supplied NUID (creating it if missing) and replaces its
31
- # `groups` with the IdP-asserted set. Full replace, not merge —
32
- # the IdP assertion is authoritative.
30
+ # the supplied email — the account key — (creating it if missing) and
31
+ # replaces its `groups` with the IdP-asserted set. Full replace, not
32
+ # merge — the IdP assertion is authoritative. Keying on email keeps a
33
+ # person's staff/student logins as distinct accounts under one NUID.
33
34
  #
34
35
  # Always authenticates via {FaradayHelper#system_connection}, so the
35
36
  # caller has no way to act as a non-system principal. Atlas allows
@@ -37,32 +38,40 @@ module AtlasRb
37
38
  class User
38
39
  extend AtlasRb::FaradayHelper
39
40
 
40
- # Find-or-create the User keyed on NUID and replace its groups.
41
+ # Find-or-create the User keyed on email and replace its groups.
41
42
  #
42
- # @param nuid [String] the NUID of the user being provisioned.
43
- # This is the *subject* of the operation, not the actor — the
44
- # actor is always the system fixture.
43
+ # Email is the account key: a person's staff and student logins share a
44
+ # NUID but present a different email each, so keying on email keeps them
45
+ # as distinct accounts instead of collapsing (last-write-wins) on NUID.
46
+ #
47
+ # @param email [String] the account key — the login's `mail`.
48
+ # This is the *subject* of the operation, not the actor; the actor is
49
+ # always the system fixture.
50
+ # @param nuid [String, nil] the person's NUID (the grouping thread that
51
+ # ties a person's accounts together). Forwarded when available.
45
52
  # @param groups [Array<String>] full group set; replaces, not merges.
46
53
  # @param name [String, nil] forwarded if the SSO callback has it;
47
54
  # Atlas treats this field as optional.
48
- # @param email [String, nil] forwarded if available; optional in
49
- # Atlas.
55
+ # @param affiliation [String, nil] the login's unscoped-affiliation, a
56
+ # human label for the account (e.g. "staff"/"student"); optional.
50
57
  # @return [AtlasRb::Mash] the resulting User record (`id`, `nuid`,
51
- # `name`, `email`, `role`, `groups`).
58
+ # `name`, `email`, `role`, `groups`, `affiliation`, `preferred`).
52
59
  #
53
60
  # @example From Cerberus's SSO callback
54
61
  # AtlasRb::System::User.find_or_create(
62
+ # email: "j.doe@northeastern.edu",
55
63
  # nuid: "001234567",
56
64
  # groups: ["northeastern:staff", "drs:editors"],
57
65
  # name: "Jane Doe",
58
- # email: "j.doe@example.edu"
66
+ # affiliation: "staff"
59
67
  # )
60
- def self.find_or_create(nuid:, groups:, name: nil, email: nil)
68
+ def self.find_or_create(email:, nuid: nil, groups: [], name: nil, affiliation: nil)
61
69
  body = { groups: groups }
62
- body[:name] = name if name
63
- body[:email] = email if email
70
+ body[:nuid] = nuid if nuid
71
+ body[:name] = name if name
72
+ body[:affiliation] = affiliation if affiliation
64
73
 
65
- response = system_connection.put("/users/by_nuid/#{nuid}", body.to_json)
74
+ response = system_connection.put("/users/by_email/#{email}", body.to_json)
66
75
  AtlasRb::Mash.new(JSON.parse(response.body))["user"]
67
76
  end
68
77
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtlasRb
4
+ module System
5
+ # Showcase publishing: link a freshly-created Work into a depositor's
6
+ # featured showcase Collection on their behalf, without granting the
7
+ # depositor themselves standing edit rights on that shared Collection.
8
+ #
9
+ # Atlas scopes this narrowly on both sides (see Atlas's `Ability`): the
10
+ # target Collection must be `featured`, and the Work must belong to the
11
+ # `on_behalf_of` NUID — never an arbitrary or private Work. Cerberus's
12
+ # `WorkDeposit#create_published` ("Publish to my community") is the one
13
+ # caller today.
14
+ #
15
+ # Always authenticates via {FaradayHelper#system_connection}, so there is
16
+ # no way to issue this as a regular user.
17
+ class Work
18
+ extend AtlasRb::FaradayHelper
19
+
20
+ # @param work_id [String] the Work ID to link.
21
+ # @param collection_id [String] the (must-be-featured) showcase
22
+ # Collection to link the Work into.
23
+ # @param on_behalf_of [String] the depositor NUID this write is
24
+ # attributed to — required (unlike the regular, human-facing
25
+ # {AtlasRb::Work.add_linked_member}), since a :system call with no
26
+ # attribution defeats the point of this path, and Atlas's Ability
27
+ # grant is itself conditioned on it matching the Work's depositor.
28
+ # @return [Array<String>] the Work's full set of linked Collection
29
+ # noids after the add.
30
+ # @raise [AtlasRb::StaleResourceError] optimistic-lock conflict.
31
+ # @raise [AtlasRb::LinkedMemberError] structural rejection (HTTP 422) —
32
+ # non-Collection target, tombstoned work/target, already a structural
33
+ # member.
34
+ # @raise [AtlasRb::ForbiddenError] Atlas refused the link (HTTP 403) —
35
+ # e.g. the target Collection isn't featured, or on_behalf_of doesn't
36
+ # own the Work.
37
+ #
38
+ # @example From Cerberus's publish-to-showcase deposit branch
39
+ # AtlasRb::System::Work.add_linked_member(work.id, showcase.id, on_behalf_of: depositor_nuid)
40
+ def self.add_linked_member(work_id, collection_id, on_behalf_of:)
41
+ JSON.parse(
42
+ system_connection({ collection_id: collection_id }, on_behalf_of: on_behalf_of)
43
+ .post("/works/#{work_id}/linked_members")&.body
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
data/lib/atlas_rb/user.rb CHANGED
@@ -1,22 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtlasRb
4
- # Read-only user directory lookups — typeahead search and NUID → name
5
- # resolution.
4
+ # User directory lookups (typeahead search, NUID → name resolution) plus a
5
+ # person's own account management — enumerating the accounts that share their
6
+ # NUID and choosing a preferred (default) one.
6
7
  #
7
8
  # This is a **user-context** binding: calls authenticate as the acting
8
9
  # user via the standard relay-signing path (the acting NUID is signed into
9
10
  # the assertion `sub`), like every other top-level class. It is
10
11
  # deliberately *not* part of
11
12
  # {AtlasRb::System} — that namespace is structurally reserved for
12
- # system-token calls ({System::User.find_or_create}), and directory
13
- # lookups are an ordinary logged-in-user capability.
13
+ # system-token calls ({System::User.find_or_create}), and these are ordinary
14
+ # logged-in-user capabilities.
14
15
  #
15
- # Atlas enforces minimal disclosure: every entry carries `nuid` + `name`
16
- # only (no email, role, or groups), and rows with role `anonymous`,
17
- # `guest`, or `system` are never returned. Per the layering principle the
18
- # gem adds nothing on top — no caching, no name parsing, no result
19
- # shaping; presentation belongs to the host application.
16
+ # The directory lookups enforce minimal disclosure: every entry carries
17
+ # `nuid` + `name` only (no email, role, or groups), and rows with role
18
+ # `anonymous`, `guest`, or `system` are never returned. The account methods
19
+ # ({.accounts} / {.set_preferred}) *do* disclose email/groups/affiliation, so
20
+ # Atlas limits them to the person themselves (matching NUID), an admin, or the
21
+ # system principal. Per the layering principle the gem adds nothing on top —
22
+ # no caching, no name parsing, no result shaping; presentation belongs to the
23
+ # host application.
20
24
  class User
21
25
  extend AtlasRb::FaradayHelper
22
26
 
@@ -88,5 +92,47 @@ module AtlasRb
88
92
  connection({ nuids: Array(nuids).join(",") }, nuid).get(ROUTE)&.body
89
93
  ).map { |entry| AtlasRb::Mash.new(entry) }
90
94
  end
95
+
96
+ # List the accounts sharing a NUID — a person's staff/student logins.
97
+ #
98
+ # Unlike the directory lookups this discloses each account's email,
99
+ # affiliation label, role, stored group set, and which one is preferred —
100
+ # the data the login "you have more than one account" check and the My DRS
101
+ # accounts panel render from. Atlas limits it to the person themselves, an
102
+ # admin, or the system principal; others get a `403`.
103
+ #
104
+ # @param target_nuid [String] the NUID whose accounts to list — the
105
+ # *subject*, distinct from the acting `nuid:` kwarg.
106
+ # @param nuid [String, nil] optional acting user's NUID (signed into the
107
+ # assertion `sub`); on the BYO-JWT path identity lives in the token.
108
+ # @return [AtlasRb::Mash] the envelope: `nuid` plus an `accounts` array,
109
+ # each entry carrying `email`, `name`, `affiliation`, `role`, `groups`,
110
+ # and `preferred`.
111
+ #
112
+ # @example Login-time multi-account detection
113
+ # AtlasRb::User.accounts("001234567", nuid: "001234567")["accounts"].size # => 2
114
+ def self.accounts(target_nuid, nuid: nil)
115
+ AtlasRb::Mash.new(JSON.parse(
116
+ connection({}, nuid).get("#{ROUTE}/by_nuid/#{target_nuid}/accounts")&.body
117
+ ))
118
+ end
119
+
120
+ # Set the preferred (default) account for a NUID — the account chosen when
121
+ # a login names no specific one. Same self/admin/system scope as {.accounts}.
122
+ #
123
+ # @param target_nuid [String] the NUID whose default is being set.
124
+ # @param email [String] the account to make preferred; must be one of the
125
+ # NUID's accounts (else Atlas returns `404`).
126
+ # @param nuid [String, nil] optional acting user's NUID (signed into `sub`).
127
+ # @return [AtlasRb::Mash] the updated account record under `"user"`.
128
+ #
129
+ # @example
130
+ # AtlasRb::User.set_preferred("001234567", email: "j.doe@husky.neu.edu",
131
+ # nuid: "001234567")
132
+ def self.set_preferred(target_nuid, email:, nuid: nil)
133
+ response = connection({}, nuid)
134
+ .put("#{ROUTE}/by_nuid/#{target_nuid}/preferred_account", { email: email }.to_json)
135
+ AtlasRb::Mash.new(JSON.parse(response.body))["user"]
136
+ end
91
137
  end
92
138
  end
data/lib/atlas_rb.rb CHANGED
@@ -31,6 +31,7 @@ require_relative "atlas_rb/admin/community"
31
31
  require_relative "atlas_rb/system"
32
32
  require_relative "atlas_rb/system/user"
33
33
  require_relative "atlas_rb/system/token"
34
+ require_relative "atlas_rb/system/work"
34
35
  require_relative "atlas_rb/audit_event"
35
36
 
36
37
  # Ruby client for the Atlas API — Northeastern University's institutional
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.8.11
4
+ version: 1.9.1
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-08 00:00:00.000000000 Z
11
+ date: 2026-07-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -152,6 +152,7 @@ files:
152
152
  - lib/atlas_rb/system.rb
153
153
  - lib/atlas_rb/system/token.rb
154
154
  - lib/atlas_rb/system/user.rb
155
+ - lib/atlas_rb/system/work.rb
155
156
  - lib/atlas_rb/user.rb
156
157
  - lib/atlas_rb/version.rb
157
158
  - lib/atlas_rb/work.rb