bynk_check/actors.rs
1//! v0.45 actor contracts (the actors-foundations slice).
2//!
3//! An `actor` declaration is a nominal *boundary contract* (ADR Q1): a closed,
4//! compiler-known authentication `Scheme` plus an optional sealed identity. A
5//! handler consumes an actor on its `by` clause; the boundary verifies the
6//! scheme and mints the identity before the body runs (two-phase, fail-closed —
7//! ADR Q5/Q2).
8//!
9//! This module holds the compiler-known parts: the closed scheme set, the
10//! prelude actors, the per-protocol default actors, and the admissible-scheme
11//! sets. Foundations admits only the two zero-crypto schemes (`None`,
12//! `Internal`); `Bearer`/`Signature` are reserved-and-rejected.
13
14use std::collections::{BTreeSet, HashMap};
15
16use bynk_syntax::ast::{
17 BinOp, ByClause, Expr, ExprKind, Handler, HandlerKind, ServiceProtocol, TypeRef, UnaryOp,
18};
19use bynk_syntax::span::Span;
20
21/// P6.49 (design/tracks/the-ir.md §6b), following P6.27's `ExprId` precedent:
22/// every reader in this module — [`bearer_seam_for`], [`oidc_seam_for`],
23/// [`signature_seam_for`], [`sum_members_for`], [`caller_binder_for`] — is
24/// already parameterised by `&HashMap<String, ActorDecl>`. `bynk-emit` clones
25/// and forwards this table (`project.rs`'s `EmitProjectCtx::actors`,
26/// `emitter/workers.rs`/`workers_entry.rs`'s own `actors` parameters) without
27/// ever reading an `ActorDecl` field itself — P6.34 investigated and declined
28/// resolving it earlier, since `bynk-emit`'s own `lower_actor_seam_ir` needs
29/// the raw declarations to do its own resolution; this re-export changes the
30/// import path, not that data flow.
31pub use bynk_syntax::ast::ActorDecl;
32
33/// The authentication scheme — a closed, compiler-known set (ADR Q1). Sealed
34/// now, openable later by widening this enum.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Scheme {
37 /// Anonymous — no verification; identity is `()`. (`Visitor`.)
38 None,
39 /// In-system / platform trust — the channel itself is the assertion
40 /// (service-binding / platform dispatch). Admitted in Foundations.
41 Internal,
42 /// Bearer token — compiler-generated JWT/HS256 verification (ADR 0085).
43 Bearer,
44 /// Request signature — HMAC-SHA256 over the body (ADR 0089).
45 Signature,
46 /// OIDC/JWKS — compiler-generated verification of an asymmetrically-signed
47 /// (RS256/ES256) JWT against a provider's published JWKS, checking
48 /// `iss`/`aud`/`exp`/`nbf` and minting the identity from `sub` (ADR 0175).
49 /// The first scheme opened via the "user-configured verifier" route: the
50 /// trust root is the provider's public key set (a URL), so the declaration
51 /// carries **no inline secret** — only public trust parameters.
52 Oidc,
53 /// I3 (#1971, interop track D2): a caller that is not Bynk, declared by the
54 /// context it calls (`auth = Foreign(secret = "LEGACY_CALL_KEY")`). It signs
55 /// a call like a Bynk caller does (`bynk_check::wire_format`), naming
56 /// itself by the actor's name, under the declared secret. Admitted on
57 /// `call` only, and resolves to its own actor, never to `Caller`.
58 Foreign,
59}
60
61impl Scheme {
62 /// Classify a scheme name written in `auth = <Scheme>`. `None` means the
63 /// name is not one of the compiler-known schemes.
64 pub fn from_name(s: &str) -> Option<Scheme> {
65 Some(match s {
66 "None" => Scheme::None,
67 "Internal" => Scheme::Internal,
68 "Bearer" => Scheme::Bearer,
69 "Signature" => Scheme::Signature,
70 "Oidc" => Scheme::Oidc,
71 "Foreign" => Scheme::Foreign,
72 _ => return None,
73 })
74 }
75
76 /// The schemes the compiler can emit verification for. v0.45 admitted the
77 /// two zero-crypto schemes (`None`/`Internal`); v0.47 added `Bearer`
78 /// (JWT/HS256); v0.51 added `Signature` (HMAC over the body); v0.151 adds
79 /// `Oidc` (JWKS/RS256+ES256). All five schemes are now admitted.
80 pub fn admitted(self) -> bool {
81 matches!(
82 self,
83 Scheme::None
84 | Scheme::Internal
85 | Scheme::Bearer
86 | Scheme::Signature
87 | Scheme::Oidc
88 | Scheme::Foreign
89 )
90 }
91
92 pub fn as_str(self) -> &'static str {
93 match self {
94 Scheme::None => "None",
95 Scheme::Internal => "Internal",
96 Scheme::Bearer => "Bearer",
97 Scheme::Signature => "Signature",
98 Scheme::Oidc => "Oidc",
99 Scheme::Foreign => "Foreign",
100 }
101 }
102}
103
104/// The identity a verified actor yields (ADR Q2). In Foundations this is `()`
105/// for trivial actors, the built-in sealed `CallerId` for the cross-context
106/// `Internal` channel (Q7, folded in), or a context-owned declared type.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Identity {
109 /// `()` — `None` actors and platform-tag `Internal` actors.
110 Unit,
111 /// The built-in sealed calling-context identity (Q7). Minted at the
112 /// service-binding seam; read-only and never re-checked.
113 CallerId,
114 /// A context-owned declared type named in `identity = <T>`.
115 Declared(String),
116}
117
118/// The built-in sealed identity type for the cross-context calling principal.
119pub const CALLER_ID: &str = "CallerId";
120
121/// A resolved actor contract: its scheme and the identity it yields.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Contract {
124 pub scheme: Scheme,
125 pub identity: Identity,
126}
127
128/// The prelude actors — compiler-known boundary contracts available without a
129/// declaration. They back the per-protocol defaults and let public HTTP routes
130/// write `by v: Visitor` without ceremony.
131pub fn prelude_actor(name: &str) -> Option<Contract> {
132 Some(match name {
133 // Anonymous public surface — the only safe HTTP actor in Foundations.
134 "Visitor" => Contract {
135 scheme: Scheme::None,
136 identity: Identity::Unit,
137 },
138 // Platform schedulers / producers — Internal, carrying no useful
139 // identity payload (a bare tag).
140 "Scheduler" | "Producer" => Contract {
141 scheme: Scheme::Internal,
142 identity: Identity::Unit,
143 },
144 // The cross-context calling principal — Internal, yielding the sealed
145 // `CallerId` (Q7).
146 "Caller" => Contract {
147 scheme: Scheme::Internal,
148 identity: Identity::CallerId,
149 },
150 _ => return None,
151 })
152}
153
154/// The default actor a handler inherits when it omits `by`, by protocol (ADR
155/// Q5). HTTP has no safe default — `by` is required there.
156pub fn default_actor(protocol: &ServiceProtocol) -> Option<&'static str> {
157 match protocol {
158 ServiceProtocol::Call => Some("Caller"),
159 ServiceProtocol::Cron => Some("Scheduler"),
160 ServiceProtocol::Queue { .. } => Some("Producer"),
161 // Events track, slice 0 (spine #936): delivery is runtime-triggered,
162 // not an external caller — like `Queue`'s "Producer", the default
163 // actor names the event's originating publisher.
164 ServiceProtocol::Events { .. } => Some("Publisher"),
165 // v0.103: like HTTP, a WebSocket upgrade has no safe default actor —
166 // `by` is mandatory on `on open` (edge auth before accept, D-A).
167 ServiceProtocol::Http | ServiceProtocol::WebSocket { .. } => None,
168 }
169}
170
171/// v0.47: the data the emitter needs to lower a Bearer verification seam for a
172/// handler — the `by` binder (v0.50: `None` for the binder-less verify-and-
173/// discard form), the signing-secret env name, and the identity type to
174/// construct from the JWT `sub` claim. Resolved only for a handler whose `by`
175/// clause names a local Bearer actor; the checker guarantees the secret is
176/// present and the identity is a string-constructible local type.
177#[derive(Debug, Clone)]
178pub struct BearerSeam {
179 /// The identity binder, or `None` for `by <BearerActor>` (verify the token,
180 /// don't capture the identity). When `None` the seam still verifies fail-
181 /// closed but mints no identity and threads nothing into `deps`.
182 pub binder: Option<String>,
183 pub secret: String,
184 pub identity_type: String,
185 /// v0.53: the authorisation invariant when the `by` actor is a refinement
186 /// (`actor Admin = User where <pred>`). The seam verifies the scheme (401),
187 /// then checks this predicate against the verified claims (403 fail-closed),
188 /// then mints the (base) identity. `None` for a plain Bearer actor.
189 pub authorization: Option<ClaimPredicate>,
190}
191
192/// Resolve a handler's Bearer seam, if its `by` clause names a local Bearer
193/// actor — or a **refinement** of one (v0.53), following the refinement to its
194/// base for the scheme/secret/identity and carrying the authorisation
195/// predicate. Returns `None` for non-Bearer handlers (prelude actors are never
196/// Bearer) — those emit unchanged.
197/// #706: whether a `by` clause names a **Bearer**-secured actor (following a
198/// refinement to its base). The routes for which `by Nobody` can drive the auth
199/// seam to a `401` — an unsecured (`Visitor`/`None`) or `Signature`/`Oidc` route
200/// has no Bearer seam the test driver knows how to leave unauthenticated, so
201/// `by Nobody` there is rejected (`bynk.test.nobody_needs_secured_route`). The
202/// scheme check mirrors [`bearer_seam_for`].
203pub fn by_clause_is_bearer(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
204 let Some(named) = actors.get(&by.primary().name) else {
205 return false;
206 };
207 let base = match &named.refinement {
208 Some(r) => match actors.get(&r.base.name) {
209 Some(b) => b,
210 None => return false,
211 },
212 None => named,
213 };
214 base.auth
215 .as_ref()
216 .and_then(|a| Scheme::from_name(a.name.as_str()))
217 == Some(Scheme::Bearer)
218}
219
220pub fn bearer_seam_for(
221 handler: &Handler,
222 actors: &HashMap<String, ActorDecl>,
223) -> Option<BearerSeam> {
224 let by = handler.by_clause.as_ref()?;
225 let named = actors.get(&by.primary().name)?;
226 // Follow a refinement to its base; carry the authorisation predicate. The
227 // checker guarantees a refinement's base is Bearer and its predicate parses.
228 let (base, authorization) = match &named.refinement {
229 Some(r) => (
230 actors.get(&r.base.name)?,
231 parse_claim_predicate(&r.predicate).ok(),
232 ),
233 None => (named, None),
234 };
235 if Scheme::from_name(base.auth.as_ref()?.name.as_str()) != Some(Scheme::Bearer) {
236 return None;
237 }
238 let secret = base.scheme_arg("secret")?.value.as_str()?.to_string();
239 let TypeRef::Named(id) = base.identity.as_ref()? else {
240 return None;
241 };
242 Some(BearerSeam {
243 binder: by.binder.as_ref().map(|b| b.name.clone()),
244 secret,
245 identity_type: id.name.clone(),
246 authorization,
247 })
248}
249
250/// v0.151: the data the emitter needs to lower an OIDC/JWKS verification seam —
251/// the `by` binder (or `None` for the verify-and-discard form), the public
252/// trust parameters (`issuer`, `audience`, `jwks` URL), and the identity type
253/// to construct from the verified `sub` claim. Resolved only for a handler
254/// whose `by` clause names a local `Oidc` actor. Unlike Bearer/Signature, the
255/// seam carries **no secret env name** — the trust root is the provider's
256/// published public key set (`jwks`).
257#[derive(Debug, Clone)]
258pub struct OidcSeam {
259 /// The identity binder, or `None` for `by <OidcActor>` (verify, don't
260 /// capture the identity). When `None` the seam still verifies fail-closed
261 /// but mints no identity and threads nothing into `deps`.
262 pub binder: Option<String>,
263 /// The expected `iss` claim (and the provider whose JWKS anchors trust).
264 pub issuer: String,
265 /// The expected `aud` claim (this API's audience identifier).
266 pub audience: String,
267 /// The JWKS endpoint URL the verifier fetches signing keys from.
268 pub jwks: String,
269 /// The context-owned, string-constructible identity type minted from `sub`.
270 pub identity_type: String,
271 /// #2380 (L5): the environment variable whose JSON, when set, overrides
272 /// `issuer`/`audience`/`jwks` at run time.
273 pub trust_from: Option<String>,
274}
275
276/// Resolve a handler's OIDC seam, if its `by` clause names a single local
277/// `Oidc` actor. Returns `None` for non-Oidc handlers, for a multi-actor `by`
278/// clause (Oidc is single-actor this slice), and for a refinement (refinement
279/// over Oidc is a later slice) — those follow the existing seam paths. The
280/// checker guarantees `issuer`/`audience`/`jwks` are present and the identity
281/// is a string-constructible local type.
282pub fn oidc_seam_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<OidcSeam> {
283 let by = handler.by_clause.as_ref()?;
284 if by.is_sum() {
285 return None;
286 }
287 let actor = actors.get(&by.primary().name)?;
288 // A refinement's `auth` is `None`; it falls through here and follows the
289 // Bearer refinement path (or is rejected). An Oidc base is not narrowed.
290 if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Oidc) {
291 return None;
292 }
293 let issuer = actor.scheme_arg("issuer")?.value.as_str()?.to_string();
294 let audience = actor.scheme_arg("audience")?.value.as_str()?.to_string();
295 let jwks = actor.scheme_arg("jwks")?.value.as_str()?.to_string();
296 let TypeRef::Named(id) = actor.identity.as_ref()? else {
297 return None;
298 };
299 Some(OidcSeam {
300 binder: by.binder.as_ref().map(|b| b.name.clone()),
301 issuer,
302 audience,
303 jwks,
304 identity_type: id.name.clone(),
305 trust_from: actor
306 .scheme_arg("trustFrom")
307 .and_then(|a| a.value.as_str())
308 .map(str::to_string),
309 })
310}
311
312/// v0.54: the binder of a cross-context `on call … by c: Caller` handler that
313/// captures a live `CallerId` (the calling context's name, Q7). `None` unless
314/// the handler binds an identity whose contract is `CallerId` — i.e. the
315/// `Caller` prelude actor (the only source of `CallerId`). A binder-less
316/// `on call` (or one inheriting the `Caller` default) captures nothing and is
317/// unaffected.
318pub fn caller_binder_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<String> {
319 // `CallerId` is a cross-context `on call` concept; the checker rejects a
320 // `Caller` actor on other protocols (`scheme_not_admissible`), but guard here
321 // too so the caller seam is never emitted off the call path.
322 if !matches!(handler.kind, HandlerKind::Call) {
323 return None;
324 }
325 let by = handler.by_clause.as_ref()?;
326 // I3 (#1971): a sum (`by who: Caller | Legacy`) binds the resolved actor,
327 // not a `CallerId`, even when `Caller` is its first member
328 // ([`call_admission_for`]).
329 if by.is_sum() {
330 return None;
331 }
332 let binder = by.binder.as_ref()?;
333 let name = &by.primary().name;
334 // `CallerId` is yielded only by the `Caller` prelude actor; a local actor
335 // never declares it. A binder that collides with a param is suppressed
336 // upstream, mirroring the other seams.
337 let is_caller = !actors.contains_key(name)
338 && prelude_actor(name).map(|c| c.identity) == Some(Identity::CallerId)
339 && !handler.params.iter().any(|p| p.name.name == binder.name);
340 is_caller.then(|| binder.name.clone())
341}
342
343/// I3 (#1971): a declared foreign caller, as a `call` handler admits it: the
344/// actor's name, which the caller sends as `X-Bynk-Caller`, and the secret
345/// the callee verifies its signature under.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct ForeignCaller {
348 pub actor: String,
349 pub secret: String,
350 /// The foreign Worker's name, when the declaration gives one
351 /// (`worker = "ledger-svc"`), so `bynk deploy`'s hand-off can name it.
352 pub worker: Option<String>,
353}
354
355/// I3 (#1971): the foreign caller an actor declaration describes, if it is a
356/// `Foreign`-scheme actor with a `secret`.
357pub fn foreign_caller_of(decl: &ActorDecl) -> Option<ForeignCaller> {
358 (Scheme::from_name(decl.auth.as_ref()?.name.as_str())? == Scheme::Foreign).then_some(())?;
359 Some(ForeignCaller {
360 actor: decl.name.name.clone(),
361 secret: decl.scheme_arg("secret")?.value.as_str()?.to_string(),
362 worker: decl
363 .scheme_arg("worker")
364 .and_then(|a| a.value.as_str())
365 .map(str::to_string),
366 })
367}
368
369/// I3 (#1971): which verified callers an `on call` handler admits. Every call
370/// is authenticated before it reaches a handler (`wire_format`); this is what
371/// the handler then admits, in its `by` clause's order. A Bynk context is
372/// admitted by `Caller` (the default with no `by`) or a local `Internal`
373/// actor; a foreign caller only by its own `Foreign` actor, never by `Caller`.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct CallAdmission {
376 /// Whether a Bynk context is admitted.
377 pub contexts: bool,
378 /// The foreign callers admitted, in `by` order.
379 pub foreign: Vec<ForeignCaller>,
380 /// For a sum, the members in `by` order: `None` for the member that
381 /// admits a context (`Caller`), the foreign caller otherwise. The body's
382 /// `match` reads the resolved one.
383 pub sum: Option<Vec<CallSumMember>>,
384}
385
386/// I3 (#1971): one member of a `call` handler's actor sum.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub enum CallSumMember {
389 /// `Caller`: a Bynk context, with its name as the identity.
390 Caller,
391 /// A declared foreign caller, with no identity beyond its actor.
392 Foreign(ForeignCaller),
393}
394
395impl CallSumMember {
396 /// The member's tag, the actor's name.
397 pub fn actor_name(&self) -> &str {
398 match self {
399 CallSumMember::Caller => "Caller",
400 CallSumMember::Foreign(f) => &f.actor,
401 }
402 }
403}
404
405/// I3 (#1971): what an `on call` handler admits. `None` off the call path.
406pub fn call_admission_for(
407 handler: &Handler,
408 actors: &HashMap<String, ActorDecl>,
409) -> Option<CallAdmission> {
410 if !matches!(handler.kind, HandlerKind::Call) {
411 return None;
412 }
413 let Some(by) = handler.by_clause.as_ref() else {
414 return Some(CallAdmission {
415 contexts: true,
416 foreign: Vec::new(),
417 sum: None,
418 });
419 };
420 let mut admission = CallAdmission {
421 contexts: false,
422 foreign: Vec::new(),
423 sum: None,
424 };
425 let mut members = Vec::new();
426 for actor in &by.actors {
427 match actors.get(&actor.name) {
428 // Classified by scheme, not by whether the declaration is whole:
429 // a malformed `Foreign` actor (the checker reports it) admits
430 // nobody, rather than falling through to admit contexts.
431 Some(decl)
432 if decl.auth.as_ref().and_then(|a| Scheme::from_name(&a.name))
433 == Some(Scheme::Foreign) =>
434 {
435 if let Some(f) = foreign_caller_of(decl) {
436 admission.foreign.push(f.clone());
437 members.push(CallSumMember::Foreign(f));
438 }
439 }
440 Some(_) => {
441 admission.contexts = true;
442 members.push(CallSumMember::Caller);
443 }
444 None => {
445 admission.contexts = true;
446 members.push(CallSumMember::Caller);
447 }
448 }
449 }
450 if by.is_sum() {
451 admission.sum = Some(members);
452 }
453 Some(admission)
454}
455
456/// v0.51: the data the emitter needs to lower a Signature verification seam —
457/// the signing-secret env name, the signature header, and an optional
458/// timestamp header + tolerance window for replay defence. Resolved only for a
459/// handler whose `by` clause names a local Signature actor.
460#[derive(Debug, Clone)]
461pub struct SignatureSeam {
462 pub secret: String,
463 pub header: String,
464 pub timestamp_header: Option<String>,
465 pub tolerance_secs: Option<i64>,
466}
467
468/// Resolve a handler's Signature seam, if its `by` clause names a local
469/// Signature actor. The checker guarantees `secret` and `header` are present.
470pub fn signature_seam_for(
471 handler: &Handler,
472 actors: &HashMap<String, ActorDecl>,
473) -> Option<SignatureSeam> {
474 signature_seam_for_by(handler.by_clause.as_ref()?, actors)
475}
476
477/// #2155: the `Signature` seam a `by` clause's route is signed with at the
478/// `system` tier — its one actor's, or an actor sum's first `Signature`
479/// member's (what `by Forged`/`by Stale` sign as). `None` when it has none,
480/// and for a sum with an open member ([`sum_has_open_member`]), whose seam
481/// never refuses a bad signature.
482pub fn signature_seam_of_route(
483 by: &ByClause,
484 actors: &HashMap<String, ActorDecl>,
485) -> Option<SignatureSeam> {
486 if !by.is_sum() {
487 return signature_seam_for_by(by, actors);
488 }
489 if sum_has_open_member(by, actors) {
490 return None;
491 }
492 by.actors.iter().find_map(|a| {
493 let decl = actors.get(&a.name)?;
494 (Scheme::from_name(decl.auth.as_ref()?.name.as_str()) == Some(Scheme::Signature))
495 .then(|| signature_seam_from_decl(decl))
496 .flatten()
497 })
498}
499
500/// #2155: whether a `by` clause's route has a credential seam the `system`
501/// driver can leave out (`by Nobody`): a Bearer actor (or a refinement of
502/// one), a `Signature` actor, or an actor sum with such a member and no open
503/// member ([`sum_has_open_member`]).
504pub fn route_is_secured(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
505 if !by.is_sum() {
506 return by_clause_is_bearer(by, actors) || signature_seam_for_by(by, actors).is_some();
507 }
508 if sum_has_open_member(by, actors) {
509 return false;
510 }
511 by.actors.iter().any(|a| {
512 actors
513 .get(&a.name)
514 .and_then(|d| d.auth.as_ref())
515 .and_then(|auth| Scheme::from_name(auth.name.as_str()))
516 .is_some_and(|s| matches!(s, Scheme::Bearer | Scheme::Signature))
517 })
518}
519
520/// #2155: whether an actor sum has an *open* member — `Visitor`, or a
521/// declared actor with no auth scheme. The sum's seam resolves a request with
522/// a missing or bad credential as that member, so it never answers `401`:
523/// `by Nobody`, `by Forged` and `by Stale` have nothing to drive there.
524pub fn sum_has_open_member(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
525 by.actors.iter().any(|a| match actors.get(&a.name) {
526 // A refinement narrows a secured base, so it is never open.
527 Some(decl) if decl.refinement.is_some() => false,
528 Some(decl) => decl
529 .auth
530 .as_ref()
531 .and_then(|auth| Scheme::from_name(auth.name.as_str()))
532 .is_none_or(|s| s == Scheme::None),
533 None => prelude_actor(&a.name).is_some_and(|c| c.scheme == Scheme::None),
534 })
535}
536
537/// #2135: [`signature_seam_for`] from the `by` clause alone — the seam of
538/// its primary actor, when that is a local Signature actor.
539pub fn signature_seam_for_by(
540 by: &ByClause,
541 actors: &HashMap<String, ActorDecl>,
542) -> Option<SignatureSeam> {
543 let actor = actors.get(&by.primary().name)?;
544 if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Signature) {
545 return None;
546 }
547 signature_seam_from_decl(actor)
548}
549
550/// The Signature seam data carried by an actor declaration (its keyed config).
551/// Shared by the single-actor `signature_seam_for` and the multi-actor
552/// `sum_members_for`.
553fn signature_seam_from_decl(actor: &ActorDecl) -> Option<SignatureSeam> {
554 Some(SignatureSeam {
555 secret: actor.scheme_arg("secret")?.value.as_str()?.to_string(),
556 header: actor.scheme_arg("header")?.value.as_str()?.to_string(),
557 timestamp_header: actor
558 .scheme_arg("timestamp")
559 .and_then(|a| a.value.as_str())
560 .map(str::to_string),
561 tolerance_secs: actor.scheme_arg("tolerance").and_then(|a| a.value.as_int()),
562 })
563}
564
565/// v0.52: one resolved member of a multi-actor sum — the seam the emitter tries
566/// at that position in the first-wins order. `actor_name` is the variant tag the
567/// body matches on.
568#[derive(Debug, Clone)]
569pub struct SumMember {
570 pub actor_name: String,
571 pub seam: SumMemberSeam,
572}
573
574/// The verification a sum member contributes. `None` (a catch-all such as
575/// `Visitor`) always resolves, so it terminates the order.
576#[derive(Debug, Clone)]
577pub enum SumMemberSeam {
578 None,
579 Bearer {
580 secret: String,
581 identity_type: String,
582 },
583 Signature(SignatureSeam),
584}
585
586impl SumMember {
587 /// Whether resolving this member needs the raw request body read.
588 pub fn needs_body(&self) -> bool {
589 matches!(self.seam, SumMemberSeam::Signature(_))
590 }
591 /// The member's identity type name, if it mints one (Bearer). `None`/
592 /// Signature members carry a unit identity.
593 pub fn identity_type(&self) -> Option<&str> {
594 match &self.seam {
595 SumMemberSeam::Bearer { identity_type, .. } => Some(identity_type),
596 _ => None,
597 }
598 }
599}
600
601/// v0.52: resolve a handler's `by` clause into ordered sum members, if it names
602/// more than one actor. `None` for a single-actor handler (those keep the
603/// existing seam paths). The checker has already validated peer/scheme/
604/// reachability rules; this lowers the verified members for emission.
605pub fn sum_members_for(
606 handler: &Handler,
607 actors: &HashMap<String, ActorDecl>,
608) -> Option<Vec<SumMember>> {
609 let by = handler.by_clause.as_ref()?;
610 if !by.is_sum() {
611 return None;
612 }
613 let mut members = Vec::new();
614 for actor_ref in &by.actors {
615 let seam = if let Some(decl) = actors.get(&actor_ref.name) {
616 match Scheme::from_name(decl.auth.as_ref()?.name.as_str())? {
617 Scheme::None => SumMemberSeam::None,
618 Scheme::Bearer => {
619 let secret = decl.scheme_arg("secret")?.value.as_str()?.to_string();
620 let TypeRef::Named(id) = decl.identity.as_ref()? else {
621 return None;
622 };
623 SumMemberSeam::Bearer {
624 secret,
625 identity_type: id.name.clone(),
626 }
627 }
628 Scheme::Signature => SumMemberSeam::Signature(signature_seam_from_decl(decl)?),
629 // v0.151: `Oidc` is single-actor only this slice — the checker
630 // rejects it as a sum member (`bynk.actor.oidc_not_in_sum`), so
631 // a well-formed program never reaches here with an Oidc peer.
632 // Return `None` (fail closed → no sum emission) defensively.
633 Scheme::Oidc => return None,
634 // I3: a `Foreign` sum is a `call` sum (`call_admission_for`).
635 Scheme::Internal | Scheme::Foreign => return None,
636 }
637 } else {
638 // A prelude actor: only `Visitor` (scheme `None`) is an HTTP peer.
639 match prelude_actor(&actor_ref.name) {
640 Some(c) if c.scheme == Scheme::None => SumMemberSeam::None,
641 _ => return None,
642 }
643 };
644 members.push(SumMember {
645 actor_name: actor_ref.name.clone(),
646 seam,
647 });
648 }
649 Some(members)
650}
651
652/// Whether `scheme` is admissible on `protocol` (the admissible-scheme-per-
653/// protocol check). HTTP admits `None` (public routes) and `Bearer` (an
654/// `Authorization` header is an HTTP concept); the internal protocols
655/// (call/cron/queue) admit `Internal`. `Signature` is still reserved.
656pub fn scheme_admissible(protocol: &ServiceProtocol, scheme: Scheme) -> bool {
657 match protocol {
658 ServiceProtocol::Http => {
659 matches!(
660 scheme,
661 Scheme::None | Scheme::Bearer | Scheme::Signature | Scheme::Oidc
662 )
663 }
664 // v0.103 (D-B): a WebSocket upgrade authenticates via `None` (anonymous)
665 // or `Bearer` — but the token is read from the `Sec-WebSocket-Protocol`
666 // subprotocol, since a browser `WebSocket` cannot set an `Authorization`
667 // header. `Signature` is rejected at the WS boundary: HMAC-over-body has
668 // no body on a handshake.
669 ServiceProtocol::WebSocket { .. } => {
670 matches!(scheme, Scheme::None | Scheme::Bearer)
671 }
672 // Events track, slice 0 (spine #936): delivery is an internal,
673 // runtime-triggered invocation, like `Call`/`Cron`/`Queue` — no
674 // external network request, so no external auth scheme applies.
675 // I3 (#1971): a `call` also admits a declared foreign caller.
676 ServiceProtocol::Call => matches!(scheme, Scheme::Internal | Scheme::Foreign),
677 ServiceProtocol::Cron | ServiceProtocol::Queue { .. } | ServiceProtocol::Events { .. } => {
678 matches!(scheme, Scheme::Internal)
679 }
680 }
681}
682
683/// v0.53: the closed claim-predicate vocabulary for a refinement actor's `where`
684/// clause (`actor Admin = User where hasClaim("admin")`). Claims are untyped
685/// JSON, so the predicate is a closed set — `hasClaim`/`claimEquals` composed
686/// with `&&`/`||`/`!` — checked against the *verified* JWT claims at the
687/// boundary. A general typed-claims expression surface is a later slice.
688#[derive(Debug, Clone)]
689pub enum ClaimPredicate {
690 /// `hasClaim("name")` — the claim is present and truthy.
691 HasClaim(String),
692 /// `claimEquals("name", "value")` — the claim string-equals `value`.
693 ClaimEquals(String, String),
694 And(Box<ClaimPredicate>, Box<ClaimPredicate>),
695 Or(Box<ClaimPredicate>, Box<ClaimPredicate>),
696 Not(Box<ClaimPredicate>),
697}
698
699fn claim_str_lit(e: &Expr) -> Option<String> {
700 match &e.kind {
701 ExprKind::StrLit(s) => Some(s.clone()),
702 _ => None,
703 }
704}
705
706/// Recognise the closed claim-predicate vocabulary in a refinement `where`
707/// expression. `Err(span)` points at the first sub-expression outside the set
708/// (for `bynk.actor.refinement_predicate_unsupported`).
709pub fn parse_claim_predicate(e: &Expr) -> Result<ClaimPredicate, Span> {
710 match &e.kind {
711 ExprKind::Paren(inner) => parse_claim_predicate(inner),
712 ExprKind::BinOp(BinOp::And, l, r) => Ok(ClaimPredicate::And(
713 Box::new(parse_claim_predicate(l)?),
714 Box::new(parse_claim_predicate(r)?),
715 )),
716 ExprKind::BinOp(BinOp::Or, l, r) => Ok(ClaimPredicate::Or(
717 Box::new(parse_claim_predicate(l)?),
718 Box::new(parse_claim_predicate(r)?),
719 )),
720 ExprKind::UnaryOp(UnaryOp::Not, inner) => {
721 Ok(ClaimPredicate::Not(Box::new(parse_claim_predicate(inner)?)))
722 }
723 ExprKind::Call {
724 name,
725 type_args,
726 args,
727 } if type_args.is_empty() => match (name.name.as_str(), args.as_slice()) {
728 ("hasClaim", [a]) => claim_str_lit(a).map(ClaimPredicate::HasClaim).ok_or(a.span),
729 ("claimEquals", [a, b]) => match (claim_str_lit(a), claim_str_lit(b)) {
730 (Some(n), Some(v)) => Ok(ClaimPredicate::ClaimEquals(n, v)),
731 (None, _) => Err(a.span),
732 (_, None) => Err(b.span),
733 },
734 _ => Err(name.span),
735 },
736 _ => Err(e.span),
737 }
738}
739
740/// Lower a claim predicate to a JavaScript boolean expression over `claims_var`
741/// (the verified claims object, `Record<string, unknown>`). Used by the emitter
742/// for the refinement seam's 403 check.
743pub fn claim_predicate_to_js(pred: &ClaimPredicate, claims_var: &str) -> String {
744 match pred {
745 ClaimPredicate::HasClaim(name) => {
746 format!(
747 "globalThis.Boolean({claims_var}[\"{}\"])",
748 js_str_escape(name)
749 )
750 }
751 ClaimPredicate::ClaimEquals(name, value) => format!(
752 "({claims_var}[\"{}\"] === \"{}\")",
753 js_str_escape(name),
754 js_str_escape(value)
755 ),
756 ClaimPredicate::And(l, r) => format!(
757 "({} && {})",
758 claim_predicate_to_js(l, claims_var),
759 claim_predicate_to_js(r, claims_var)
760 ),
761 ClaimPredicate::Or(l, r) => format!(
762 "({} || {})",
763 claim_predicate_to_js(l, claims_var),
764 claim_predicate_to_js(r, claims_var)
765 ),
766 ClaimPredicate::Not(inner) => {
767 format!("(!{})", claim_predicate_to_js(inner, claims_var))
768 }
769 }
770}
771
772fn js_str_escape(s: &str) -> String {
773 s.replace('\\', "\\\\").replace('"', "\\\"")
774}
775
776/// #2019: what a witness claim set requires of one claim. Each need is the
777/// exact set of claim values that meets it, so two needs merge exactly when
778/// their sets intersect. The excluded strings carried by `Truthy`, `Falsy`
779/// and `NotEquals` come from negated `claimEquals`.
780#[derive(Debug, Clone, PartialEq, Eq)]
781enum ClaimNeed {
782 /// String-equal to the value (`claimEquals`).
783 Equals(String),
784 /// Present and truthy (`hasClaim`), and none of these strings.
785 Truthy(BTreeSet<String>),
786 /// Absent or falsy (`!hasClaim`), and none of these strings.
787 Falsy(BTreeSet<String>),
788 /// Anything but these strings (`!claimEquals`), absent included.
789 NotEquals(BTreeSet<String>),
790}
791
792type ClaimSet = std::collections::BTreeMap<String, ClaimNeed>;
793
794/// The intersection of two needs on one claim, or `None` when it is empty.
795fn merge_need(a: &ClaimNeed, b: &ClaimNeed) -> Option<ClaimNeed> {
796 use ClaimNeed::*;
797 let union = |x: &BTreeSet<String>, y: &BTreeSet<String>| x.union(y).cloned().collect();
798 Some(match (a, b) {
799 (Equals(x), Equals(y)) => (x == y).then(|| Equals(x.clone()))?,
800 // A non-empty string is truthy; the empty string is falsy.
801 (Equals(v), Truthy(not)) | (Truthy(not), Equals(v)) => {
802 (!v.is_empty() && !not.contains(v)).then(|| Equals(v.clone()))?
803 }
804 (Equals(v), Falsy(not)) | (Falsy(not), Equals(v)) => {
805 (v.is_empty() && !not.contains(v)).then(|| Equals(v.clone()))?
806 }
807 (Equals(v), NotEquals(not)) | (NotEquals(not), Equals(v)) => {
808 (!not.contains(v)).then(|| Equals(v.clone()))?
809 }
810 (Truthy(_), Falsy(_)) | (Falsy(_), Truthy(_)) => return None,
811 (Truthy(x), Truthy(y) | NotEquals(y)) | (NotEquals(y), Truthy(x)) => Truthy(union(x, y)),
812 (Falsy(x), Falsy(y) | NotEquals(y)) | (NotEquals(y), Falsy(x)) => Falsy(union(x, y)),
813 (NotEquals(x), NotEquals(y)) => NotEquals(union(x, y)),
814 })
815}
816
817/// Merge two claim sets, or `None` when they contradict.
818fn merge_claims(a: &ClaimSet, b: &ClaimSet) -> Option<ClaimSet> {
819 let mut out = a.clone();
820 for (name, need) in b {
821 let merged = match out.get(name) {
822 None => need.clone(),
823 Some(have) => merge_need(have, need)?,
824 };
825 out.insert(name.clone(), merged);
826 }
827 Some(out)
828}
829
830/// Every claim set satisfying `pred` (when `want` is true) or falsifying it,
831/// one per way the `||`/`&&` alternatives can be met. The closed predicate
832/// vocabulary keeps this small.
833fn claim_witnesses(pred: &ClaimPredicate, want: bool) -> Vec<ClaimSet> {
834 let one = |name: &str, need: ClaimNeed| vec![ClaimSet::from([(name.to_string(), need)])];
835 let both = |l: Vec<ClaimSet>, r: Vec<ClaimSet>| {
836 l.iter()
837 .flat_map(|a| r.iter().filter_map(move |b| merge_claims(a, b)))
838 .collect()
839 };
840 let either = |mut l: Vec<ClaimSet>, r: Vec<ClaimSet>| {
841 l.extend(r);
842 l
843 };
844 match (pred, want) {
845 (ClaimPredicate::HasClaim(n), true) => one(n, ClaimNeed::Truthy(BTreeSet::new())),
846 (ClaimPredicate::ClaimEquals(n, v), true) => one(n, ClaimNeed::Equals(v.clone())),
847 (ClaimPredicate::HasClaim(n), false) => one(n, ClaimNeed::Falsy(BTreeSet::new())),
848 (ClaimPredicate::ClaimEquals(n, v), false) => {
849 one(n, ClaimNeed::NotEquals(BTreeSet::from([v.clone()])))
850 }
851 (ClaimPredicate::And(l, r), true) | (ClaimPredicate::Or(l, r), false) => {
852 both(claim_witnesses(l, want), claim_witnesses(r, want))
853 }
854 (ClaimPredicate::Or(l, r), true) | (ClaimPredicate::And(l, r), false) => {
855 either(claim_witnesses(l, want), claim_witnesses(r, want))
856 }
857 (ClaimPredicate::Not(inner), _) => claim_witnesses(inner, !want),
858 }
859}
860
861/// #2019: a JavaScript object literal of JWT claims that satisfies a
862/// refinement actor's predicate, for the `system`-tier test driver to sign
863/// next to `sub` when a case acts as that refinement (`by Admin("root")`):
864/// `hasClaim("admin")` → `{ "admin": true }`, `claimEquals("role", "ops")` →
865/// `{ "role": "ops" }`. The Worker's seam then checks the predicate against
866/// the verified claims exactly as it would a real token's. A `Truthy` need
867/// signs `true`: a boolean is never `===` a string, so it also lies outside
868/// every excluded value. An absent claim meets `Falsy` and `NotEquals`, so
869/// those sign nothing. The search is exact over the closed vocabulary, so
870/// `None` means no claim set satisfies the predicate
871/// (`hasClaim("a") && !hasClaim("a")`).
872pub fn claim_witness_js(pred: &ClaimPredicate) -> Option<String> {
873 let set = claim_witnesses(pred, true).into_iter().next()?;
874 let entries = set
875 .iter()
876 .filter_map(|(name, need)| {
877 let value = match need {
878 ClaimNeed::Truthy(_) => "true".to_string(),
879 ClaimNeed::Equals(v) => format!("\"{}\"", js_str_escape(v)),
880 ClaimNeed::Falsy(_) | ClaimNeed::NotEquals(_) => return None,
881 };
882 Some(format!("\"{}\": {value}", js_str_escape(name)))
883 })
884 .collect::<Vec<_>>();
885 Some(if entries.is_empty() {
886 "{}".to_string()
887 } else {
888 format!("{{ {} }}", entries.join(", "))
889 })
890}
891
892#[cfg(test)]
893mod claim_witness_tests {
894 use super::{ClaimPredicate as P, claim_witness_js};
895
896 fn has(n: &str) -> Box<P> {
897 Box::new(P::HasClaim(n.to_string()))
898 }
899 fn eq(n: &str, v: &str) -> Box<P> {
900 Box::new(P::ClaimEquals(n.to_string(), v.to_string()))
901 }
902
903 #[test]
904 fn a_single_claim_is_signed() {
905 assert_eq!(
906 claim_witness_js(&has("admin")).as_deref(),
907 Some("{ \"admin\": true }")
908 );
909 assert_eq!(
910 claim_witness_js(&eq("role", "ops")).as_deref(),
911 Some("{ \"role\": \"ops\" }")
912 );
913 }
914
915 #[test]
916 fn a_conjunction_signs_both_and_a_disjunction_its_first_arm() {
917 assert_eq!(
918 claim_witness_js(&P::And(has("a"), eq("role", "ops"))).as_deref(),
919 Some("{ \"a\": true, \"role\": \"ops\" }")
920 );
921 assert_eq!(
922 claim_witness_js(&P::Or(has("a"), has("b"))).as_deref(),
923 Some("{ \"a\": true }")
924 );
925 }
926
927 #[test]
928 fn a_negation_leaves_the_claim_out_and_steers_the_disjunction() {
929 assert_eq!(
930 claim_witness_js(&P::Not(has("banned"))).as_deref(),
931 Some("{}")
932 );
933 // `(a || b) && !a` is met only by `b`.
934 assert_eq!(
935 claim_witness_js(&P::And(
936 Box::new(P::Or(has("a"), has("b"))),
937 Box::new(P::Not(has("a")))
938 ))
939 .as_deref(),
940 Some("{ \"b\": true }")
941 );
942 // `!(a && b)` is met by leaving `a` out.
943 assert_eq!(
944 claim_witness_js(&P::Not(Box::new(P::And(has("a"), has("b"))))).as_deref(),
945 Some("{}")
946 );
947 }
948
949 #[test]
950 fn a_negated_equality_excludes_one_value_only() {
951 let not = |p: Box<P>| Box::new(P::Not(p));
952 // `hasClaim("role") && !claimEquals("role", "banned")`: any truthy
953 // non-"banned" value; `true` is never `===` a string.
954 assert_eq!(
955 claim_witness_js(&P::And(has("role"), not(eq("role", "banned")))).as_deref(),
956 Some("{ \"role\": true }")
957 );
958 // `claimEquals("role", "ops") && !claimEquals("role", "admin")`.
959 assert_eq!(
960 claim_witness_js(&P::And(eq("role", "ops"), not(eq("role", "admin")))).as_deref(),
961 Some("{ \"role\": \"ops\" }")
962 );
963 // `!claimEquals("role", "banned")` alone: leave the claim out.
964 assert_eq!(
965 claim_witness_js(&P::Not(eq("role", "banned"))).as_deref(),
966 Some("{}")
967 );
968 // `!claimEquals("r", "x") && !hasClaim("r")`: absent meets both.
969 assert_eq!(
970 claim_witness_js(&P::And(not(eq("r", "x")), not(has("r")))).as_deref(),
971 Some("{}")
972 );
973 }
974
975 #[test]
976 fn an_excluded_value_still_contradicts_its_own_equality() {
977 let not = |p: Box<P>| Box::new(P::Not(p));
978 // `hasClaim("r") && !claimEquals("r", "x") && claimEquals("r", "x")`.
979 assert_eq!(
980 claim_witness_js(&P::And(
981 Box::new(P::And(has("r"), not(eq("r", "x")))),
982 eq("r", "x")
983 )),
984 None
985 );
986 // An empty string is falsy, so it cannot meet `hasClaim`.
987 assert_eq!(claim_witness_js(&P::And(has("r"), eq("r", ""))), None);
988 assert_eq!(
989 claim_witness_js(&P::And(not(has("r")), eq("r", ""))).as_deref(),
990 Some("{ \"r\": \"\" }")
991 );
992 }
993
994 #[test]
995 fn a_contradiction_has_no_witness() {
996 assert_eq!(
997 claim_witness_js(&P::And(has("a"), Box::new(P::Not(has("a"))))),
998 None
999 );
1000 assert_eq!(claim_witness_js(&P::And(eq("r", "x"), eq("r", "y"))), None);
1001 }
1002}