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.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Authentication
5
+ # Cognito identity for controllers. Mixed into BeltController::Base, so every
6
+ # controller already has it:
7
+ #
8
+ # class ProfilesController < ApplicationController
9
+ # before_action :authenticate_user!
10
+ #
11
+ # def show
12
+ # @profile = current_user
13
+ # end
14
+ # end
15
+ #
16
+ # `current_user` returns a record of the app's user model (see
17
+ # CognitoAuthenticatable), provisioned just-in-time on first sight. Nothing in a
18
+ # controller needs to decode a token, read a claim, or parse a Cognito group.
19
+ #
20
+ # Every ivar here is listed in ImplicitResponse::FRAMEWORK_IVARS, so an identity
21
+ # resolved mid-action never leaks into a JSON response body.
22
+ module Controller
23
+ # The raw `Authorization` header, however the client cased it.
24
+ def authorization_header
25
+ Claims.authorization_header(event)
26
+ end
27
+
28
+ # The raw Bearer credential — whatever it is. An app whose API accepts its own
29
+ # token format alongside Cognito reads it here and decides for itself.
30
+ def bearer_token
31
+ Claims.bearer_token(event)
32
+ end
33
+
34
+ # The Cognito claims on this request, or nil if there aren't any. Memoized
35
+ # including the nil, so a request decodes at most once.
36
+ def cognito_claims
37
+ return @cognito_claims if defined?(@cognito_claims)
38
+
39
+ @cognito_claims = Claims.from_event(event, issuer: belt_authentication_config.issuer)
40
+ end
41
+
42
+ def cognito_sub
43
+ cognito_claims && cognito_claims['sub']
44
+ end
45
+
46
+ def cognito_email
47
+ cognito_claims && cognito_claims['email']
48
+ end
49
+
50
+ # Cognito groups on the current token. Prefer #cognito_admin? or a role on
51
+ # #current_user over reading this directly.
52
+ def cognito_groups
53
+ return @cognito_groups if defined?(@cognito_groups)
54
+
55
+ @cognito_groups = Claims.groups(cognito_claims)
56
+ end
57
+
58
+ # Does this token grant platform staff access? The *grant* mechanism —
59
+ # `current_user.admin?` mirrors it, which is what the rest of the app should ask,
60
+ # so that a user's staff status is answerable without a token in hand.
61
+ def cognito_admin?
62
+ cognito_groups.intersect?(belt_authentication_config.admin_groups)
63
+ end
64
+
65
+ # The user record behind the token on this request, or nil when the request
66
+ # carries no Cognito identity (anonymous, or an app-specific credential such as
67
+ # an API key).
68
+ #
69
+ # Provisioned on first sight and refreshed only on drift — see
70
+ # CognitoAuthenticatable.sync_from_claims!. Memoized including the nil.
71
+ def current_user
72
+ return @current_user if defined?(@current_user)
73
+
74
+ @current_user = resolve_cognito_user
75
+ end
76
+
77
+ def user_signed_in?
78
+ !current_user.nil?
79
+ end
80
+
81
+ # Suppress Cognito resolution for a request authenticated some other way — an
82
+ # app's own API key, a machine token. Default false: always attempt.
83
+ #
84
+ # Usually unnecessary, since a credential that isn't a Cognito ID token yields no
85
+ # claims and therefore no user. It matters when a request could carry both, and
86
+ # the app's answer is "this caller is not a human".
87
+ def skip_cognito_identity?
88
+ false
89
+ end
90
+
91
+ # before_action guard. Raises, because Belt discards before_action return values
92
+ # (see BeltController::Base#run_before_actions) — returning a response hash would
93
+ # let the action run anyway. Belt maps the error to 401.
94
+ def authenticate_user!
95
+ raise NotAuthenticated, 'Authentication required' unless user_signed_in?
96
+ end
97
+
98
+ private
99
+
100
+ def resolve_cognito_user
101
+ return nil if skip_cognito_identity?
102
+
103
+ claims = cognito_claims
104
+ return nil if claims.nil?
105
+
106
+ model = belt_authentication_config.user_class
107
+ return nil unless model.respond_to?(:sync_from_claims!)
108
+
109
+ model.sync_from_claims!(
110
+ sub: claims['sub'],
111
+ email: claims['email'],
112
+ name: claims['name'],
113
+ admin: cognito_admin?,
114
+ email_verified: claims['email_verified']
115
+ )
116
+ end
117
+
118
+ def belt_authentication_config
119
+ Belt.configuration.authentication
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'activeitem'
4
+
5
+ module Belt
6
+ module Authentication
7
+ # The declaration, Devise-style. Extended onto ActiveItem::Base so it reads as part
8
+ # of the model DSL rather than as a mixin the app has to know the path to:
9
+ #
10
+ # class User < ApplicationRecord
11
+ # cognito_authenticatable
12
+ # end
13
+ module ModelMacro
14
+ # @param roles [Array<String>] allowed values for `role`. Defaults to
15
+ # member/admin; extend it if the platform has more than staff and everyone else.
16
+ # @param default_role [String] role given to a newly provisioned user.
17
+ # @param email_index [String, false] GSI name for email lookups, or false if the
18
+ # app doesn't need to resolve people by email.
19
+ def cognito_authenticatable(roles: nil, default_role: nil, email_index: nil)
20
+ include Belt::Authentication::CognitoAuthenticatable
21
+
22
+ self.cognito_roles = roles.map(&:to_s) if roles
23
+ self.cognito_default_role = default_role.to_s if default_role
24
+ self.cognito_email_index = email_index unless email_index.nil?
25
+
26
+ validate_cognito_default_role!
27
+ self
28
+ end
29
+
30
+ # Whether this model has declared cognito_authenticatable.
31
+ def cognito_authenticatable?
32
+ include?(Belt::Authentication::CognitoAuthenticatable)
33
+ end
34
+
35
+ private
36
+
37
+ def validate_cognito_default_role!
38
+ return if cognito_roles.include?(cognito_default_role)
39
+
40
+ raise ArgumentError,
41
+ "default_role #{cognito_default_role.inspect} is not in roles #{cognito_roles.inspect}"
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ ActiveItem::Base.extend(Belt::Authentication::ModelMacro)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'errors'
4
+ require_relative 'configuration'
5
+ require_relative 'authentication/configuration'
6
+ require_relative 'authentication/claims'
7
+ require_relative 'authentication/cognito_authenticatable'
8
+ require_relative 'authentication/model_macro'
9
+ require_relative 'authentication/controller'
10
+ module Belt
11
+ # Cognito-backed identity for Belt apps.
12
+ #
13
+ # Cognito owns authentication (passwords, MFA, groups). Belt owns the *record* of the
14
+ # human it authenticated, so the app can answer its own questions about them without
15
+ # re-reading JWT claims everywhere.
16
+ #
17
+ # Declare it on a model, Devise-style:
18
+ #
19
+ # class User < ApplicationRecord
20
+ # cognito_authenticatable
21
+ # end
22
+ #
23
+ # That single line supplies the identity attributes (email, name, role,
24
+ # email_verified, last_seen_on), the EmailIndex GSI, and the class methods that
25
+ # provision a row from a token — see Authentication::CognitoAuthenticatable.
26
+ #
27
+ # Controllers get `current_user`, `authenticate_user!`, `user_signed_in?` and
28
+ # `cognito_admin?` for free (Authentication::Controller is mixed into
29
+ # BeltController::Base), so a request never has to decode a JWT by hand.
30
+ #
31
+ # Rows are provisioned just-in-time on the first authenticated request. There is no
32
+ # signup endpoint to keep in step with Cognito's hosted UI.
33
+ module Authentication
34
+ # Raised when a request carries no usable Cognito identity and one is required.
35
+ # A subclass of Belt::AuthenticationError so BeltController's existing
36
+ # rescue_from mapping (401) applies without any app-level wiring.
37
+ class NotAuthenticated < Belt::AuthenticationError; end
38
+ end
39
+ end
@@ -4,12 +4,14 @@ require 'fileutils'
4
4
  require 'erb'
5
5
  require_relative 'app_detection'
6
6
  require_relative 'frontend_registry'
7
+ require_relative 'tables_command'
7
8
 
8
9
  module Belt
9
10
  module CLI
10
11
  class AuthCommand
11
12
  TEMPLATE_DIR = File.expand_path('../../templates/generate/auth', __dir__)
12
13
  MODULE_DIR = 'infrastructure/modules/app'
14
+ MODELS_DIR = 'lambda/models'
13
15
 
14
16
  include AppDetection
15
17
 
@@ -59,6 +61,8 @@ module Belt
59
61
  What this generates:
60
62
  infrastructure/modules/app/cognito.tf User pool + client resources
61
63
  infrastructure/modules/app/cognito_outputs.tf Pool ID, ARN, and client ID outputs
64
+ lambda/models/user.rb User model (cognito_authenticatable)
65
+ infrastructure/modules/app/dynamodb.tf users table + EmailIndex (regenerated)
62
66
 
63
67
  With --signup (when frontend/ exists):
64
68
  frontend/src/lib/auth.js Auth module (signIn, signUp, etc.)
@@ -95,6 +99,8 @@ module Belt
95
99
  2. Add auth: :cognito to your routes namespace
96
100
  3. Run `belt deploy` to create the user pool
97
101
  4. Create your account (admin-only): aws cognito-idp admin-create-user ...
102
+
103
+ Then `current_user` works in every controller — see `belt explain authentication`.
98
104
  HELP
99
105
  end
100
106
 
@@ -125,6 +131,7 @@ module Belt
125
131
  write_cognito_variables_tf if @ses_email
126
132
  patch_main_tf
127
133
  patch_env_outputs
134
+ write_user_model
128
135
  generate_frontend_auth if frontend?
129
136
 
130
137
  puts "\n✓ Auth generated!"
@@ -219,6 +226,26 @@ module Belt
219
226
 
220
227
  private
221
228
 
229
+ # Scaffold the app's user model. `cognito_authenticatable` is the whole point:
230
+ # the identity plumbing lives in the gem, so this file exists only to hold the
231
+ # app's own domain. Never overwritten — someone's associations are in there.
232
+ def write_user_model
233
+ return unless Dir.exist?(MODELS_DIR)
234
+
235
+ dest = File.join(MODELS_DIR, 'user.rb')
236
+ if File.exist?(dest)
237
+ puts " skip #{dest} (already exists)"
238
+ return
239
+ end
240
+
241
+ @user_class = 'User'
242
+ write_template('user_model.rb.erb', dest)
243
+ puts " create #{dest}"
244
+
245
+ # Regenerate dynamodb.tf so the users table (and its EmailIndex) exists.
246
+ TablesCommand.sync_all_environments
247
+ end
248
+
222
249
  def build_pool_metadata
223
250
  if @pool_names.length == 1 && @pool_names.first == 'main'
224
251
  [{ name: 'main', suffix: '', label: '' }]
@@ -14,6 +14,7 @@ require_relative 'zip_artifact_builder'
14
14
  require_relative 'nested_environment'
15
15
  require_relative 'cognito_sharer'
16
16
  require_relative 'dynamo_copier'
17
+ require_relative 'dns_command'
17
18
  require_relative 'apex_dns_sync'
18
19
 
19
20
  module Belt
@@ -177,6 +178,11 @@ module Belt
177
178
  # Sync apex DNS records to root zone (if this is a prod/apex environment)
178
179
  run_apex_dns_sync
179
180
 
181
+ # Sync ACM validation CNAMEs for apex domains (e.g., prod)
182
+ # This handles the case where prod uses the apex domain (example.com)
183
+ # and needs validation CNAMEs in the root zone, not the env's zone.
184
+ sync_acm_validation_if_apex
185
+
180
186
  puts "\n✅ Deployed #{@env} successfully!"
181
187
  print_outputs(env_dir)
182
188
 
@@ -299,6 +305,27 @@ module Belt
299
305
  end
300
306
  end
301
307
 
308
+ # ─── ACM Validation Sync ────────────────────────────────────────
309
+
310
+ # For apex environments (prod), ACM validation CNAMEs need to be in the
311
+ # root zone (managed by infrastructure/dns), not the environment's zone.
312
+ # This is because the registrar points to the root zone, which is
313
+ # authoritative for the apex domain.
314
+ def sync_acm_validation_if_apex
315
+ # Only sync if DNS infrastructure exists
316
+ return unless Dir.exist?(File.join(@infra_dir, '..', 'infrastructure', 'dns')) ||
317
+ Dir.exist?('infrastructure/dns')
318
+
319
+ # Check if this is an apex environment
320
+ return unless apex_environment?
321
+
322
+ DnsCommand.sync_acm_validation_if_needed(@env)
323
+ end
324
+
325
+ def apex_environment?
326
+ %w[prod production].include?(@env)
327
+ end
328
+
302
329
  # ─── Backup Phase ───────────────────────────────────────────────
303
330
 
304
331
  def run_backups