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 +4 -4
- data/CHANGELOG.md +111 -0
- data/README.md +40 -0
- data/lib/belt/authentication/claims.rb +124 -0
- data/lib/belt/authentication/cognito_authenticatable/class_methods.rb +129 -0
- data/lib/belt/authentication/cognito_authenticatable.rb +109 -0
- data/lib/belt/authentication/configuration.rb +69 -0
- data/lib/belt/authentication/controller.rb +123 -0
- data/lib/belt/authentication/model_macro.rb +47 -0
- data/lib/belt/authentication.rb +39 -0
- data/lib/belt/cli/auth_command.rb +27 -0
- data/lib/belt/cli/deploy_command.rb +27 -0
- data/lib/belt/cli/dns_command.rb +510 -241
- data/lib/belt/cli/environment_command.rb +5 -5
- data/lib/belt/cli/explain_command.rb +6 -0
- data/lib/belt/cli/frontend_deploy_command.rb +6 -0
- data/lib/belt/cli/frontend_env_command.rb +13 -4
- data/lib/belt/cli/logs_command.rb +11 -0
- data/lib/belt/cli/server_command.rb +5 -0
- data/lib/belt/cli/tables_command.rb +29 -4
- data/lib/belt/configuration.rb +26 -0
- data/lib/belt/docs/authentication.md +186 -0
- data/lib/belt/errors.rb +8 -0
- data/lib/belt/version.rb +1 -1
- data/lib/belt.rb +2 -19
- data/lib/belt_controller/base.rb +4 -0
- data/lib/belt_controller/implicit_response.rb +1 -0
- data/lib/templates/generate/auth/user_model.rb.erb +21 -0
- metadata +11 -1
|
@@ -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
|