Skip to main content

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::HashMap;
15
16use bynk_syntax::ast::{
17    ActorDecl, BinOp, ByClause, Expr, ExprKind, Handler, HandlerKind, ServiceProtocol, TypeRef,
18    UnaryOp,
19};
20use bynk_syntax::span::Span;
21
22/// The authentication scheme — a closed, compiler-known set (ADR Q1). Sealed
23/// now, openable later by widening this enum.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum Scheme {
26    /// Anonymous — no verification; identity is `()`. (`Visitor`.)
27    None,
28    /// In-system / platform trust — the channel itself is the assertion
29    /// (service-binding / platform dispatch). Admitted in Foundations.
30    Internal,
31    /// Bearer token — compiler-generated JWT/HS256 verification (ADR 0085).
32    Bearer,
33    /// Request signature — HMAC-SHA256 over the body (ADR 0089).
34    Signature,
35    /// OIDC/JWKS — compiler-generated verification of an asymmetrically-signed
36    /// (RS256/ES256) JWT against a provider's published JWKS, checking
37    /// `iss`/`aud`/`exp`/`nbf` and minting the identity from `sub` (ADR 0175).
38    /// The first scheme opened via the "user-configured verifier" route: the
39    /// trust root is the provider's public key set (a URL), so the declaration
40    /// carries **no inline secret** — only public trust parameters.
41    Oidc,
42}
43
44impl Scheme {
45    /// Classify a scheme name written in `auth = <Scheme>`. `None` means the
46    /// name is not one of the compiler-known schemes.
47    pub fn from_name(s: &str) -> Option<Scheme> {
48        Some(match s {
49            "None" => Scheme::None,
50            "Internal" => Scheme::Internal,
51            "Bearer" => Scheme::Bearer,
52            "Signature" => Scheme::Signature,
53            "Oidc" => Scheme::Oidc,
54            _ => return None,
55        })
56    }
57
58    /// The schemes the compiler can emit verification for. v0.45 admitted the
59    /// two zero-crypto schemes (`None`/`Internal`); v0.47 added `Bearer`
60    /// (JWT/HS256); v0.51 added `Signature` (HMAC over the body); v0.151 adds
61    /// `Oidc` (JWKS/RS256+ES256). All five schemes are now admitted.
62    pub fn admitted(self) -> bool {
63        matches!(
64            self,
65            Scheme::None | Scheme::Internal | Scheme::Bearer | Scheme::Signature | Scheme::Oidc
66        )
67    }
68
69    pub fn as_str(self) -> &'static str {
70        match self {
71            Scheme::None => "None",
72            Scheme::Internal => "Internal",
73            Scheme::Bearer => "Bearer",
74            Scheme::Signature => "Signature",
75            Scheme::Oidc => "Oidc",
76        }
77    }
78}
79
80/// The identity a verified actor yields (ADR Q2). In Foundations this is `()`
81/// for trivial actors, the built-in sealed `CallerId` for the cross-context
82/// `Internal` channel (Q7, folded in), or a context-owned declared type.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum Identity {
85    /// `()` — `None` actors and platform-tag `Internal` actors.
86    Unit,
87    /// The built-in sealed calling-context identity (Q7). Minted at the
88    /// service-binding seam; read-only and never re-checked.
89    CallerId,
90    /// A context-owned declared type named in `identity = <T>`.
91    Declared(String),
92}
93
94/// The built-in sealed identity type for the cross-context calling principal.
95pub const CALLER_ID: &str = "CallerId";
96
97/// A resolved actor contract: its scheme and the identity it yields.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Contract {
100    pub scheme: Scheme,
101    pub identity: Identity,
102}
103
104/// The prelude actors — compiler-known boundary contracts available without a
105/// declaration. They back the per-protocol defaults and let public HTTP routes
106/// write `by v: Visitor` without ceremony.
107pub fn prelude_actor(name: &str) -> Option<Contract> {
108    Some(match name {
109        // Anonymous public surface — the only safe HTTP actor in Foundations.
110        "Visitor" => Contract {
111            scheme: Scheme::None,
112            identity: Identity::Unit,
113        },
114        // Platform schedulers / producers — Internal, carrying no useful
115        // identity payload (a bare tag).
116        "Scheduler" | "Producer" => Contract {
117            scheme: Scheme::Internal,
118            identity: Identity::Unit,
119        },
120        // The cross-context calling principal — Internal, yielding the sealed
121        // `CallerId` (Q7).
122        "Caller" => Contract {
123            scheme: Scheme::Internal,
124            identity: Identity::CallerId,
125        },
126        _ => return None,
127    })
128}
129
130/// The default actor a handler inherits when it omits `by`, by protocol (ADR
131/// Q5). HTTP has no safe default — `by` is required there.
132pub fn default_actor(protocol: &ServiceProtocol) -> Option<&'static str> {
133    match protocol {
134        ServiceProtocol::Call => Some("Caller"),
135        ServiceProtocol::Cron => Some("Scheduler"),
136        ServiceProtocol::Queue { .. } => Some("Producer"),
137        // Events track, slice 0 (spine #936): delivery is runtime-triggered,
138        // not an external caller — like `Queue`'s "Producer", the default
139        // actor names the event's originating publisher.
140        ServiceProtocol::Events { .. } => Some("Publisher"),
141        // v0.103: like HTTP, a WebSocket upgrade has no safe default actor —
142        // `by` is mandatory on `on open` (edge auth before accept, D-A).
143        ServiceProtocol::Http | ServiceProtocol::WebSocket { .. } => None,
144    }
145}
146
147/// v0.47: the data the emitter needs to lower a Bearer verification seam for a
148/// handler — the `by` binder (v0.50: `None` for the binder-less verify-and-
149/// discard form), the signing-secret env name, and the identity type to
150/// construct from the JWT `sub` claim. Resolved only for a handler whose `by`
151/// clause names a local Bearer actor; the checker guarantees the secret is
152/// present and the identity is a string-constructible local type.
153#[derive(Debug, Clone)]
154pub struct BearerSeam {
155    /// The identity binder, or `None` for `by <BearerActor>` (verify the token,
156    /// don't capture the identity). When `None` the seam still verifies fail-
157    /// closed but mints no identity and threads nothing into `deps`.
158    pub binder: Option<String>,
159    pub secret: String,
160    pub identity_type: String,
161    /// v0.53: the authorisation invariant when the `by` actor is a refinement
162    /// (`actor Admin = User where <pred>`). The seam verifies the scheme (401),
163    /// then checks this predicate against the verified claims (403 fail-closed),
164    /// then mints the (base) identity. `None` for a plain Bearer actor.
165    pub authorization: Option<ClaimPredicate>,
166}
167
168/// Resolve a handler's Bearer seam, if its `by` clause names a local Bearer
169/// actor — or a **refinement** of one (v0.53), following the refinement to its
170/// base for the scheme/secret/identity and carrying the authorisation
171/// predicate. Returns `None` for non-Bearer handlers (prelude actors are never
172/// Bearer) — those emit unchanged.
173/// #706: whether a `by` clause names a **Bearer**-secured actor (following a
174/// refinement to its base). The routes for which `by Nobody` can drive the auth
175/// seam to a `401` — an unsecured (`Visitor`/`None`) or `Signature`/`Oidc` route
176/// has no Bearer seam the test driver knows how to leave unauthenticated, so
177/// `by Nobody` there is rejected (`bynk.test.nobody_needs_secured_route`). The
178/// scheme check mirrors [`bearer_seam_for`].
179pub fn by_clause_is_bearer(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
180    let Some(named) = actors.get(&by.primary().name) else {
181        return false;
182    };
183    let base = match &named.refinement {
184        Some(r) => match actors.get(&r.base.name) {
185            Some(b) => b,
186            None => return false,
187        },
188        None => named,
189    };
190    base.auth
191        .as_ref()
192        .and_then(|a| Scheme::from_name(a.name.as_str()))
193        == Some(Scheme::Bearer)
194}
195
196pub fn bearer_seam_for(
197    handler: &Handler,
198    actors: &HashMap<String, ActorDecl>,
199) -> Option<BearerSeam> {
200    let by = handler.by_clause.as_ref()?;
201    let named = actors.get(&by.primary().name)?;
202    // Follow a refinement to its base; carry the authorisation predicate. The
203    // checker guarantees a refinement's base is Bearer and its predicate parses.
204    let (base, authorization) = match &named.refinement {
205        Some(r) => (
206            actors.get(&r.base.name)?,
207            parse_claim_predicate(&r.predicate).ok(),
208        ),
209        None => (named, None),
210    };
211    if Scheme::from_name(base.auth.as_ref()?.name.as_str()) != Some(Scheme::Bearer) {
212        return None;
213    }
214    let secret = base.scheme_arg("secret")?.value.as_str()?.to_string();
215    let TypeRef::Named(id) = base.identity.as_ref()? else {
216        return None;
217    };
218    Some(BearerSeam {
219        binder: by.binder.as_ref().map(|b| b.name.clone()),
220        secret,
221        identity_type: id.name.clone(),
222        authorization,
223    })
224}
225
226/// v0.151: the data the emitter needs to lower an OIDC/JWKS verification seam —
227/// the `by` binder (or `None` for the verify-and-discard form), the public
228/// trust parameters (`issuer`, `audience`, `jwks` URL), and the identity type
229/// to construct from the verified `sub` claim. Resolved only for a handler
230/// whose `by` clause names a local `Oidc` actor. Unlike Bearer/Signature, the
231/// seam carries **no secret env name** — the trust root is the provider's
232/// published public key set (`jwks`).
233#[derive(Debug, Clone)]
234pub struct OidcSeam {
235    /// The identity binder, or `None` for `by <OidcActor>` (verify, don't
236    /// capture the identity). When `None` the seam still verifies fail-closed
237    /// but mints no identity and threads nothing into `deps`.
238    pub binder: Option<String>,
239    /// The expected `iss` claim (and the provider whose JWKS anchors trust).
240    pub issuer: String,
241    /// The expected `aud` claim (this API's audience identifier).
242    pub audience: String,
243    /// The JWKS endpoint URL the verifier fetches signing keys from.
244    pub jwks: String,
245    /// The context-owned, string-constructible identity type minted from `sub`.
246    pub identity_type: String,
247}
248
249/// Resolve a handler's OIDC seam, if its `by` clause names a single local
250/// `Oidc` actor. Returns `None` for non-Oidc handlers, for a multi-actor `by`
251/// clause (Oidc is single-actor this slice), and for a refinement (refinement
252/// over Oidc is a later slice) — those follow the existing seam paths. The
253/// checker guarantees `issuer`/`audience`/`jwks` are present and the identity
254/// is a string-constructible local type.
255pub fn oidc_seam_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<OidcSeam> {
256    let by = handler.by_clause.as_ref()?;
257    if by.is_sum() {
258        return None;
259    }
260    let actor = actors.get(&by.primary().name)?;
261    // A refinement's `auth` is `None`; it falls through here and follows the
262    // Bearer refinement path (or is rejected). An Oidc base is not narrowed.
263    if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Oidc) {
264        return None;
265    }
266    let issuer = actor.scheme_arg("issuer")?.value.as_str()?.to_string();
267    let audience = actor.scheme_arg("audience")?.value.as_str()?.to_string();
268    let jwks = actor.scheme_arg("jwks")?.value.as_str()?.to_string();
269    let TypeRef::Named(id) = actor.identity.as_ref()? else {
270        return None;
271    };
272    Some(OidcSeam {
273        binder: by.binder.as_ref().map(|b| b.name.clone()),
274        issuer,
275        audience,
276        jwks,
277        identity_type: id.name.clone(),
278    })
279}
280
281/// v0.54: the binder of a cross-context `on call … by c: Caller` handler that
282/// captures a live `CallerId` (the calling context's name, Q7). `None` unless
283/// the handler binds an identity whose contract is `CallerId` — i.e. the
284/// `Caller` prelude actor (the only source of `CallerId`). A binder-less
285/// `on call` (or one inheriting the `Caller` default) captures nothing and is
286/// unaffected.
287pub fn caller_binder_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<String> {
288    // `CallerId` is a cross-context `on call` concept; the checker rejects a
289    // `Caller` actor on other protocols (`scheme_not_admissible`), but guard here
290    // too so the caller seam is never emitted off the call path.
291    if !matches!(handler.kind, HandlerKind::Call) {
292        return None;
293    }
294    let by = handler.by_clause.as_ref()?;
295    let binder = by.binder.as_ref()?;
296    let name = &by.primary().name;
297    // `CallerId` is yielded only by the `Caller` prelude actor; a local actor
298    // never declares it. A binder that collides with a param is suppressed
299    // upstream, mirroring the other seams.
300    let is_caller = !actors.contains_key(name)
301        && prelude_actor(name).map(|c| c.identity) == Some(Identity::CallerId)
302        && !handler.params.iter().any(|p| p.name.name == binder.name);
303    is_caller.then(|| binder.name.clone())
304}
305
306/// v0.51: the data the emitter needs to lower a Signature verification seam —
307/// the signing-secret env name, the signature header, and an optional
308/// timestamp header + tolerance window for replay defence. Resolved only for a
309/// handler whose `by` clause names a local Signature actor.
310#[derive(Debug, Clone)]
311pub struct SignatureSeam {
312    pub secret: String,
313    pub header: String,
314    pub timestamp_header: Option<String>,
315    pub tolerance_secs: Option<i64>,
316}
317
318/// Resolve a handler's Signature seam, if its `by` clause names a local
319/// Signature actor. The checker guarantees `secret` and `header` are present.
320pub fn signature_seam_for(
321    handler: &Handler,
322    actors: &HashMap<String, ActorDecl>,
323) -> Option<SignatureSeam> {
324    let by = handler.by_clause.as_ref()?;
325    let actor = actors.get(&by.primary().name)?;
326    if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Signature) {
327        return None;
328    }
329    signature_seam_from_decl(actor)
330}
331
332/// The Signature seam data carried by an actor declaration (its keyed config).
333/// Shared by the single-actor `signature_seam_for` and the multi-actor
334/// `sum_members_for`.
335fn signature_seam_from_decl(actor: &ActorDecl) -> Option<SignatureSeam> {
336    Some(SignatureSeam {
337        secret: actor.scheme_arg("secret")?.value.as_str()?.to_string(),
338        header: actor.scheme_arg("header")?.value.as_str()?.to_string(),
339        timestamp_header: actor
340            .scheme_arg("timestamp")
341            .and_then(|a| a.value.as_str())
342            .map(str::to_string),
343        tolerance_secs: actor.scheme_arg("tolerance").and_then(|a| a.value.as_int()),
344    })
345}
346
347/// v0.52: one resolved member of a multi-actor sum — the seam the emitter tries
348/// at that position in the first-wins order. `actor_name` is the variant tag the
349/// body matches on.
350#[derive(Debug, Clone)]
351pub struct SumMember {
352    pub actor_name: String,
353    pub seam: SumMemberSeam,
354}
355
356/// The verification a sum member contributes. `None` (a catch-all such as
357/// `Visitor`) always resolves, so it terminates the order.
358#[derive(Debug, Clone)]
359pub enum SumMemberSeam {
360    None,
361    Bearer {
362        secret: String,
363        identity_type: String,
364    },
365    Signature(SignatureSeam),
366}
367
368impl SumMember {
369    /// Whether resolving this member needs the raw request body read.
370    pub fn needs_body(&self) -> bool {
371        matches!(self.seam, SumMemberSeam::Signature(_))
372    }
373    /// The member's identity type name, if it mints one (Bearer). `None`/
374    /// Signature members carry a unit identity.
375    pub fn identity_type(&self) -> Option<&str> {
376        match &self.seam {
377            SumMemberSeam::Bearer { identity_type, .. } => Some(identity_type),
378            _ => None,
379        }
380    }
381}
382
383/// v0.52: resolve a handler's `by` clause into ordered sum members, if it names
384/// more than one actor. `None` for a single-actor handler (those keep the
385/// existing seam paths). The checker has already validated peer/scheme/
386/// reachability rules; this lowers the verified members for emission.
387pub fn sum_members_for(
388    handler: &Handler,
389    actors: &HashMap<String, ActorDecl>,
390) -> Option<Vec<SumMember>> {
391    let by = handler.by_clause.as_ref()?;
392    if !by.is_sum() {
393        return None;
394    }
395    let mut members = Vec::new();
396    for actor_ref in &by.actors {
397        let seam = if let Some(decl) = actors.get(&actor_ref.name) {
398            match Scheme::from_name(decl.auth.as_ref()?.name.as_str())? {
399                Scheme::None => SumMemberSeam::None,
400                Scheme::Bearer => {
401                    let secret = decl.scheme_arg("secret")?.value.as_str()?.to_string();
402                    let TypeRef::Named(id) = decl.identity.as_ref()? else {
403                        return None;
404                    };
405                    SumMemberSeam::Bearer {
406                        secret,
407                        identity_type: id.name.clone(),
408                    }
409                }
410                Scheme::Signature => SumMemberSeam::Signature(signature_seam_from_decl(decl)?),
411                // v0.151: `Oidc` is single-actor only this slice — the checker
412                // rejects it as a sum member (`bynk.actor.oidc_not_in_sum`), so
413                // a well-formed program never reaches here with an Oidc peer.
414                // Return `None` (fail closed → no sum emission) defensively.
415                Scheme::Oidc => return None,
416                Scheme::Internal => return None,
417            }
418        } else {
419            // A prelude actor: only `Visitor` (scheme `None`) is an HTTP peer.
420            match prelude_actor(&actor_ref.name) {
421                Some(c) if c.scheme == Scheme::None => SumMemberSeam::None,
422                _ => return None,
423            }
424        };
425        members.push(SumMember {
426            actor_name: actor_ref.name.clone(),
427            seam,
428        });
429    }
430    Some(members)
431}
432
433/// Whether `scheme` is admissible on `protocol` (the admissible-scheme-per-
434/// protocol check). HTTP admits `None` (public routes) and `Bearer` (an
435/// `Authorization` header is an HTTP concept); the internal protocols
436/// (call/cron/queue) admit `Internal`. `Signature` is still reserved.
437pub fn scheme_admissible(protocol: &ServiceProtocol, scheme: Scheme) -> bool {
438    match protocol {
439        ServiceProtocol::Http => {
440            matches!(
441                scheme,
442                Scheme::None | Scheme::Bearer | Scheme::Signature | Scheme::Oidc
443            )
444        }
445        // v0.103 (D-B): a WebSocket upgrade authenticates via `None` (anonymous)
446        // or `Bearer` — but the token is read from the `Sec-WebSocket-Protocol`
447        // subprotocol, since a browser `WebSocket` cannot set an `Authorization`
448        // header. `Signature` is rejected at the WS boundary: HMAC-over-body has
449        // no body on a handshake.
450        ServiceProtocol::WebSocket { .. } => {
451            matches!(scheme, Scheme::None | Scheme::Bearer)
452        }
453        // Events track, slice 0 (spine #936): delivery is an internal,
454        // runtime-triggered invocation, like `Call`/`Cron`/`Queue` — no
455        // external network request, so no external auth scheme applies.
456        ServiceProtocol::Call
457        | ServiceProtocol::Cron
458        | ServiceProtocol::Queue { .. }
459        | ServiceProtocol::Events { .. } => {
460            matches!(scheme, Scheme::Internal)
461        }
462    }
463}
464
465/// v0.53: the closed claim-predicate vocabulary for a refinement actor's `where`
466/// clause (`actor Admin = User where hasClaim("admin")`). Claims are untyped
467/// JSON, so the predicate is a closed set — `hasClaim`/`claimEquals` composed
468/// with `&&`/`||`/`!` — checked against the *verified* JWT claims at the
469/// boundary. A general typed-claims expression surface is a later slice.
470#[derive(Debug, Clone)]
471pub enum ClaimPredicate {
472    /// `hasClaim("name")` — the claim is present and truthy.
473    HasClaim(String),
474    /// `claimEquals("name", "value")` — the claim string-equals `value`.
475    ClaimEquals(String, String),
476    And(Box<ClaimPredicate>, Box<ClaimPredicate>),
477    Or(Box<ClaimPredicate>, Box<ClaimPredicate>),
478    Not(Box<ClaimPredicate>),
479}
480
481fn claim_str_lit(e: &Expr) -> Option<String> {
482    match &e.kind {
483        ExprKind::StrLit(s) => Some(s.clone()),
484        _ => None,
485    }
486}
487
488/// Recognise the closed claim-predicate vocabulary in a refinement `where`
489/// expression. `Err(span)` points at the first sub-expression outside the set
490/// (for `bynk.actor.refinement_predicate_unsupported`).
491pub fn parse_claim_predicate(e: &Expr) -> Result<ClaimPredicate, Span> {
492    match &e.kind {
493        ExprKind::Paren(inner) => parse_claim_predicate(inner),
494        ExprKind::BinOp(BinOp::And, l, r) => Ok(ClaimPredicate::And(
495            Box::new(parse_claim_predicate(l)?),
496            Box::new(parse_claim_predicate(r)?),
497        )),
498        ExprKind::BinOp(BinOp::Or, l, r) => Ok(ClaimPredicate::Or(
499            Box::new(parse_claim_predicate(l)?),
500            Box::new(parse_claim_predicate(r)?),
501        )),
502        ExprKind::UnaryOp(UnaryOp::Not, inner) => {
503            Ok(ClaimPredicate::Not(Box::new(parse_claim_predicate(inner)?)))
504        }
505        ExprKind::Call {
506            name,
507            type_args,
508            args,
509        } if type_args.is_empty() => match (name.name.as_str(), args.as_slice()) {
510            ("hasClaim", [a]) => claim_str_lit(a).map(ClaimPredicate::HasClaim).ok_or(a.span),
511            ("claimEquals", [a, b]) => match (claim_str_lit(a), claim_str_lit(b)) {
512                (Some(n), Some(v)) => Ok(ClaimPredicate::ClaimEquals(n, v)),
513                (None, _) => Err(a.span),
514                (_, None) => Err(b.span),
515            },
516            _ => Err(name.span),
517        },
518        _ => Err(e.span),
519    }
520}
521
522/// Lower a claim predicate to a JavaScript boolean expression over `claims_var`
523/// (the verified claims object, `Record<string, unknown>`). Used by the emitter
524/// for the refinement seam's 403 check.
525pub fn claim_predicate_to_js(pred: &ClaimPredicate, claims_var: &str) -> String {
526    match pred {
527        ClaimPredicate::HasClaim(name) => {
528            format!("Boolean({claims_var}[\"{}\"])", js_str_escape(name))
529        }
530        ClaimPredicate::ClaimEquals(name, value) => format!(
531            "({claims_var}[\"{}\"] === \"{}\")",
532            js_str_escape(name),
533            js_str_escape(value)
534        ),
535        ClaimPredicate::And(l, r) => format!(
536            "({} && {})",
537            claim_predicate_to_js(l, claims_var),
538            claim_predicate_to_js(r, claims_var)
539        ),
540        ClaimPredicate::Or(l, r) => format!(
541            "({} || {})",
542            claim_predicate_to_js(l, claims_var),
543            claim_predicate_to_js(r, claims_var)
544        ),
545        ClaimPredicate::Not(inner) => {
546            format!("(!{})", claim_predicate_to_js(inner, claims_var))
547        }
548    }
549}
550
551fn js_str_escape(s: &str) -> String {
552    s.replace('\\', "\\\\").replace('"', "\\\"")
553}