hecks 3.11.0 → 3.12.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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
  3. data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
  4. data/lib/hecks/adapters/driving/newcomer.rb +50 -0
  5. data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
  6. data/lib/hecks/bluebook/assembly/marks.rb +3 -15
  7. data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
  8. data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
  9. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
  10. data/lib/hecks/bluebook.rb +1 -0
  11. data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
  12. data/lib/hecks/gate/stages.yml +32 -0
  13. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
  14. data/lib/hecks/hecks/hecks.world +3 -0
  15. data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
  16. data/lib/hecks/language/bluebook/policy.behaviors +19 -0
  17. data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
  18. data/lib/hecks/projection_files/expression_tables.rb +7 -2
  19. data/lib/hecks/projections/deploy/box/compose.rb +6 -4
  20. data/lib/hecks/projections/deploy/box/edge.rb +62 -0
  21. data/lib/hecks/projections/deploy/box/logging.rb +99 -0
  22. data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
  23. data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
  24. data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
  25. data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
  26. data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
  27. data/lib/hecks/projections/deploy/box/settings.rb +6 -2
  28. data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
  29. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
  30. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
  31. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
  32. data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
  33. data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
  34. data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
  35. data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
  36. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
  37. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
  38. data/lib/hecks/projections/deploy/box.rb +15 -3
  39. data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
  40. data/lib/hecks/projector/cli_audience.rb +20 -0
  41. data/lib/hecks/projector/cli_projector/usage.rb +15 -4
  42. data/lib/hecks/rust_build/host.rb +11 -4
  43. data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
  44. data/lib/hecks/tools/ci_gate_decision.rb +35 -0
  45. data/lib/hecks/tools/ci_gates.rb +20 -1
  46. data/lib/hecks/tools/lanes/files.rb +8 -0
  47. data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
  48. data/lib/hecks/tools/lanes.rb +1 -0
  49. data/lib/hecks/tools/release_only.rb +111 -0
  50. data/lib/hecks/version.rb +1 -1
  51. data/lib/hecks/vocabulary.rb +15 -1
  52. data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
  53. data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
  54. data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
  55. data/rust/host/HECKS_RELEASE +1 -1
  56. data/rust/host/src/extension.rs +13 -1
  57. data/rust/host/src/lib.rs +1 -0
  58. data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
  59. data/rust/host/src/platform_signin/config.rs +512 -0
  60. data/rust/host/src/platform_signin/cookie.rs +237 -0
  61. data/rust/host/src/platform_signin/extension.rs +190 -0
  62. data/rust/host/src/platform_signin/fetcher.rs +322 -0
  63. data/rust/host/src/platform_signin/handler.rs +251 -0
  64. data/rust/host/src/platform_signin/handler_tests.rs +554 -0
  65. data/rust/host/src/platform_signin/jti.rs +196 -0
  66. data/rust/host/src/platform_signin/jwks.rs +542 -0
  67. data/rust/host/src/platform_signin/jws.rs +249 -0
  68. data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
  69. data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
  70. data/rust/host/src/platform_signin/mod.rs +75 -0
  71. data/rust/host/src/platform_signin/mode_tests.rs +382 -0
  72. data/rust/host/src/platform_signin/pages.rs +204 -0
  73. data/rust/host/src/platform_signin/query.rs +233 -0
  74. data/rust/host/src/platform_signin/session.rs +151 -0
  75. data/rust/host/src/platform_signin/settings.rs +385 -0
  76. data/rust/host/src/platform_signin/strict_json.rs +351 -0
  77. data/rust/host/src/platform_signin/test_support.rs +235 -0
  78. data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
  79. data/rust/host/src/platform_signin/verify.rs +604 -0
  80. data/rust/host/src/platform_signin/wiring_support.rs +319 -0
  81. data/rust/host/src/web.rs +50 -6
  82. data/rust/parser/src/parse/value_object.rs +22 -10
  83. metadata +42 -4
  84. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  85. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
@@ -0,0 +1,254 @@
1
+ //! The member mapping against a real Postgres, through the same functions the Google sign-in uses.
2
+ //!
3
+ //! Each test gets a scratch database shaped like the real head views. The one path these tests do
4
+ //! not take is the write that links a member (it needs the compiled domain module); the mapping
5
+ //! logic around it is covered with a fake store in the handler tests.
6
+
7
+ use std::path::Path;
8
+ use std::sync::Arc;
9
+
10
+ use serde_json::{json, Value};
11
+ use tokio::sync::Mutex;
12
+ use tokio_postgres::{Client, NoTls};
13
+
14
+ use super::extension::PlatformSignin;
15
+ use super::session::{map_member, HostMembers};
16
+ use super::wiring_support::*;
17
+ use crate::journal::LineageConfig;
18
+ use crate::lambda_client::NeverInvoker;
19
+
20
+ fn domain_ir() -> Value {
21
+ json!({
22
+ "name": "Acme",
23
+ "lineage": {"capable_aggregates": [{"name": "Member", "storage_name": "member"}]},
24
+ "membership": {"provider": "Acme", "aggregate": "Acme::Member"},
25
+ "identity": {"provider": "Identity", "register": "Identity::Identity.Register", "link": "Identity::ExternalIdentifier.Link", "resolve": "Identity::ExternalIdentifier.ResolvedBy"},
26
+ })
27
+ }
28
+
29
+ async fn scratch(name: &str) -> Mutex<Client> {
30
+ let (admin, conn) = tokio_postgres::connect(&crate::test_pg::conninfo("postgres"), NoTls)
31
+ .await
32
+ .expect("postgres");
33
+ tokio::spawn(async move {
34
+ let _ = conn.await;
35
+ });
36
+ admin
37
+ .batch_execute(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
38
+ .await
39
+ .unwrap();
40
+ admin
41
+ .batch_execute(&format!("CREATE DATABASE {name}"))
42
+ .await
43
+ .unwrap();
44
+ let (client, conn) = tokio_postgres::connect(&crate::test_pg::conninfo(name), NoTls)
45
+ .await
46
+ .expect("scratch");
47
+ tokio::spawn(async move {
48
+ let _ = conn.await;
49
+ });
50
+ client
51
+ .batch_execute(
52
+ "CREATE TABLE acme_member_head_snapshot_1 (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL);
53
+ CREATE VIEW acme_member_head AS SELECT id, state FROM acme_member_head_snapshot_1;
54
+ CREATE TABLE acme_external_identifier_head_snapshot_1 (id text PRIMARY KEY, ordinal bigint NOT NULL, state jsonb NOT NULL);
55
+ CREATE VIEW acme_external_identifier_head AS SELECT id, state FROM acme_external_identifier_head_snapshot_1;",
56
+ )
57
+ .await
58
+ .unwrap();
59
+ Mutex::new(client)
60
+ }
61
+
62
+ async fn member(
63
+ db: &Mutex<Client>,
64
+ email: &str,
65
+ role: Option<&str>,
66
+ identity: Option<&str>,
67
+ disabled: bool,
68
+ ) {
69
+ let state = json!({
70
+ "name": {"value": "Person"}, "email": {"value": email},
71
+ "role": role.map(|r| json!({"value": r})), "identity_id": identity.map(|i| json!({"value": i})), "disabled": disabled,
72
+ });
73
+ db.lock().await.execute("INSERT INTO acme_member_head_snapshot_1 (id, ordinal, state) VALUES ($1, 1, $2::jsonb)", &[&email, &state]).await.unwrap();
74
+ }
75
+
76
+ async fn known_identity(db: &Mutex<Client>, issuer: &str, subject: &str, identity: &str) {
77
+ let state =
78
+ json!({"issuer": {"value": issuer}, "subject": {"value": subject}, "identity": identity});
79
+ let id = format!("{issuer}:{subject}");
80
+ db.lock().await.execute("INSERT INTO acme_external_identifier_head_snapshot_1 (id, ordinal, state) VALUES ($1, 1, $2::jsonb)", &[&id, &state]).await.unwrap();
81
+ }
82
+
83
+ struct World {
84
+ db: Mutex<Client>,
85
+ ir: Value,
86
+ config: LineageConfig,
87
+ }
88
+
89
+ impl World {
90
+ async fn new(name: &str) -> Self {
91
+ Self {
92
+ db: scratch(name).await,
93
+ ir: domain_ir(),
94
+ config: LineageConfig {
95
+ domain: "Acme".into(),
96
+ era: Some(1),
97
+ mirrored: None,
98
+ },
99
+ }
100
+ }
101
+
102
+ fn members(&self) -> HostMembers<'_> {
103
+ HostMembers {
104
+ client: &self.db,
105
+ domain_ir: &self.ir,
106
+ wasm_path: Path::new("unused.wasm"),
107
+ config: &self.config,
108
+ invoker: &NeverInvoker,
109
+ }
110
+ }
111
+
112
+ async fn signs_in(&self) -> Option<crate::auth::Session> {
113
+ let doc = vectors();
114
+ let claims = vector_claims(&doc, "valid");
115
+ map_member(&self.members(), &claims).await.unwrap()
116
+ }
117
+ }
118
+
119
+ #[tokio::test]
120
+ async fn a_resolved_identity_signs_its_linked_member_in() {
121
+ let world = World::new("hecks_host_signin_resolves").await;
122
+ member(
123
+ &world.db,
124
+ "linked@example.test",
125
+ Some("Admin"),
126
+ Some("identity-1"),
127
+ false,
128
+ )
129
+ .await;
130
+ known_identity(
131
+ &world.db,
132
+ "https://idp.example",
133
+ "idp-subject-0001",
134
+ "identity-1",
135
+ )
136
+ .await;
137
+ let session = world.signs_in().await.expect("signed in");
138
+ assert_eq!(session.email, "linked@example.test");
139
+ assert_eq!(session.role.as_deref(), Some("Admin"));
140
+ }
141
+
142
+ #[tokio::test]
143
+ async fn a_member_linked_to_another_identity_is_not_signed_in() {
144
+ let world = World::new("hecks_host_signin_other_identity").await;
145
+ member(
146
+ &world.db,
147
+ "person@example.test",
148
+ Some("Admin"),
149
+ Some("identity-other"),
150
+ false,
151
+ )
152
+ .await;
153
+ assert!(world.signs_in().await.is_none());
154
+ }
155
+
156
+ #[tokio::test]
157
+ async fn an_unknown_person_is_not_signed_in() {
158
+ let world = World::new("hecks_host_signin_unknown").await;
159
+ assert!(world.signs_in().await.is_none());
160
+ }
161
+
162
+ #[tokio::test]
163
+ async fn a_disabled_member_is_not_signed_in_by_identity_or_by_email() {
164
+ let world = World::new("hecks_host_signin_disabled").await;
165
+ member(&world.db, "person@example.test", Some("Admin"), None, true).await;
166
+ assert!(world.signs_in().await.is_none());
167
+ let linked = World::new("hecks_host_signin_disabled_linked").await;
168
+ member(
169
+ &linked.db,
170
+ "linked@example.test",
171
+ Some("Admin"),
172
+ Some("identity-1"),
173
+ true,
174
+ )
175
+ .await;
176
+ known_identity(
177
+ &linked.db,
178
+ "https://idp.example",
179
+ "idp-subject-0001",
180
+ "identity-1",
181
+ )
182
+ .await;
183
+ assert!(linked.signs_in().await.is_none());
184
+ }
185
+
186
+ #[tokio::test]
187
+ async fn a_member_without_a_role_is_not_signed_in() {
188
+ let world = World::new("hecks_host_signin_no_role").await;
189
+ member(
190
+ &world.db,
191
+ "linked@example.test",
192
+ None,
193
+ Some("identity-1"),
194
+ false,
195
+ )
196
+ .await;
197
+ known_identity(
198
+ &world.db,
199
+ "https://idp.example",
200
+ "idp-subject-0001",
201
+ "identity-1",
202
+ )
203
+ .await;
204
+ assert!(world.signs_in().await.is_none());
205
+ let unlinked = World::new("hecks_host_signin_no_role_email").await;
206
+ member(&unlinked.db, "person@example.test", None, None, false).await;
207
+ assert!(unlinked.signs_in().await.is_none());
208
+ }
209
+
210
+ #[tokio::test]
211
+ async fn the_whole_callback_over_a_real_store_sets_the_session_and_clears_the_cookie() {
212
+ let world = World::new("hecks_host_signin_whole").await;
213
+ member(
214
+ &world.db,
215
+ "linked@example.test",
216
+ Some("Admin"),
217
+ Some("identity-1"),
218
+ false,
219
+ )
220
+ .await;
221
+ known_identity(
222
+ &world.db,
223
+ "https://idp.example",
224
+ "idp-subject-0001",
225
+ "identity-1",
226
+ )
227
+ .await;
228
+ let doc = vectors();
229
+ let request = request_for(&single(&doc, "valid"));
230
+ let ext = PlatformSignin::new(vector_handler("alongside", Arc::new(Recorder::default())));
231
+ let response = ext.finish(&request, &world.members()).await;
232
+ assert_eq!(response["statusCode"], 302);
233
+ assert_eq!(response["headers"]["location"], "/admin.html");
234
+ assert_eq!(cookies_of(&response).len(), 2);
235
+ }
236
+
237
+ #[tokio::test]
238
+ async fn the_whole_callback_over_a_real_store_refuses_an_unlinked_person() {
239
+ let world = World::new("hecks_host_signin_whole_unlinked").await;
240
+ member(
241
+ &world.db,
242
+ "person@example.test",
243
+ Some("Admin"),
244
+ Some("identity-other"),
245
+ false,
246
+ )
247
+ .await;
248
+ let doc = vectors();
249
+ let request = request_for(&single(&doc, "valid"));
250
+ let ext = PlatformSignin::new(vector_handler("alongside", Arc::new(Recorder::default())));
251
+ let response = ext.finish(&request, &world.members()).await;
252
+ assert_eq!(response["statusCode"], 403);
253
+ assert_eq!(cookies_of(&response), vec![super::cookie::clear_header()]);
254
+ }
@@ -0,0 +1,75 @@
1
+ //! Signing a site's people in through a platform issuer: the token verifier and the flow around it.
2
+ //!
3
+ //! A site that lets a platform issuer sign its people in receives, on its callback, a compact JWS
4
+ //! signed with Ed25519. The verifier ([`verify`], with [`jws`], [`jwks`], [`jti`] and [`config`])
5
+ //! decides whether such a token is genuine, fresh, meant for this site and this environment, and
6
+ //! not already used. It is a library of pure functions and small types: no HTTP client, no cookie
7
+ //! code, no environment reads, no routes. Its caller supplies the clock, the values from the
8
+ //! site's own sign-in cookie, and a [`jwks::JwksFetcher`].
9
+ //!
10
+ //! ## Why a reference implementation elsewhere
11
+ //!
12
+ //! The issuer's own verifier (the Ruby one in the platform repository) is the reference. This
13
+ //! module gives the same verdict and the same stable refusal code, in the same check order, for
14
+ //! every vector in the shared known-answer file (`tests/fixtures/platform_signin_vectors.json`).
15
+ //! The check order is part of the contract: an attacker who can fail two checks at once must learn
16
+ //! only about the first, and a request that fails an early check must never spend the token's
17
+ //! single-use identifier.
18
+ //!
19
+ //! ## What the verifier is not
20
+ //!
21
+ //! The verifier does not log, and nothing in it ever formats a token. It is not a session:
22
+ //! mapping a verified identity to a member of the site is the job of [`session`].
23
+ //!
24
+ //! ## The flow around it
25
+ //!
26
+ //! [`extension`] puts the sign-in on the host's routes, [`handler`] starts and finishes a sign-in,
27
+ //! [`settings`] reads the switch and its companions once at boot, and [`cookie`], [`query`] and
28
+ //! [`pages`] are the strict edges of the request and response. With the switch unset none of it
29
+ //! is installed.
30
+
31
+ /// Validated settings: the pinned issuer, key set address, site and environment.
32
+ pub mod config;
33
+ /// The site's own sign-in cookie: how it is written, deleted and strictly read.
34
+ pub mod cookie;
35
+ /// The host extension that puts the flow on the site's routes.
36
+ pub mod extension;
37
+ /// The real, bounded fetch of the issuer's key set.
38
+ pub mod fetcher;
39
+ /// Starting a sign-in and finishing it on the callback.
40
+ pub mod handler;
41
+ /// The bounded in-memory record that makes each token single-use.
42
+ pub mod jti;
43
+ /// The issuer's public keys: parsing, thumbprint indexing and a rate-limited cache.
44
+ pub mod jwks;
45
+ /// Strict compact JWS parsing and canonical base64url.
46
+ pub mod jws;
47
+ /// The redirects and refusal pages of the flow.
48
+ pub mod pages;
49
+ /// The callback's query string, read strictly.
50
+ pub mod query;
51
+ /// Mapping a verified token to a member of the site.
52
+ pub mod session;
53
+ /// The switch and the settings the flow needs, validated at boot.
54
+ pub mod settings;
55
+ /// A JSON object reader that refuses repeated keys and deep nesting.
56
+ pub mod strict_json;
57
+ /// The ordered checks, the stable refusal reasons and the verified claims.
58
+ pub mod verify;
59
+
60
+ #[cfg(test)]
61
+ mod attack_tests;
62
+ #[cfg(test)]
63
+ mod handler_tests;
64
+ #[cfg(test)]
65
+ mod known_answer_tests;
66
+ #[cfg(test)]
67
+ mod mapping_tests;
68
+ #[cfg(test)]
69
+ mod mode_tests;
70
+ #[cfg(test)]
71
+ mod test_support;
72
+ #[cfg(test)]
73
+ mod ttl_tests;
74
+ #[cfg(test)]
75
+ mod wiring_support;