atlas_rb 1.8.11 → 1.9.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: f6edbe1b05256eaab4d296a11f358bb7c7c3f9eaf439be4d48c41e75cfea4951
4
- data.tar.gz: 8e042786b3c7636f7ad3676b9086a7db4ecfa8efed7ed1e91ac8ec4153dbb320
3
+ metadata.gz: 9bfa2bcb90b5d30ebbf8a4a2612b1a6aafab5fc4eb07df8b12c3ba904ab1fb48
4
+ data.tar.gz: 9afb80d4f4cecfdfa5204ad101967fe4360057a2222ca573153e00bfc3a3e281
5
5
  SHA512:
6
- metadata.gz: 44832489abc704196a42b92d85304e886cc3951e711ea33980c9357cb515a4f3e674d7c75db989b7de5c7fbdc9ed36dcfc51a5eceb1d697371d3971227a7e5fa
7
- data.tar.gz: 45d0ed33989b889a4a2f3ae3c9932b73f85daa3bc7f5ce9e859f7ea304bf97c71195694322e4e78cdcb537e8f50f37cd1b1d1fe6f7c76bc8458f780688e5c143
6
+ metadata.gz: 2dbd56463086ce26fc3380eaee4e850dfe6c66a15d6e716c22271d8a13230fbb9db122195f368b41dad8edf96eb153540c699c567f0d43b50b96bc95e8d5c1b7
7
+ data.tar.gz: cfba3517bd027ae9ff972b3316da4a3bd941e28a080961a69d16625b874bd6b6f5ececd503327d5f0b9ebc56ffc118bd36c04823e2a94ba07aa45a4179fcdc4b
data/.version CHANGED
@@ -1 +1 @@
1
- 1.8.11
1
+ 1.9.0
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.0)
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(
@@ -243,14 +247,15 @@ module AtlasRb
243
247
  # is only for endpoints Atlas serves with `require_auth` skipped (`GET
244
248
  # /reset`); every normal endpoint leaves `optional` false so a
245
249
  # misconfiguration fails loudly rather than silently going unauthenticated.
246
- def auth_headers(nuid, on_behalf_of, optional: false)
250
+ def auth_headers(nuid, on_behalf_of, account: nil, optional: false)
247
251
  jwt = ENV.fetch("ATLAS_JWT", nil)
248
252
  return { "Authorization" => "Bearer #{jwt}" } if jwt
249
253
 
250
254
  nuid ||= AtlasRb.config.default_nuid&.call
251
255
  on_behalf_of ||= AtlasRb.config.default_on_behalf_of&.call
256
+ account ||= AtlasRb.config.default_account&.call
252
257
 
253
- headers = signed_relay_headers(nuid, on_behalf_of)
258
+ headers = signed_relay_headers(nuid, on_behalf_of, account)
254
259
  return headers if headers
255
260
  return {} if optional
256
261
 
@@ -262,14 +267,16 @@ module AtlasRb
262
267
  # A signed-assertion Authorization header (sub = acting nuid), or nil when
263
268
  # signing isn't configured or there is no acting nuid to put in `sub`.
264
269
  # 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)
270
+ # honours it on the assertion path; a header obo is ignored there). When a
271
+ # NUID holds several accounts, `account` (email) rides as a signed `acct`
272
+ # claim naming which one is acting; absent, Atlas picks the preferred.
273
+ def signed_relay_headers(nuid, on_behalf_of, account = nil)
267
274
  return nil unless nuid
268
275
 
269
276
  key = assertion_signing_key
270
277
  return nil unless key
271
278
 
272
- { "Authorization" => "Bearer #{signed_assertion(nuid.to_s, key, on_behalf_of)}" }
279
+ { "Authorization" => "Bearer #{signed_assertion(nuid.to_s, key, on_behalf_of, account)}" }
273
280
  end
274
281
 
275
282
  # Mint a Cerberus relay assertion for `nuid`, signed ES256 with `key`. The
@@ -278,11 +285,12 @@ module AtlasRb
278
285
  # for an Atlas-side one-time cache. When `on_behalf_of` is given, it rides as
279
286
  # a SIGNED `obo` claim — acting-as that can't be forged onto a stolen
280
287
  # assertion (Atlas admin-gates the operator and ignores any header obo here).
281
- def signed_assertion(nuid, key, on_behalf_of = nil)
288
+ def signed_assertion(nuid, key, on_behalf_of = nil, account = nil)
282
289
  now = Time.now.to_i
283
290
  payload = { "iss" => ASSERTION_ISSUER, "aud" => ASSERTION_AUDIENCE, "sub" => nuid,
284
291
  "iat" => now, "exp" => now + ASSERTION_TTL, "jti" => SecureRandom.uuid,
285
- "obo" => on_behalf_of&.to_s }.compact # obo only when acting-as
292
+ "obo" => on_behalf_of&.to_s, # obo only when acting-as
293
+ "acct" => account&.to_s }.compact # acct only when a specific account is named
286
294
  JWT.encode(payload, key, "ES256", { kid: assertion_signing_kid })
287
295
  end
288
296
 
@@ -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
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
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.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-07-08 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday