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