1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
//! M1.4c-ii admit-gate table: the projection of
//! [`Environment::messaging_endpoints`](greentic_deploy_spec::Environment) that
//! the revision ingress consults when a request carries a
//! `x-greentic-messaging-endpoint-id` header.
//!
//! M1.4c-i started receiving that header in [`crate::revision_serve`] and
//! threading it through to the runtime context, but accepted any value. This
//! module turns the header into a real authorization gate by answering one
//! question per request: *given the endpoint id the caller claims, is the
//! resolved deployment's bundle in that endpoint's `linked_bundles` ACL?*
//!
//! See `greentic-deploy-spec::messaging_endpoint` on why `linked_bundles` is
//! an ACL rather than a deployment selector — the runtime resolves the
//! concrete [`BundleDeployment`](greentic_deploy_spec::BundleDeployment) via
//! existing route binding + traffic-split routing first, and only then asks
//! this table whether that bundle is reachable through the asserted endpoint.
//!
//! The empty-table case (env declares no messaging endpoints) intentionally
//! fail-closes every header-asserted request: declaring an endpoint is the
//! one and only way to opt in.
//!
//! Composition with the rest of the serve pipeline is two-step but a *single*
//! conceptual gate:
//!
//! 1. Before dispatch we look the endpoint up — unknown endpoint ⇒ refuse the
//! request cheaply with `UNAUTHORIZED` and don't waste a dispatch on it.
//! 2. After the dispatcher picks a revision we check membership of
//! `outcome.bundle_id` in the ACL — outside-of-ACL ⇒ `FORBIDDEN`.
//!
//! Requests without the header take neither branch and stay on the legacy
//! single-instance path (back-compat for environments that never adopt M1).
use std::collections::{HashMap, HashSet};
use greentic_deploy_spec::{BundleId, Environment, WelcomeFlowRef};
/// The per-endpoint state the revision ingress needs at request time:
/// the `linked_bundles` ACL plus the M1.5 welcome-flow ref (if declared).
/// Co-locating these prevents drift between two parallel maps keyed on the
/// same endpoint id.
#[derive(Clone, Debug)]
struct EndpointEntry {
linked_bundles: HashSet<String>,
/// [`MessagingEndpoint::welcome_flow`](greentic_deploy_spec::MessagingEndpoint::welcome_flow)
/// cloned in so the lookup is one map. Read by the producer at
/// `revision_serve::serve` to build the per-request `WelcomeFlowHint`.
welcome_flow: Option<WelcomeFlowRef>,
}
/// Per-endpoint ACL projection of `Environment.messaging_endpoints` consulted
/// by the revision ingress; see the module docs.
#[derive(Clone, Debug, Default)]
pub struct EndpointAdmit {
/// Key: the on-wire `endpoint_id` form (the same string the caller asserts
/// in `x-greentic-messaging-endpoint-id`, which matches
/// `MessagingEndpointId::to_string`).
by_id: HashMap<String, EndpointEntry>,
/// Two-level index: `provider_type → provider_id → endpoint_id`. Drives
/// the M1 IID.4 resolver: when a request arrives without
/// `x-greentic-messaging-endpoint-id`, the host invokes each enabled
/// provider component's `identify-instance` export; the returned
/// `provider_id` is paired with the resolver's known `provider_type` and
/// looked up here to recover the matching `endpoint_id`.
///
/// Nested over a flat `(String, String)` key map because:
/// * Two-step lookup borrows on `&str` directly, no per-call allocation.
/// * `provider_types()` is the outer map's keys.
/// * `endpoint_count_for_provider_type(t)` is the inner map's `.len()` —
/// the separate count field this replaced is redundant.
///
/// (`provider_type`, `provider_id`) uniqueness is an
/// [`Environment::validate`](greentic_deploy_spec::Environment::validate)
/// invariant, so duplicates can't reach this table.
by_provider_type: HashMap<String, HashMap<String, String>>,
}
impl EndpointAdmit {
/// Build an admit table from the env's declared endpoints. Each endpoint's
/// `linked_bundles` is materialized as a `HashSet<String>` so membership
/// checks at request time are O(1) regardless of ACL size.
pub fn from_environment(env: &Environment) -> Self {
let mut by_id = HashMap::with_capacity(env.messaging_endpoints.len());
let mut by_provider_type: HashMap<String, HashMap<String, String>> = HashMap::new();
for ep in &env.messaging_endpoints {
let endpoint_id = ep.endpoint_id.to_string();
by_id.insert(
endpoint_id.clone(),
EndpointEntry {
linked_bundles: ep
.linked_bundles
.iter()
.map(|b| b.as_str().to_string())
.collect(),
welcome_flow: ep.welcome_flow.clone(),
},
);
by_provider_type
.entry(ep.provider_type.clone())
.or_default()
.insert(ep.provider_id.clone(), endpoint_id);
}
Self {
by_id,
by_provider_type,
}
}
/// Look up the on-wire `endpoint_id` for a `(provider_type, provider_id)`
/// pair. Used by the M1 IID.4 resolver to recover an `endpoint_id` from a
/// `provider_id` returned by a component's `identify-instance` probe.
pub(crate) fn endpoint_id_for_provider(
&self,
provider_type: &str,
provider_id: &str,
) -> Option<&str> {
self.by_provider_type
.get(provider_type)
.and_then(|m| m.get(provider_id))
.map(String::as_str)
}
/// Iterate over the distinct `provider_type` values declared in this env.
/// The resolver uses this to pick which provider components to probe per
/// request (one probe per type, not per endpoint).
pub(crate) fn provider_types(&self) -> impl Iterator<Item = &str> {
self.by_provider_type.keys().map(String::as_str)
}
/// Number of endpoints declared for `provider_type`. ≥2 means a missing
/// header MUST fail closed when the resolver cannot disambiguate.
pub(crate) fn endpoint_count_for_provider_type(&self, provider_type: &str) -> usize {
self.by_provider_type
.get(provider_type)
.map(HashMap::len)
.unwrap_or(0)
}
/// Look up the ACL set for `endpoint_id`. `None` means *this env has never
/// declared that endpoint* — the caller MUST refuse the request, not fall
/// through to the legacy path. The set itself may be empty (a declared but
/// unwired endpoint), in which case any subsequent bundle check rejects.
pub fn linked_bundles(&self, endpoint_id: &str) -> Option<&HashSet<String>> {
self.by_id.get(endpoint_id).map(|e| &e.linked_bundles)
}
/// Look up the raw M1.5 welcome-flow ref for `endpoint_id`, if the
/// endpoint is declared AND has `welcome_flow` set. Returns `None` for
/// both unknown endpoints and known-but-unset welcome flows — the
/// producer cannot distinguish those at this site because
/// [`linked_bundles`] has already classified unknowns as `UNAUTHORIZED`
/// upstream.
///
/// Most callers want [`welcome_flow_for_bundle`]; this raw lookup is
/// kept for projection-level tests.
///
/// [`linked_bundles`]: EndpointAdmit::linked_bundles
/// [`welcome_flow_for_bundle`]: EndpointAdmit::welcome_flow_for_bundle
pub(crate) fn welcome_flow(&self, endpoint_id: &str) -> Option<&WelcomeFlowRef> {
self.by_id
.get(endpoint_id)
.and_then(|e| e.welcome_flow.as_ref())
}
/// Look up the welcome-flow ref for `endpoint_id` **only when the
/// dispatched bundle matches the welcome ref's bundle**. Returns `None`
/// when the endpoint is unknown, has no welcome ref, OR dispatched into
/// a sibling bundle.
///
/// The deploy-spec only invariant is `welcome_flow.bundle_id ∈
/// linked_bundles`, NOT that dispatch lands on the welcome bundle.
/// Endpoints can `linked_bundles` more than one bundle, and dispatch
/// picks one via traffic splits / pins. Crossing bundles would target
/// the welcome bundle's pack/flow on a sibling bundle's revision —
/// either misroute (pack-id collision) or 500 (pack absent).
pub(crate) fn welcome_flow_for_bundle(
&self,
endpoint_id: &str,
dispatched_bundle: &BundleId,
) -> Option<&WelcomeFlowRef> {
self.welcome_flow(endpoint_id)
.filter(|ref_| &ref_.bundle_id == dispatched_bundle)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{endpoint, endpoint_typed, env_with};
use greentic_deploy_spec::{BundleId, PackId};
#[test]
fn empty_env_yields_empty_table() {
let admit = EndpointAdmit::from_environment(&env_with(Vec::new()));
assert!(admit.linked_bundles("anything").is_none());
}
#[test]
fn endpoint_lookup_keys_on_endpoint_id_string() {
let ep = endpoint("teams-legal", &["legal-bundle", "shared-utils"]);
let id = ep.endpoint_id.to_string();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let bundles = admit
.linked_bundles(&id)
.expect("declared endpoint should resolve");
assert!(bundles.contains("legal-bundle"));
assert!(bundles.contains("shared-utils"));
assert!(!bundles.contains("finance-bundle"));
}
#[test]
fn endpoint_with_empty_acl_is_known_but_never_admits() {
let ep = endpoint("teams-bare", &[]);
let id = ep.endpoint_id.to_string();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let bundles = admit
.linked_bundles(&id)
.expect("declared endpoint must resolve even with empty ACL");
assert!(bundles.is_empty());
}
#[test]
fn unknown_endpoint_id_returns_none() {
let admit =
EndpointAdmit::from_environment(&env_with(vec![endpoint("teams-legal", &["legal"])]));
assert!(admit.linked_bundles("bogus-endpoint-id").is_none());
assert!(admit.linked_bundles("").is_none());
}
#[test]
fn welcome_flow_lookup_returns_ref_when_declared() {
let mut ep = endpoint("teams-legal", &["legal-bundle"]);
ep.welcome_flow = Some(WelcomeFlowRef {
bundle_id: BundleId::new("legal-bundle"),
pack_id: PackId::new("legal-pack"),
flow_id: "welcome".to_string(),
});
let id = ep.endpoint_id.to_string();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
let ref_ = admit.welcome_flow(&id).expect("welcome ref present");
assert_eq!(ref_.bundle_id.as_str(), "legal-bundle");
assert_eq!(ref_.pack_id.as_str(), "legal-pack");
assert_eq!(ref_.flow_id, "welcome");
}
#[test]
fn welcome_flow_lookup_returns_none_when_unset() {
// Both shapes that lack a welcome flow collapse to None at this site:
// an unknown endpoint AND a known endpoint whose `welcome_flow` is None.
// The unknown-vs-unset distinction belongs upstream at `linked_bundles`,
// which has already refused the unknown case with `UNAUTHORIZED`.
let ep = endpoint("teams-legal", &["legal-bundle"]); // welcome_flow: None
let id = ep.endpoint_id.to_string();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
assert!(admit.welcome_flow(&id).is_none());
assert!(admit.welcome_flow("bogus-endpoint-id").is_none());
}
#[test]
fn endpoint_id_for_provider_returns_matching_endpoint() {
let teams_legal = endpoint_typed("teams", "28:legal-bot", &["legal-bundle"]);
let teams_accounting = endpoint_typed("teams", "28:acct-bot", &["acct-bundle"]);
let slack = endpoint_typed("slack", "T0LEGAL", &["legal-bundle"]);
let (legal_id, acct_id, slack_id) = (
teams_legal.endpoint_id.to_string(),
teams_accounting.endpoint_id.to_string(),
slack.endpoint_id.to_string(),
);
let admit =
EndpointAdmit::from_environment(&env_with(vec![teams_legal, teams_accounting, slack]));
assert_eq!(
admit.endpoint_id_for_provider("teams", "28:legal-bot"),
Some(legal_id.as_str())
);
assert_eq!(
admit.endpoint_id_for_provider("teams", "28:acct-bot"),
Some(acct_id.as_str())
);
assert_eq!(
admit.endpoint_id_for_provider("slack", "T0LEGAL"),
Some(slack_id.as_str())
);
}
#[test]
fn endpoint_id_for_provider_returns_none_on_cross_type_or_unknown_id() {
let admit = EndpointAdmit::from_environment(&env_with(vec![endpoint_typed(
"teams",
"28:legal-bot",
&["legal-bundle"],
)]));
// Wrong type (Slack key against Teams entry).
assert!(
admit
.endpoint_id_for_provider("slack", "28:legal-bot")
.is_none()
);
// Right type, unknown provider_id.
assert!(
admit
.endpoint_id_for_provider("teams", "28:unknown")
.is_none()
);
// Empty inputs.
assert!(admit.endpoint_id_for_provider("", "").is_none());
}
#[test]
fn endpoint_counts_by_provider_type_reflect_declared_endpoints() {
let admit = EndpointAdmit::from_environment(&env_with(vec![
endpoint_typed("teams", "28:a", &["b1"]),
endpoint_typed("teams", "28:b", &["b2"]),
endpoint_typed("slack", "T0", &["b1"]),
]));
assert_eq!(admit.endpoint_count_for_provider_type("teams"), 2);
assert_eq!(admit.endpoint_count_for_provider_type("slack"), 1);
assert_eq!(admit.endpoint_count_for_provider_type("telegram"), 0);
let mut types: Vec<&str> = admit.provider_types().collect();
types.sort_unstable();
assert_eq!(types, vec!["slack", "teams"]);
}
#[test]
fn welcome_flow_for_bundle_returns_ref_only_when_bundle_matches() {
let mut ep = endpoint("teams-legal", &["bundle-a", "bundle-b"]);
ep.welcome_flow = Some(WelcomeFlowRef {
bundle_id: BundleId::new("bundle-b"),
pack_id: PackId::new("legal-pack"),
flow_id: "welcome".to_string(),
});
let id = ep.endpoint_id.to_string();
let admit = EndpointAdmit::from_environment(&env_with(vec![ep]));
// Dispatch to the welcome bundle ⇒ ref returned.
let hit = admit
.welcome_flow_for_bundle(&id, &BundleId::new("bundle-b"))
.expect("ref present");
assert_eq!(hit.bundle_id.as_str(), "bundle-b");
// Dispatch to a sibling bundle ⇒ None (cross-bundle hint would
// target B's pack/flow on A's revision).
assert!(
admit
.welcome_flow_for_bundle(&id, &BundleId::new("bundle-a"))
.is_none()
);
// Unknown endpoint ⇒ None regardless of bundle.
assert!(
admit
.welcome_flow_for_bundle("bogus-endpoint-id", &BundleId::new("bundle-b"))
.is_none()
);
}
}