belt 0.3.44 → 0.4.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: 7977614906b8f7154ca9e00597f1eb5744bc00ac6814985cfda98a16687d5992
4
- data.tar.gz: d60ca2ff84dec2ce5d8b6ee97be3f9ef189fc455881e30b4877a37ae3dafef36
3
+ metadata.gz: 846db7f1373b5bd98f3e795404b1f157825f5dd94541bd46aa76e3510ba89adf
4
+ data.tar.gz: 251135fea6ee2d3d55e7dcdf46b98460ddc3df50fa1e4113b561f721755c935d
5
5
  SHA512:
6
- metadata.gz: b8cf0deed02541a8775b7f4000ba8f5336d09572d6752520bd5191407fd40b97412d5d8c2ee19790b6e8445231be9ffedbd09688c0233b642a96d2ba4494a38c
7
- data.tar.gz: 025eb3373be5e8c345b29525fd6f81c2e21581b2b044d66dab03482b838e7e491556e6669b099cd128c0b5e5965b29a1ea741761c741b8dee5c489c7f587c34d
6
+ metadata.gz: 60b0ecf81e375ea1c219c84ccf7f90d568437cc81507c708ec56a40f903178e0611e5b6be03e1e71185ce66263d1ec0c12ddee8e1a4d2cfdc26f99dba4828c48
7
+ data.tar.gz: fb3fa14275a68c7e79892504d0fef2cbe86727225033a7d7bc3c1272b963413b5ed7259c3a5c2f1e202770d16b0234ff242571e4d2b7abed50a040090da250e1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,116 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ### Bug Fix
6
+
7
+ - **Apply environment AWS profile in `belt deploy frontend`, `belt frontend env`, `belt logs`, and `belt server`**:
8
+ Standalone frontend deployment (`belt deploy frontend <env>`), frontend env generation (`belt frontend env <env>`),
9
+ log viewing (`belt logs`), and the local dev server (`belt server`) now load `infrastructure/<env>/belt.rb`
10
+ and apply its configured `aws_profile` and environment variables. Previously, running `belt deploy frontend <env>`
11
+ directly would query Terraform outputs without the environment's AWS profile, causing a 403 against remote S3
12
+ state backends and aborting with `Error: Could not determine S3 bucket. Run belt apply <env> first.`
13
+
14
+ ## 0.4.1
15
+
16
+ ### Feature
17
+
18
+ - **`belt dns doctor`**: New diagnostic command that checks DNS health across all
19
+ environments. Shows zone status, NS delegation, ACM certificate state, and
20
+ API Gateway custom domain configuration. Use `--env prod` to check a specific
21
+ environment only.
22
+
23
+ ```bash
24
+ belt dns doctor
25
+ belt dns doctor --env prod
26
+ ```
27
+
28
+ - **`belt dns sync-validation`**: New command to sync ACM validation CNAMEs from
29
+ an environment's zone to the root zone. Needed for apex domains (e.g., prod →
30
+ `example.com`) where the root zone is authoritative for the apex domain.
31
+
32
+ ```bash
33
+ belt dns sync-validation prod
34
+ ```
35
+
36
+ - **Auto-sync ACM validation for apex environments**: `belt deploy prod` now
37
+ automatically syncs ACM validation CNAMEs to the root zone when deploying
38
+ environments that use the apex domain. No manual intervention needed — the
39
+ "prod is special" logic is handled by Belt internally.
40
+
41
+ This fixes the issue where prod ACM certificates would timeout waiting for
42
+ validation because the validation CNAMEs were created in the prod zone, but
43
+ ACM validates against the authoritative zone (the root zone managed by
44
+ `infrastructure/dns`).
45
+
46
+ ### Bug Fix
47
+
48
+ - **Fix misleading nested environment domain announcement**: `belt generate
49
+ environment <name> <parent>` printed `Domain will be:
50
+ api.<env>.<parent>.<domain>`, but the deployed infrastructure actually uses
51
+ the single-level `api-<env>.<parent>.<domain>` form (the `api-` prefix keeps
52
+ the host under the parent's `*.<parent>.<domain>` wildcard cert). The message
53
+ now matches the real deployed domain. Infrastructure was already correct —
54
+ only the CLI output was wrong.
55
+
56
+ ## 0.4.0
57
+
58
+ ### New Features
59
+
60
+ - **`cognito_authenticatable` — Cognito identity in one line.** Everything an app used
61
+ to hand-write to turn a JWT into a user record now lives in the gem, Devise-style:
62
+
63
+ ```ruby
64
+ class User < ApplicationRecord
65
+ cognito_authenticatable
66
+ end
67
+ ```
68
+
69
+ That supplies the Cognito `sub` as primary key, the identity attributes
70
+ (`email`, `name`, `role`, `email_verified`, `last_seen_on`), an `EmailIndex` GSI,
71
+ `.sync_from_claims!` / `.for_sub` / `.for_email`, and `#admin?`. The app's model is
72
+ left holding only the app's own domain.
73
+
74
+ Controllers get `current_user`, `user_signed_in?`, `authenticate_user!`, and
75
+ `cognito_admin?` with no `include` and no configuration — `BeltController::Base`
76
+ mixes them in. Both token shapes are handled (a pre-verified API Gateway authorizer
77
+ claim set, or a raw `Authorization: Bearer` ID token the Lambda decodes and checks
78
+ for expiry, issuer, and `token_use`).
79
+
80
+ Rows are provisioned just-in-time on the first authenticated request and refreshed
81
+ only on drift, so an unchanged user costs one `GetItem` and no write. Hook your own
82
+ behaviour off that moment with `#after_cognito_sync`.
83
+
84
+ Options: `roles:`, `default_role:`, `email_index:`. Configure with
85
+ `Belt.configure { |c| c.authentication.user_class = 'Account' }`. Full docs:
86
+ `belt explain authentication`.
87
+
88
+ - **`belt generate auth` scaffolds the user model.** It writes `lambda/models/user.rb`
89
+ (never overwriting an existing one) and regenerates `dynamodb.tf` so the `users`
90
+ table and its `EmailIndex` exist. `belt setup tables` now recognizes the macro, so a
91
+ model that declares `cognito_authenticatable` gets its GSI without an explicit
92
+ `indexes()` call.
93
+
94
+ ### Bug Fix
95
+
96
+ - **`belt setup tables` respected `belongs_to ..., index: false`.** It didn't. ActiveItem
97
+ skips registering an association index when told to, but the table generator created
98
+ the convention GSI anyway — on a `fooId` attribute the model never writes, so the
99
+ index silently indexed nothing while costing storage. It now skips those declarations.
100
+ Regenerating `dynamodb.tf` in a project that uses `index: false` will therefore drop
101
+ the dead GSIs; that's a real (if harmless) Terraform diff, so look before you apply.
102
+
103
+ ### Internal
104
+
105
+ - `Belt::AuthenticationError` and friends moved to `lib/belt/errors.rb` so they can be
106
+ required without pulling in the whole gem. No API change.
107
+
108
+ ### Upgrading
109
+
110
+ - Upgrade is additive — nothing breaks by bumping to 0.4.0. To adopt
111
+ `cognito_authenticatable` in an existing app (and for the one `index: false` diff to
112
+ watch even if you don't), see [UPGRADING.md](UPGRADING.md).
113
+
3
114
  ## 0.3.43
4
115
 
5
116
  ### Bug Fix
data/README.md CHANGED
@@ -256,6 +256,46 @@ end
256
256
  - **Scope in nested context**: Group routes with shared path prefix, controller, tables, or auth
257
257
  - **Singular `resource`**: For nested resources without an `:id` (billing, profile, etc.)
258
258
 
259
+ ## Authentication
260
+
261
+ Cognito owns authentication. Belt owns the record of the human it authenticated:
262
+
263
+ ```ruby
264
+ class User < ApplicationRecord
265
+ cognito_authenticatable
266
+ end
267
+ ```
268
+
269
+ One line supplies the Cognito `sub` as primary key, the identity attributes
270
+ (`email`, `name`, `role`, `email_verified`, `last_seen_on`), an `EmailIndex` GSI,
271
+ just-in-time provisioning from a token, and `#admin?` for platform staff. Your model is
272
+ left holding only your own domain.
273
+
274
+ Controllers get it for free — no `include`, no configuration:
275
+
276
+ ```ruby
277
+ class ProfilesController < ApplicationController
278
+ before_action :authenticate_user!
279
+
280
+ def show
281
+ @profile = current_user
282
+ end
283
+ end
284
+ ```
285
+
286
+ | | |
287
+ |---|---|
288
+ | `current_user` | the user record, or nil. Memoized per request |
289
+ | `user_signed_in?` | is there a Cognito identity on this request? |
290
+ | `authenticate_user!` | `before_action` guard → 401 |
291
+ | `cognito_admin?` | does the token carry a staff Cognito group? |
292
+
293
+ `belt generate auth` creates the user pool *and* scaffolds the model and its table.
294
+ Full details — configuration, the `after_cognito_sync` hook, platform staff, and how
295
+ both token shapes are handled — in `belt explain authentication`.
296
+
297
+ Upgrading an existing app to Cognito auth? See [UPGRADING.md](UPGRADING.md).
298
+
259
299
  ## BeltController Features
260
300
 
261
301
  ### Callbacks
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+
6
+ module Belt
7
+ module Authentication
8
+ # Reads Cognito claims off a Lambda event.
9
+ #
10
+ # Two shapes have to be handled, because a route can be authenticated either way:
11
+ #
12
+ # 1. API Gateway Cognito authorizer — claims arrive pre-verified under
13
+ # requestContext.authorizer.claims, with every value flattened to a String.
14
+ # 2. A raw `Authorization: Bearer <id token>` header — the Lambda decodes it.
15
+ #
16
+ # Case 2 is signature-unverified by design: when a Cognito authorizer is attached,
17
+ # the gateway has already checked the signature and an unsigned token never reaches
18
+ # us. What we can still check cheaply is checked (structure, expiry, issuer, token
19
+ # use), which is what stops an expired or foreign token from being waved through on
20
+ # routes that read the header directly.
21
+ module Claims
22
+ # Cognito sends `cognito:groups` as a JSON array in the raw token, but API
23
+ # Gateway flattens it to a string — "[admins, members]" or "admins,members".
24
+ GROUPS_CLAIM = 'cognito:groups'
25
+
26
+ TRUTHY = [true, 'true', 1, '1'].freeze
27
+
28
+ class << self
29
+ # Claims for this request, or nil if there is no usable Cognito identity.
30
+ def from_event(event, issuer: nil)
31
+ authorizer = event.dig('requestContext', 'authorizer', 'claims')
32
+ return authorizer if authorizer
33
+
34
+ token = bearer_token(event)
35
+ return nil unless token
36
+
37
+ decode(token, issuer: issuer)
38
+ end
39
+
40
+ # The raw Bearer credential, whatever it is. Callers that only want Cognito
41
+ # tokens rely on #decode rejecting anything that isn't one — an app's own API
42
+ # key scheme can share the header without special-casing here.
43
+ def bearer_token(event)
44
+ header = authorization_header(event)
45
+ return nil unless header&.start_with?('Bearer ')
46
+
47
+ token = header.sub('Bearer ', '').strip
48
+ token.empty? ? nil : token
49
+ end
50
+
51
+ def authorization_header(event)
52
+ headers = event['headers'] || {}
53
+ headers['Authorization'] || headers['authorization']
54
+ end
55
+
56
+ # Decode a Cognito ID token's payload, rejecting it unless it is one.
57
+ # Returns nil rather than raising: a bad token means "no identity", and every
58
+ # caller here is deciding whether an identity exists.
59
+ def decode(token, issuer: nil)
60
+ payload = payload_segment(token)
61
+ return nil unless payload
62
+
63
+ claims = JSON.parse(payload)
64
+ return nil unless claims.is_a?(Hash)
65
+ return nil if expired?(claims)
66
+ return nil unless issuer_matches?(claims, issuer)
67
+ return nil unless id_token?(claims)
68
+
69
+ claims
70
+ rescue ArgumentError, JSON::ParserError
71
+ nil
72
+ end
73
+
74
+ def groups(claims)
75
+ parse_groups(claims && claims[GROUPS_CLAIM])
76
+ end
77
+
78
+ def parse_groups(raw)
79
+ return [] if raw.nil?
80
+ return raw.map(&:to_s) if raw.is_a?(Array)
81
+
82
+ # Strip only the wrapping brackets Cognito uses for the space/comma-separated
83
+ # group list (e.g. "[admins, editors]"), not brackets anywhere in the string.
84
+ raw.to_s.sub(/\A\[/, '').sub(/\]\z/, '').split(',').map(&:strip).reject(&:empty?)
85
+ end
86
+
87
+ # Booleans arrive as JSON booleans from a decoded token and as strings from
88
+ # API Gateway authorizer claims.
89
+ def truthy?(value)
90
+ TRUTHY.include?(value)
91
+ end
92
+
93
+ private
94
+
95
+ def payload_segment(token)
96
+ parts = token.to_s.split('.')
97
+ return nil unless parts.length == 3
98
+
99
+ segment = parts[1]
100
+ segment += '=' * (4 - (segment.length % 4)) if (segment.length % 4) != 0
101
+ Base64.urlsafe_decode64(segment)
102
+ end
103
+
104
+ def expired?(claims)
105
+ exp = claims['exp']
106
+ !exp.nil? && Time.now.to_i > exp.to_i
107
+ end
108
+
109
+ def issuer_matches?(claims, issuer)
110
+ return true if issuer.nil?
111
+
112
+ claims['iss'] == issuer
113
+ end
114
+
115
+ # Belt authenticates with ID tokens; an access token carries no email/name and
116
+ # must not stand in for one.
117
+ def id_token?(claims)
118
+ use = claims['token_use']
119
+ use.nil? || use == 'id'
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Authentication
5
+ module CognitoAuthenticatable
6
+ # Class-side of cognito_authenticatable: the schema knobs the macro sets, and the
7
+ # lookups that turn a token into a record.
8
+ module ClassMethods
9
+ attr_writer :cognito_roles, :cognito_default_role, :cognito_email_index
10
+
11
+ # Allowed values for `role`. Set by the macro's `roles:` option.
12
+ def cognito_roles
13
+ @cognito_roles || ROLES
14
+ end
15
+
16
+ # Role given to a newly provisioned user.
17
+ def cognito_default_role
18
+ @cognito_default_role || DEFAULT_ROLE
19
+ end
20
+
21
+ # GSI used for email lookups, or false if the app doesn't need them.
22
+ def cognito_email_index
23
+ return @cognito_email_index if defined?(@cognito_email_index)
24
+
25
+ DEFAULT_EMAIL_INDEX
26
+ end
27
+
28
+ def cognito_email_index_definition
29
+ index = cognito_email_index
30
+ return {} unless index
31
+
32
+ { index.to_s => { partition_key: ATTRIBUTE_MAP['email'] } }
33
+ end
34
+
35
+ # Look up by Cognito sub. Returns nil rather than raising — callers are asking
36
+ # whether the user exists yet, not asserting that they do.
37
+ def for_sub(sub)
38
+ id = sub.to_s
39
+ return nil if id.empty?
40
+
41
+ find(id)
42
+ rescue ActiveItem::RecordNotFound
43
+ nil
44
+ end
45
+
46
+ def for_email(email)
47
+ normalized = normalize_cognito_email(email)
48
+ return nil if normalized.empty?
49
+
50
+ index = cognito_email_index
51
+ index ? find_by(email: normalized, index: index.to_s) : find_by(email: normalized)
52
+ end
53
+
54
+ # Provision-or-refresh the row for the identity on this request.
55
+ #
56
+ # Called once per authenticated request, so it is deliberately write-averse: an
57
+ # unchanged user costs one GetItem and no write. Only real drift (name changed
58
+ # in Cognito, staff group granted or revoked, first sighting today) writes.
59
+ #
60
+ # @param sub [String] Cognito `sub` — becomes the primary key.
61
+ # @param admin [Boolean] Whether the token carries a staff Cognito group.
62
+ # Cognito stays the source of truth, `role` only mirrors it, and passing
63
+ # false demotes — so removal from the group takes effect on the next request.
64
+ # @return [self, nil] nil when there is no usable sub.
65
+ def sync_from_claims!(sub:, email: nil, name: nil, admin: false, email_verified: false)
66
+ id = sub.to_s
67
+ return nil if id.empty?
68
+
69
+ existing = for_sub(id)
70
+ attrs = cognito_claim_attributes(
71
+ email: email, name: name, admin: admin, email_verified: email_verified
72
+ )
73
+
74
+ user = existing ? refresh_from_cognito(existing, attrs) : provision_from_cognito(id, attrs)
75
+ user&.after_cognito_sync
76
+ user
77
+ end
78
+
79
+ def normalize_cognito_email(email)
80
+ email.to_s.strip.downcase
81
+ end
82
+
83
+ private
84
+
85
+ def cognito_claim_attributes(email:, name:, admin:, email_verified:)
86
+ normalized_email = normalize_cognito_email(email)
87
+
88
+ {
89
+ email: normalized_email.empty? ? nil : normalized_email,
90
+ name: cognito_display_name(name, normalized_email),
91
+ role: admin ? ADMIN_ROLE : cognito_default_role,
92
+ email_verified: Claims.truthy?(email_verified),
93
+ last_seen_on: Time.now.utc.strftime('%Y-%m-%d')
94
+ }.compact
95
+ end
96
+
97
+ # The `name` claim is optional — Cognito's hosted signup UI doesn't collect it
98
+ # unless configured — so fall back to the email local part. Better than a blank
99
+ # row in an admin screen.
100
+ def cognito_display_name(name, normalized_email)
101
+ given = name.to_s.strip
102
+ return given unless given.empty?
103
+ return nil if normalized_email.empty?
104
+
105
+ normalized_email.split('@').first
106
+ end
107
+
108
+ def provision_from_cognito(id, attrs)
109
+ create!(attrs.merge(id: id))
110
+ rescue ActiveItem::RecordInvalid
111
+ # Two concurrent first requests from the same new user: DynamoDB's
112
+ # attribute_not_exists condition rejects the loser, surfacing as an
113
+ # "already exists" validation error. The row we wanted now exists.
114
+ for_sub(id)
115
+ end
116
+
117
+ # Write only what actually changed. `last_seen_on` is a date precisely so an
118
+ # active session doesn't generate a write per request.
119
+ def refresh_from_cognito(user, attrs)
120
+ drift = attrs.reject { |attr, value| user.public_send(attr) == value }
121
+ return user if drift.empty?
122
+
123
+ user.update!(drift)
124
+ user
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Belt
6
+ module Authentication
7
+ # Everything a Belt app needs to know about a Cognito-authenticated human,
8
+ # without writing any of it.
9
+ #
10
+ # class User < ApplicationRecord
11
+ # cognito_authenticatable
12
+ # end
13
+ #
14
+ # What that gets you:
15
+ #
16
+ # * Primary key **is** the Cognito `sub`. Resolving the caller is one GetItem,
17
+ # and the sub becomes a usable foreign key everywhere else in the schema.
18
+ # * Attributes: email, name, role, email_verified, last_seen_on.
19
+ # * An `EmailIndex` GSI, so invitations (addressed to an email before the invitee
20
+ # has an account) can find their person.
21
+ # * `.sync_from_claims!` — just-in-time provisioning from a token, write-averse
22
+ # enough to sit on the authenticated request path.
23
+ # * `#admin?` — platform staff, mirrored from a Cognito group on every request.
24
+ #
25
+ # Options:
26
+ #
27
+ # cognito_authenticatable roles: %w[member admin support], # default member/admin
28
+ # default_role: 'member',
29
+ # email_index: 'PeopleEmailIndex' # or false to skip
30
+ #
31
+ # The app's own domain stays the app's: memberships, orgs, plans, per-tenant roles.
32
+ # This concern only owns identity.
33
+ module CognitoAuthenticatable
34
+ extend ActiveSupport::Concern
35
+
36
+ # Platform-wide role. `admin` means staff — someone who can see across tenants.
37
+ # Not to be confused with a per-tenant role, which the app models itself.
38
+ ROLES = %w[member admin].freeze
39
+ ADMIN_ROLE = 'admin'
40
+ DEFAULT_ROLE = 'member'
41
+ DEFAULT_EMAIL_INDEX = 'EmailIndex'
42
+
43
+ # DynamoDB attribute names for the identity attributes. Snake_case, not
44
+ # ActiveItem's default camelCase, so they read the same in the console, in
45
+ # `dynamodb.tf`, and in a GSI key definition.
46
+ ATTRIBUTE_MAP = {
47
+ 'id' => 'id',
48
+ 'email' => 'email',
49
+ 'name' => 'name',
50
+ 'role' => 'role',
51
+ 'email_verified' => 'email_verified',
52
+ 'last_seen_on' => 'last_seen_on'
53
+ }.freeze
54
+
55
+ # Merges the identity schema into whatever the model declares itself, in either
56
+ # order. Prepended to the model's singleton rather than assigned in `included do`
57
+ # because ActiveItem's `indexes` / `dynamo_attribute_map` writers replace instead
58
+ # of merge — an app declaring its own index after the macro would otherwise drop
59
+ # EmailIndex on the floor, silently, until a lookup failed in production.
60
+ #
61
+ # App declarations win on conflict.
62
+ module SchemaDefaults
63
+ def indexes(definitions = nil)
64
+ return super if definitions
65
+
66
+ cognito_email_index_definition.merge(super())
67
+ end
68
+
69
+ def dynamo_attribute_map(mappings = nil)
70
+ return super if mappings
71
+
72
+ ATTRIBUTE_MAP.merge(super())
73
+ end
74
+ end
75
+
76
+ included do
77
+ attr_accessor :email, # Cognito `email` claim, normalized to lowercase.
78
+ :name, # Cognito `name` claim, or the email local part.
79
+ :role, # Platform role — see ROLES.
80
+ :email_verified, # Cognito `email_verified`.
81
+ :last_seen_on # ISO date, not a timestamp — see .sync_from_claims!
82
+
83
+ # Email is deliberately NOT validated. It comes from a verified token rather
84
+ # than a form, and this model is written on the authentication path: a
85
+ # validation failure here would 500 every request instead of rejecting input.
86
+ validates :role, presence: true, inclusion: { in: ->(record) { record.class.cognito_roles } }
87
+
88
+ singleton_class.prepend(SchemaDefaults)
89
+ end
90
+
91
+ # Platform staff. Mirrored from the Cognito group on every request, so a
92
+ # revocation takes effect on the caller's next call.
93
+ def admin?
94
+ role.to_s == ADMIN_ROLE
95
+ end
96
+
97
+ def email_verified?
98
+ Claims.truthy?(email_verified)
99
+ end
100
+
101
+ # Hook for whatever the app wants to happen each time an identity is resolved
102
+ # from a token — binding pending invitations, seeding a default workspace, an
103
+ # audit trail. No-op by default; override in the model.
104
+ def after_cognito_sync; end
105
+ end
106
+ end
107
+ end
108
+
109
+ require_relative 'cognito_authenticatable/class_methods'
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Authentication
5
+ # Authentication settings, reachable from the runtime config:
6
+ #
7
+ # Belt.configure do |config|
8
+ # config.authentication.user_class = 'Account' # default: 'User'
9
+ # config.authentication.admin_groups = %w[staff] # default: ['admins']
10
+ # end
11
+ #
12
+ # Every setting has a working default, so an app that names its model `User` and
13
+ # its Cognito group `admins` configures nothing.
14
+ class Configuration
15
+ # Cognito groups whose members are platform staff. NOT a per-tenant role — that
16
+ # belongs to the app's own domain (a membership, an org role, whatever).
17
+ DEFAULT_ADMIN_GROUPS = %w[admins].freeze
18
+
19
+ DEFAULT_USER_CLASS = 'User'
20
+
21
+ def initialize
22
+ @user_class = nil
23
+ @admin_groups = nil
24
+ @issuer = nil
25
+ end
26
+
27
+ attr_writer :admin_groups, :issuer
28
+
29
+ # Accepts a class or a name. Stored as a name and resolved on demand so the
30
+ # setting can be declared before the model file is loaded.
31
+ def user_class=(value)
32
+ @user_class = value.is_a?(Class) ? value.name : value&.to_s
33
+ end
34
+
35
+ def user_class_name
36
+ @user_class || DEFAULT_USER_CLASS
37
+ end
38
+
39
+ # The model backing #current_user, or nil if the app hasn't defined one.
40
+ # Nil is a legitimate answer: an app can use Belt's Cognito plumbing without
41
+ # persisting a user row at all.
42
+ def user_class
43
+ Object.const_get(user_class_name)
44
+ rescue NameError
45
+ nil
46
+ end
47
+
48
+ def admin_groups
49
+ return Array(@admin_groups).map(&:to_s) if @admin_groups
50
+
51
+ configured = ENV.fetch('ADMIN_COGNITO_GROUPS', '').to_s.split(',').map(&:strip).reject(&:empty?)
52
+ configured.empty? ? DEFAULT_ADMIN_GROUPS : configured
53
+ end
54
+
55
+ # Expected `iss` claim. Derived from the Cognito env vars Belt's Terraform module
56
+ # already exports, so apps don't restate it. Nil disables the issuer check —
57
+ # which is the right behaviour locally and in tests, where there is no pool.
58
+ def issuer
59
+ return @issuer if @issuer
60
+
61
+ pool_id = ENV.fetch('COGNITO_USER_POOL_ID', nil)
62
+ return nil if pool_id.to_s.empty?
63
+
64
+ region = ENV['COGNITO_REGION'] || ENV['AWS_REGION'] || 'us-east-1'
65
+ "https://cognito-idp.#{region}.amazonaws.com/#{pool_id}"
66
+ end
67
+ end
68
+ end
69
+ end