belt 0.3.43 → 0.4.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: 35d8b66e560a7eedc35c0f801e54e80c5322c0aa81bbba2e84945c679ad37df8
4
- data.tar.gz: 77cac61f6823651528069f69ba79e715acbe9b01054c101cdc51f7dbd5c0227c
3
+ metadata.gz: 5a04110a957fa2d24cbca0245df1aa6aa421547ac140467cb5e6a15e9d0e1fb1
4
+ data.tar.gz: 17ffdd470fe0dd613ac82cd0554c12146fb527ee1a6ccd179f3de4b3e3024eab
5
5
  SHA512:
6
- metadata.gz: 17067c3c5dca6d23f3a7c44576a2653fcb8d281d95951d0ac0faa3deececf9158a6cfeedd08ca2419b196bed86ce75989d0537f29bcdc8f4b288e284505c05d7
7
- data.tar.gz: 7b4dad5df465c3f7ad7cf42bbd6dc803bfe5ef6443cfb92be9918c04a33ca5cb68c5b01082777933fd559cda4d7d9eb4e33dae07a22c1b1b1bf2d62cfa43a7ad
6
+ metadata.gz: 540c4da110ef61d412c09af5ce4db33bd534ec460d636ac2988e9784cb0fc58ac54e6cd9eeb201b48f863832d7d936ae5677bbcb78d37e702f04915e234ee230
7
+ data.tar.gz: ecc50065fcff0b93904c89cbc8c0e3c1a0c45e8f54c7c12af80d9546d894ed12b0a9f8103f8ca5eae3623c1cbd0e38d703137acf86e242ca9fe05946734ac28f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,63 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### New Features
6
+
7
+ - **`cognito_authenticatable` — Cognito identity in one line.** Everything an app used
8
+ to hand-write to turn a JWT into a user record now lives in the gem, Devise-style:
9
+
10
+ ```ruby
11
+ class User < ApplicationRecord
12
+ cognito_authenticatable
13
+ end
14
+ ```
15
+
16
+ That supplies the Cognito `sub` as primary key, the identity attributes
17
+ (`email`, `name`, `role`, `email_verified`, `last_seen_on`), an `EmailIndex` GSI,
18
+ `.sync_from_claims!` / `.for_sub` / `.for_email`, and `#admin?`. The app's model is
19
+ left holding only the app's own domain.
20
+
21
+ Controllers get `current_user`, `user_signed_in?`, `authenticate_user!`, and
22
+ `cognito_admin?` with no `include` and no configuration — `BeltController::Base`
23
+ mixes them in. Both token shapes are handled (a pre-verified API Gateway authorizer
24
+ claim set, or a raw `Authorization: Bearer` ID token the Lambda decodes and checks
25
+ for expiry, issuer, and `token_use`).
26
+
27
+ Rows are provisioned just-in-time on the first authenticated request and refreshed
28
+ only on drift, so an unchanged user costs one `GetItem` and no write. Hook your own
29
+ behaviour off that moment with `#after_cognito_sync`.
30
+
31
+ Options: `roles:`, `default_role:`, `email_index:`. Configure with
32
+ `Belt.configure { |c| c.authentication.user_class = 'Account' }`. Full docs:
33
+ `belt explain authentication`.
34
+
35
+ - **`belt generate auth` scaffolds the user model.** It writes `lambda/models/user.rb`
36
+ (never overwriting an existing one) and regenerates `dynamodb.tf` so the `users`
37
+ table and its `EmailIndex` exist. `belt setup tables` now recognizes the macro, so a
38
+ model that declares `cognito_authenticatable` gets its GSI without an explicit
39
+ `indexes()` call.
40
+
41
+ ### Bug Fix
42
+
43
+ - **`belt setup tables` respected `belongs_to ..., index: false`.** It didn't. ActiveItem
44
+ skips registering an association index when told to, but the table generator created
45
+ the convention GSI anyway — on a `fooId` attribute the model never writes, so the
46
+ index silently indexed nothing while costing storage. It now skips those declarations.
47
+ Regenerating `dynamodb.tf` in a project that uses `index: false` will therefore drop
48
+ the dead GSIs; that's a real (if harmless) Terraform diff, so look before you apply.
49
+
50
+ ### Internal
51
+
52
+ - `Belt::AuthenticationError` and friends moved to `lib/belt/errors.rb` so they can be
53
+ required without pulling in the whole gem. No API change.
54
+
55
+ ### Upgrading
56
+
57
+ - Upgrade is additive — nothing breaks by bumping to 0.4.0. To adopt
58
+ `cognito_authenticatable` in an existing app (and for the one `index: false` diff to
59
+ watch even if you don't), see [UPGRADING.md](UPGRADING.md).
60
+
3
61
  ## 0.3.43
4
62
 
5
63
  ### 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
@@ -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: '' }]
@@ -41,11 +41,23 @@ module Belt
41
41
 
42
42
  # Apply the configured aws_profile and env vars to the current process.
43
43
  # Call this before running terraform, aws cli, etc.
44
+ #
45
+ # Note: If AWS credentials are already available via environment variables
46
+ # (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) — as in GitHub
47
+ # Actions OIDC — we skip setting AWS_PROFILE to avoid overriding valid credentials.
44
48
  def apply!
45
- ENV['AWS_PROFILE'] = @aws_profile if aws_profile?
49
+ ENV['AWS_PROFILE'] = @aws_profile if aws_profile? && !credentials_in_env?
46
50
  @env_vars.each { |key, value| ENV[key] = value }
47
51
  end
48
52
 
53
+ # Returns true if AWS credentials are available via environment variables.
54
+ # This happens in CI environments (GitHub Actions OIDC, CodeBuild, etc.)
55
+ def credentials_in_env?
56
+ key = ENV.fetch('AWS_ACCESS_KEY_ID', nil)
57
+ secret = ENV.fetch('AWS_SECRET_ACCESS_KEY', nil)
58
+ !key.to_s.empty? && !secret.to_s.empty?
59
+ end
60
+
49
61
  class ConfigEvaluator
50
62
  def initialize(config)
51
63
  @config = config
@@ -17,6 +17,12 @@ module Belt
17
17
  'model' => 'models',
18
18
  'activeitem' => 'models',
19
19
  'dynamodb' => 'models',
20
+ 'auth' => 'authentication',
21
+ 'cognito' => 'authentication',
22
+ 'users' => 'authentication',
23
+ 'user' => 'authentication',
24
+ 'current_user' => 'authentication',
25
+ 'login' => 'authentication',
20
26
  'deploy' => 'deployment',
21
27
  'deploying' => 'deployment',
22
28
  'terraform' => 'deployment',
@@ -84,6 +84,9 @@ module Belt
84
84
  # Extract indexes() declaration
85
85
  indexes = extract_indexes(content)
86
86
 
87
+ # cognito_authenticatable installs a GSI without an indexes() call
88
+ indexes += extract_cognito_indexes(content)
89
+
87
90
  # Extract belongs_to associations and generate convention indexes
88
91
  indexes += extract_belongs_to_indexes(content)
89
92
 
@@ -115,15 +118,33 @@ module Belt
115
118
  indexes
116
119
  end
117
120
 
121
+ # `cognito_authenticatable` installs an EmailIndex GSI without the model ever
122
+ # calling indexes() — see Belt::Authentication::CognitoAuthenticatable. The
123
+ # generator has to know that, or the table would be created without the GSI and
124
+ # the first email lookup would fail in production instead of here.
125
+ def extract_cognito_indexes(content)
126
+ declaration = uncommented(content).match(/^\s*cognito_authenticatable\b(.*)$/)
127
+ return [] unless declaration
128
+
129
+ options = declaration[1].to_s
130
+ return [] if options.match?(/email_index:\s*false/)
131
+
132
+ name = options.match(/email_index:\s*['"]([^'"]+)['"]/)
133
+ [{ name: name ? name[1] : 'EmailIndex', partition_key: 'email', sort_key: nil }]
134
+ end
135
+
118
136
  # Extract belongs_to declarations and generate convention-based GSI indexes.
119
137
  # belongs_to :conversation → ConversationIndex with partition_key: 'conversationId'
138
+ #
139
+ # `index: false` opts out — the model is saying the reverse lookup is covered some
140
+ # other way (its own indexes() entry, usually, under a different key name).
141
+ # Generating one anyway produces a GSI on an attribute that doesn't exist.
120
142
  def extract_belongs_to_indexes(content)
121
143
  indexes = []
122
144
 
123
- # Skip commented-out belongs_to lines
124
- content.lines.reject { |line| line.strip.start_with?('#') }.join
125
- .scan(/belongs_to\s+:(\w+)/) do |match|
126
- association_name = match[0]
145
+ uncommented(content).scan(/belongs_to\s+:(\w+)([^\n]*)/) do |association_name, options|
146
+ next if options.match?(/index:\s*false/)
147
+
127
148
  index_name = "#{Belt::Inflector.classify(association_name)}Index"
128
149
  partition_key = "#{association_name}Id"
129
150
 
@@ -133,6 +154,10 @@ module Belt
133
154
  indexes
134
155
  end
135
156
 
157
+ def uncommented(content)
158
+ content.lines.reject { |line| line.strip.start_with?('#') }.join
159
+ end
160
+
136
161
  def generate_dynamodb_tf(models)
137
162
  dest = File.join(MODULE_DIR, 'dynamodb.tf')
138
163
  existing_content = File.exist?(dest) ? File.read(dest) : nil
@@ -5,6 +5,7 @@ module Belt
5
5
  #
6
6
  # Belt.configure do |config|
7
7
  # config.default_format = :json # or :html
8
+ # config.authentication.user_class = 'Account'
8
9
  # end
9
10
  #
10
11
  # Note: infrastructure/<env>/belt.rb uses a separate sandboxed DSL for CLI
@@ -18,6 +19,12 @@ module Belt
18
19
 
19
20
  attr_reader :default_format
20
21
 
22
+ # Cognito identity settings — see Belt::Authentication::Configuration.
23
+ # Resolved lazily so config can be set before the app's models are loaded.
24
+ def authentication
25
+ @authentication ||= Belt::Authentication::Configuration.new
26
+ end
27
+
21
28
  def default_format=(value)
22
29
  format = value.to_sym
23
30
  unless VALID_FORMATS.include?(format)
@@ -27,4 +34,23 @@ module Belt
27
34
  @default_format = format
28
35
  end
29
36
  end
37
+
38
+ # Runtime configuration accessors live here, not in belt.rb, so that a single
39
+ # subsystem can be required on its own (`require 'belt/authentication'`) and still
40
+ # reach Belt.configuration. Test harnesses that shadow BeltController do exactly that.
41
+ class << self
42
+ # Runtime configuration (lambda/config/environment.rb). Separate from the
43
+ # CLI sandboxed DSL in infrastructure/<env>/belt.rb.
44
+ def configuration
45
+ @configuration ||= Configuration.new
46
+ end
47
+
48
+ def configure
49
+ yield configuration
50
+ end
51
+
52
+ def reset_configuration!
53
+ @configuration = Configuration.new
54
+ end
55
+ end
30
56
  end
@@ -0,0 +1,186 @@
1
+ # Authentication
2
+
3
+ Cognito owns authentication — passwords, MFA, hosted signup, groups. Belt owns the
4
+ **record** of the human Cognito authenticated, so your app can answer its own questions
5
+ about that person without re-reading JWT claims in every controller.
6
+
7
+ Declare it on a model and you're done:
8
+
9
+ ```ruby
10
+ class User < ApplicationRecord
11
+ cognito_authenticatable
12
+ end
13
+ ```
14
+
15
+ That one line supplies:
16
+
17
+ | | |
18
+ |---|---|
19
+ | Primary key | the Cognito `sub` — resolving the caller is one `GetItem` |
20
+ | Attributes | `email`, `name`, `role`, `email_verified`, `last_seen_on` |
21
+ | GSI | `EmailIndex`, for finding a person by address |
22
+ | Class methods | `.sync_from_claims!`, `.for_sub`, `.for_email` |
23
+ | Instance methods | `#admin?`, `#email_verified?` |
24
+
25
+ Controllers get `current_user`, `authenticate_user!`, `user_signed_in?`, and
26
+ `cognito_admin?` with no `include` and no configuration.
27
+
28
+ ## The table
29
+
30
+ ```hcl
31
+ resource "aws_dynamodb_table" "users" {
32
+ name = "${var.app_name}-${var.environment}-users"
33
+ billing_mode = "PAY_PER_REQUEST"
34
+ hash_key = "id" # the Cognito sub
35
+
36
+ attribute {
37
+ name = "id"
38
+ type = "S"
39
+ }
40
+
41
+ attribute {
42
+ name = "email"
43
+ type = "S"
44
+ }
45
+
46
+ global_secondary_index {
47
+ name = "EmailIndex"
48
+ hash_key = "email"
49
+ projection_type = "ALL"
50
+ }
51
+ }
52
+ ```
53
+
54
+ Identity attributes are stored snake_case (`email_verified`, not `emailVerified`) so
55
+ they read the same in the console, in `dynamodb.tf`, and in a GSI key definition.
56
+
57
+ Grant the table to every Lambda, not per route. Authenticated requests read it *before*
58
+ the action runs, so per-route `tables:` means listing it on every route and 500ing on
59
+ whichever one you missed.
60
+
61
+ ## In a controller
62
+
63
+ ```ruby
64
+ class ProfilesController < ApplicationController
65
+ before_action :authenticate_user!
66
+
67
+ def show
68
+ @profile = current_user
69
+ end
70
+ end
71
+ ```
72
+
73
+ `authenticate_user!` raises `Belt::Authentication::NotAuthenticated`, which Belt already
74
+ maps to **401**. It has to raise: `before_action` cannot halt the chain by returning a
75
+ response — return values are discarded and the action runs anyway.
76
+
77
+ | Method | |
78
+ |---|---|
79
+ | `current_user` | the user record, or nil. Memoized per request |
80
+ | `user_signed_in?` | is there a Cognito identity on this request? |
81
+ | `authenticate_user!` | `before_action` guard → 401 |
82
+ | `cognito_admin?` | does the token carry a staff Cognito group? |
83
+ | `cognito_claims` | raw claims, if you really need them |
84
+ | `bearer_token` | the raw `Authorization: Bearer` credential |
85
+
86
+ If your app has a *second* credential scheme — an API key for machine callers, say —
87
+ override `skip_cognito_identity?` to declare that such a request is not a human:
88
+
89
+ ```ruby
90
+ def skip_cognito_identity?
91
+ agent_request? # `Authorization: Bearer fp_...`
92
+ end
93
+ ```
94
+
95
+ Usually unnecessary: a credential that isn't a Cognito ID token yields no claims and
96
+ therefore no `current_user`. It matters when a request could be read as both.
97
+
98
+ ## Just-in-time provisioning
99
+
100
+ There is no signup endpoint to keep in step with Cognito's hosted UI. The first
101
+ authenticated request from a user writes the row; later requests only write when
102
+ something actually drifted — a name changed in Cognito, a staff group was granted or
103
+ revoked, or it's the first sighting today. Steady state is one `GetItem` and no write.
104
+
105
+ `last_seen_on` is a **date**, not a timestamp, precisely so an active session doesn't
106
+ generate a write per request.
107
+
108
+ To hang your own behaviour off that moment, override the hook:
109
+
110
+ ```ruby
111
+ class User < ApplicationRecord
112
+ cognito_authenticatable
113
+
114
+ # Runs whenever an identity is resolved from a token.
115
+ def after_cognito_sync
116
+ claim_pending_invitations!
117
+ end
118
+ end
119
+ ```
120
+
121
+ ## Platform staff
122
+
123
+ `role` is platform-wide: `member` or `admin`. It mirrors a Cognito group on **every**
124
+ request, so removing someone from the group locks them out on their very next call.
125
+
126
+ ```ruby
127
+ current_user.admin? # FeatureParity staff — can see across tenants
128
+ cognito_admin? # the grant: does this token carry the group?
129
+ ```
130
+
131
+ Per-tenant roles (owner of a project, member of an org) are your domain, not this
132
+ concern's. Model them yourself.
133
+
134
+ Grant staff by hand — Terraform should create the group but not manage its members, so
135
+ that a `terraform apply` can't hand out platform-wide read access:
136
+
137
+ ```bash
138
+ aws cognito-idp admin-add-user-to-group \
139
+ --user-pool-id <pool> --username <you> --group-name admins
140
+ ```
141
+
142
+ ## Configuration
143
+
144
+ Every setting has a working default. An app whose model is `User` and whose staff group
145
+ is `admins` configures nothing.
146
+
147
+ ```ruby
148
+ # lambda/config/environment.rb
149
+ Belt.configure do |config|
150
+ config.authentication.user_class = 'Account' # default: 'User'
151
+ config.authentication.admin_groups = %w[staff] # default: ['admins'], or ADMIN_COGNITO_GROUPS
152
+ config.authentication.issuer = '...' # default: derived from COGNITO_USER_POOL_ID
153
+ end
154
+ ```
155
+
156
+ Macro options:
157
+
158
+ ```ruby
159
+ cognito_authenticatable roles: %w[member admin support],
160
+ default_role: 'member',
161
+ email_index: 'PeopleEmailIndex' # or false to skip it
162
+ ```
163
+
164
+ ## Two token shapes
165
+
166
+ A route can be authenticated either way, and both are handled:
167
+
168
+ 1. **API Gateway Cognito authorizer** — claims arrive pre-verified under
169
+ `requestContext.authorizer.claims`, with every value flattened to a string
170
+ (`"true"`, `"[admins, members]"`).
171
+ 2. **A raw `Authorization: Bearer <id token>` header** — the Lambda decodes it.
172
+
173
+ Case 2 is signature-unverified by design: where an authorizer is attached, the gateway
174
+ already checked the signature and an unsigned token never reaches your code. What can
175
+ still be checked cheaply is checked — structure, expiry, issuer, and `token_use` (an
176
+ access token is rejected; it carries no email or name).
177
+
178
+ Anything that isn't a Cognito ID token — including your own API key scheme sharing the
179
+ same header — reads as "no Cognito identity" rather than an error, so
180
+ `current_user` is simply nil.
181
+
182
+ ## See also
183
+
184
+ - `belt explain models` — ActiveItem
185
+ - `belt explain controllers` — `before_action`, response helpers
186
+ - `belt generate auth` — create the Cognito user pool
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ class AuthenticationError < StandardError; end
5
+ class RecordNotFound < StandardError; end
6
+ class ActionNotFound < StandardError; end
7
+ class TemplateNotFound < StandardError; end
8
+ end
data/lib/belt/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Belt
4
- VERSION = '0.3.43'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/belt.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'activeitem'
4
4
  require_relative 'belt/version'
5
5
  require_relative 'belt/root'
6
+ require_relative 'belt/errors'
6
7
  require_relative 'belt/configuration'
7
8
  require_relative 'belt/http_status'
8
9
  require_relative 'belt/parameters'
@@ -11,30 +12,11 @@ require_relative 'belt/lambda_handler'
11
12
  require_relative 'belt/action_router'
12
13
 
13
14
  module Belt
14
- class AuthenticationError < StandardError; end
15
- class RecordNotFound < StandardError; end
16
- class ActionNotFound < StandardError; end
17
- class TemplateNotFound < StandardError; end
18
-
19
15
  @controller_paths = []
20
16
 
21
17
  class << self
22
18
  attr_reader :controller_paths
23
19
 
24
- # Runtime configuration (lambda/config/environment.rb). Separate from the
25
- # CLI sandboxed DSL in infrastructure/<env>/belt.rb.
26
- def configuration
27
- @configuration ||= Configuration.new
28
- end
29
-
30
- def configure
31
- yield configuration
32
- end
33
-
34
- def reset_configuration!
35
- @configuration = Configuration.new
36
- end
37
-
38
20
  # Auto-discover lambda/controllers dirs in all loaded gems
39
21
  def gem_controller_paths
40
22
  @gem_controller_paths ||= discover_gem_paths('lambda/controllers')
@@ -72,5 +54,6 @@ module Belt
72
54
  end
73
55
  end
74
56
 
57
+ require_relative 'belt/authentication'
75
58
  require_relative 'belt_controller/base'
76
59
  require_relative 'belt/controllers/welcome_controller'
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'cgi'
5
5
  require_relative '../belt/parameters'
6
+ require_relative '../belt/authentication'
6
7
  require_relative '../belt/helpers/response'
7
8
  require_relative '../belt/helpers/error_logging'
8
9
  require_relative '../belt/helpers/cors_origin'
@@ -14,6 +15,9 @@ module BeltController
14
15
  include Belt::Helpers::Response
15
16
  include Belt::Rendering
16
17
  include ImplicitResponse
18
+ # Cognito identity: current_user, authenticate_user!, cognito_admin?.
19
+ # Inert unless the app declares `cognito_authenticatable` on a model.
20
+ include Belt::Authentication::Controller
17
21
 
18
22
  attr_reader :event, :body
19
23
 
@@ -8,6 +8,7 @@ module BeltController
8
8
  FRAMEWORK_IVARS = %i[
9
9
  @event @raw_body @params @current_action @current_user_id @user_groups
10
10
  @logger @__assigns_before @__response_status
11
+ @current_user @cognito_claims @cognito_groups
11
12
  ].freeze
12
13
 
13
14
  private
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= @user_class %> — a human who signs into this app. One row per Cognito user.
4
+ #
5
+ # Cognito owns authentication (passwords, MFA, the `admins` group). `cognito_authenticatable`
6
+ # supplies everything derived from a token — the `sub` as primary key, email/name/role,
7
+ # just-in-time provisioning, an EmailIndex GSI. Run `belt explain authentication` for the
8
+ # full list.
9
+ #
10
+ # Add your own domain below: associations, per-tenant roles, whatever this app needs to
11
+ # know about a person that Cognito doesn't.
12
+ class <%= @user_class %> < ApplicationRecord
13
+ cognito_authenticatable
14
+
15
+ # has_many :memberships, foreign_key: 'cognito_sub', index: 'CognitoIndex'
16
+
17
+ # Runs whenever an identity is resolved from a token — first sight and every request
18
+ # after. Override to bind pending invitations, seed a workspace, and so on.
19
+ # def after_cognito_sync
20
+ # end
21
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: belt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.43
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -102,6 +102,13 @@ files:
102
102
  - lib/belt/action_router.rb
103
103
  - lib/belt/assets/belt-default.jpg
104
104
  - lib/belt/assets/welcome.css
105
+ - lib/belt/authentication.rb
106
+ - lib/belt/authentication/claims.rb
107
+ - lib/belt/authentication/cognito_authenticatable.rb
108
+ - lib/belt/authentication/cognito_authenticatable/class_methods.rb
109
+ - lib/belt/authentication/configuration.rb
110
+ - lib/belt/authentication/controller.rb
111
+ - lib/belt/authentication/model_macro.rb
105
112
  - lib/belt/cli.rb
106
113
  - lib/belt/cli/apex_dns_sync.rb
107
114
  - lib/belt/cli/app_detection.rb
@@ -149,6 +156,7 @@ files:
149
156
  - lib/belt/cli/zip_artifact_builder.rb
150
157
  - lib/belt/configuration.rb
151
158
  - lib/belt/controllers/welcome_controller.rb
159
+ - lib/belt/docs/authentication.md
152
160
  - lib/belt/docs/backups.md
153
161
  - lib/belt/docs/console.md
154
162
  - lib/belt/docs/controllers.md
@@ -161,6 +169,7 @@ files:
161
169
  - lib/belt/docs/plugins.md
162
170
  - lib/belt/docs/routing.md
163
171
  - lib/belt/docs/structure.md
172
+ - lib/belt/errors.rb
164
173
  - lib/belt/helpers/cors_origin.rb
165
174
  - lib/belt/helpers/error_logging.rb
166
175
  - lib/belt/helpers/response.rb
@@ -207,6 +216,7 @@ files:
207
216
  - lib/templates/generate/auth/frontend/apiClient.js
208
217
  - lib/templates/generate/auth/frontend/auth.css
209
218
  - lib/templates/generate/auth/frontend/auth.js
219
+ - lib/templates/generate/auth/user_model.rb.erb
210
220
  - lib/templates/generate/controller.rb.erb
211
221
  - lib/templates/generate/model.rb.erb
212
222
  - lib/templates/module/dns.tf.erb