belt 0.3.44 → 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 +4 -4
- data/CHANGELOG.md +58 -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/explain_command.rb +6 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5a04110a957fa2d24cbca0245df1aa6aa421547ac140467cb5e6a15e9d0e1fb1
|
|
4
|
+
data.tar.gz: 17ffdd470fe0dd613ac82cd0554c12146fb527ee1a6ccd179f3de4b3e3024eab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: '' }]
|
|
@@ -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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
data/lib/belt/configuration.rb
CHANGED
|
@@ -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
|
data/lib/belt/errors.rb
ADDED
data/lib/belt/version.rb
CHANGED
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'
|
data/lib/belt_controller/base.rb
CHANGED
|
@@ -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
|
|
|
@@ -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.
|
|
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
|