Skip to main content

tollgate_server/
security.rs

1//! Control-plane identities, route authorization, and operator-approved provisioner policies.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use arc_swap::ArcSwap;
7use axum::extract::{FromRequestParts, MatchedPath, OriginalUri, Request, State};
8use axum::http::{header, request::Parts};
9use axum::middleware::Next;
10use axum::response::Response;
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13use tollgate_auth::CredentialVerifier;
14use tollgate_core::{
15    AccountId, AccountSnapshot, AccountStatus, CostTable, CostUnits, EnforcementMode, Generation,
16    PermissionBits, PolicyRevision, Principal, PublishableSnapshot, ResolvedLimits,
17};
18use tollgate_store::Clock;
19
20use crate::error::ApiError;
21use crate::transport::{PeerIdentity, TlsConfig};
22
23/// Roles are disjoint. An operator credential cannot fund an instance, and a
24/// provisioner reaches only the self-service subset of the admin API (#39).
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum Role {
28    /// A service instance: lease lifecycle, snapshot and principal reads,
29    /// the active-credential projection (`GET /v1/keys`) and usage ingestion.
30    Instance,
31    /// An operator: every route under `/v1/admin`, including account
32    /// funding, status, budgets, credentials and snapshot publication.
33    Operator,
34    /// A self-service account service (#39): creates unfunded, suspended,
35    /// best-effort accounts, activates them, sets budgets within its
36    /// ceiling, and manages their credentials, on accounts a provisioner
37    /// created only. It can never fund, suspend, close, grant `Assured`, or
38    /// publish principal snapshots.
39    Provisioner,
40}
41
42impl Role {
43    /// The configuration spelling, which is also the audit spelling.
44    pub fn as_str(self) -> &'static str {
45        match self {
46            Role::Instance => "instance",
47            Role::Operator => "operator",
48            Role::Provisioner => "provisioner",
49        }
50    }
51}
52
53/// The exact policy an operator permits a provisioner to publish.
54///
55/// Runtime binding, validity and store-owned fields are not template inputs.
56/// Strict enforcement is mandatory even when a template otherwise matches.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ProvisionerPolicyTemplate {
60    /// Complete pricing schedule, including per-operation permissions.
61    pub cost_table: Arc<CostTable>,
62    /// Every shape, rate, compatibility and concurrency limit.
63    pub limits: ResolvedLimits,
64    /// Routes/work the credential may access.
65    pub permissions: PermissionBits,
66    /// Policy attribution must name the approved policy too.
67    #[serde(default)]
68    pub policy_revision: PolicyRevision,
69}
70
71impl ProvisionerPolicyTemplate {
72    pub(crate) fn validate(&self) -> Result<(), SecurityError> {
73        let snapshot = AccountSnapshot::builder(
74            AccountId(0),
75            Generation(0),
76            AccountStatus::Active,
77            Timestamp::UNIX_EPOCH,
78            self.permissions,
79            self.limits,
80            Arc::clone(&self.cost_table),
81        )
82        .build();
83        PublishableSnapshot::try_new(Arc::new(snapshot))
84            .map(|_| ())
85            .map_err(|_| SecurityError("invalid provisioner policy template"))
86    }
87
88    fn matches(&self, snapshot: &AccountSnapshot) -> bool {
89        snapshot.enforcement_mode == EnforcementMode::Strict
90            && snapshot.cost_table == self.cost_table
91            && snapshot.limits == self.limits
92            && snapshot.permissions == self.permissions
93            && snapshot.policy_revision == self.policy_revision
94    }
95}
96
97/// What a provisioner identity may grant beyond its fixed route scope.
98///
99/// A budget ceiling and a nonempty approved policy set are mandatory. An
100/// identity without either cannot be constructed (#39, #43).
101#[derive(Debug, Clone, Eq)]
102pub struct ProvisionerLimits {
103    max_budget_allowance: CostUnits,
104    policy_templates: Arc<[ProvisionerPolicyTemplate]>,
105}
106
107// Credential schemes naming one identity must agree on its authority. List
108// ordering and duplicate approvals do not change that authority.
109impl PartialEq for ProvisionerLimits {
110    fn eq(&self, other: &Self) -> bool {
111        self.max_budget_allowance == other.max_budget_allowance
112            && self
113                .policy_templates
114                .iter()
115                .all(|policy| other.policy_templates.contains(policy))
116            && other
117                .policy_templates
118                .iter()
119                .all(|policy| self.policy_templates.contains(policy))
120    }
121}
122
123impl ProvisionerLimits {
124    /// Bound the periodic allowance and approve complete snapshot policies.
125    ///
126    /// # Errors
127    /// Refuses an empty policy set or a policy that cannot pass snapshot
128    /// validation. Zero-cost policy is permitted only by explicitly approving it.
129    pub fn new(
130        max_budget_allowance: CostUnits,
131        policy_templates: Vec<ProvisionerPolicyTemplate>,
132    ) -> Result<Self, SecurityError> {
133        if policy_templates.is_empty() {
134            return Err(SecurityError(
135                "a provisioner identity requires approved policy templates",
136            ));
137        }
138        for template in &policy_templates {
139            template.validate()?;
140        }
141        Ok(Self {
142            max_budget_allowance,
143            policy_templates: policy_templates.into(),
144        })
145    }
146
147    /// The largest periodic allowance this identity may set, inclusive.
148    pub fn max_budget_allowance(&self) -> CostUnits {
149        self.max_budget_allowance
150    }
151
152    /// Whether one complete approved template matches this strict snapshot.
153    /// Fields from different templates cannot be mixed into a new policy.
154    pub fn allows_snapshot(&self, snapshot: &AccountSnapshot) -> bool {
155        self.policy_templates
156            .iter()
157            .any(|template| template.matches(snapshot))
158    }
159}
160
161/// A role with whatever it carries. Limits exist exactly for a provisioner,
162/// so an operator with a ceiling or a provisioner without one is
163/// unrepresentable.
164#[derive(Debug, Clone, PartialEq, Eq)]
165enum Grant {
166    Instance,
167    Operator,
168    Provisioner(ProvisionerLimits),
169}
170
171/// A stable, non-secret audit identity chosen by the deployment.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ControlIdentity {
174    name: String,
175    grant: Grant,
176}
177
178impl ControlIdentity {
179    /// An instance or operator identity named `name`. A provisioner needs its
180    /// limits, so it is built with [`ControlIdentity::provisioner`].
181    ///
182    /// # Errors
183    ///
184    /// Returns a [`SecurityError`] for [`Role::Provisioner`], or unless
185    /// `name` is 1 to 128 ASCII characters, each alphanumeric or one of
186    /// `@ . _ : / -`.
187    pub fn new(name: impl Into<String>, role: Role) -> Result<Self, SecurityError> {
188        let grant = match role {
189            Role::Instance => Grant::Instance,
190            Role::Operator => Grant::Operator,
191            Role::Provisioner => {
192                return Err(SecurityError(
193                    "a provisioner identity requires budget and approved policy limits",
194                ));
195            }
196        };
197        Self::with_grant(name.into(), grant)
198    }
199
200    /// A provisioner identity named `name`, bounded by `limits` (#39).
201    ///
202    /// # Errors
203    ///
204    /// Returns a [`SecurityError`] for a `name` [`ControlIdentity::new`]
205    /// would refuse.
206    pub fn provisioner(
207        name: impl Into<String>,
208        limits: ProvisionerLimits,
209    ) -> Result<Self, SecurityError> {
210        Self::with_grant(name.into(), Grant::Provisioner(limits))
211    }
212
213    fn with_grant(name: String, grant: Grant) -> Result<Self, SecurityError> {
214        if name.is_empty()
215            || name.len() > 128
216            || !name
217                .bytes()
218                .all(|b| b.is_ascii_alphanumeric() || b"@._:/-".contains(&b))
219        {
220            return Err(SecurityError(
221                "identity must be 1..=128 ASCII identifier characters",
222            ));
223        }
224        Ok(Self { name, grant })
225    }
226
227    /// The audit name, recorded as the actor on authentication and
228    /// administrative audit events.
229    pub fn name(&self) -> &str {
230        &self.name
231    }
232    /// The one role this identity holds.
233    pub fn role(&self) -> Role {
234        match &self.grant {
235            Grant::Instance => Role::Instance,
236            Grant::Operator => Role::Operator,
237            Grant::Provisioner(_) => Role::Provisioner,
238        }
239    }
240    /// The provisioner's limits; `None` for every other role.
241    pub fn provisioner_limits(&self) -> Option<ProvisionerLimits> {
242        match &self.grant {
243            Grant::Provisioner(limits) => Some(limits.clone()),
244            Grant::Instance | Grant::Operator => None,
245        }
246    }
247}
248
249/// A refused security configuration, credential file, TLS material or key
250/// set.
251///
252/// The message is a static description of which check failed. It never
253/// contains credential, key or token material, so it is safe to log.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub struct SecurityError(pub &'static str);
256
257impl std::fmt::Display for SecurityError {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.write_str(self.0)
260    }
261}
262impl std::error::Error for SecurityError {}
263
264struct BearerScheme {
265    verifier: Arc<dyn CredentialVerifier + Send + Sync>,
266    identities: HashMap<Principal, ControlIdentity>,
267}
268
269/// An immutable, validated mapping. Publish verification and authorization together;
270/// rotating one without the other would temporarily assign old credentials new roles.
271#[derive(Default)]
272pub struct SecurityPolicy {
273    bearers: Vec<BearerScheme>,
274    certificates: HashMap<[u8; 32], ControlIdentity>,
275}
276
277impl SecurityPolicy {
278    /// A policy with no credentials. It denies every protected operation.
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    /// Adds a bearer scheme: `verifier` authenticates the presented token,
284    /// and `identities` maps each principal it verifies as to an identity.
285    ///
286    /// A request's bearer token is offered to every scheme. It is refused
287    /// with `401` if no scheme verifies it, if a verifying scheme's evidence
288    /// is no longer reusable at the server's current time, or if two schemes
289    /// resolve it to different identities. A verified principal with no
290    /// mapping in its scheme is refused with `403` (INVARIANTS.md 32).
291    ///
292    /// # Errors
293    ///
294    /// Returns a [`SecurityError`] if `identities` names a principal twice.
295    pub fn with_bearer(
296        mut self,
297        verifier: Arc<dyn CredentialVerifier + Send + Sync>,
298        identities: impl IntoIterator<Item = (Principal, ControlIdentity)>,
299    ) -> Result<Self, SecurityError> {
300        let mut mapped = HashMap::new();
301        for (principal, identity) in identities {
302            if mapped.insert(principal, identity).is_some() {
303                return Err(SecurityError("duplicate bearer principal"));
304            }
305        }
306        self.bearers.push(BearerScheme {
307            verifier,
308            identities: mapped,
309        });
310        Ok(self)
311    }
312
313    /// The SHA-256 fingerprint of the leaf DER certificate, verified by TLS.
314    pub fn with_certificate(
315        mut self,
316        fingerprint: [u8; 32],
317        identity: ControlIdentity,
318    ) -> Result<Self, SecurityError> {
319        if self.certificates.insert(fingerprint, identity).is_some() {
320            return Err(SecurityError("duplicate client certificate"));
321        }
322        Ok(self)
323    }
324
325    fn bearer(&self, credential: &[u8], now: Timestamp) -> Result<ControlIdentity, ApiError> {
326        let mut identity = None;
327        for scheme in &self.bearers {
328            if let Some(proof) = scheme.verifier.verify(credential) {
329                if !proof.is_reusable_at(now) {
330                    return Err(ApiError::unauthorized());
331                }
332                let found = scheme
333                    .identities
334                    .get(&proof.principal)
335                    .ok_or_else(ApiError::forbidden)?;
336                if identity.as_ref().is_some_and(|previous| previous != found) {
337                    return Err(ApiError::unauthorized());
338                }
339                identity = Some(found.clone());
340            }
341        }
342        identity.ok_or_else(ApiError::unauthorized)
343    }
344}
345
346pub(crate) struct SecurityBundle {
347    pub policy: SecurityPolicy,
348    pub tls: Option<TlsConfig>,
349}
350
351/// Shared by the listener and router. A request pins one complete generation.
352pub struct ServerSecurity {
353    pub(crate) current: ArcSwap<SecurityBundle>,
354    encrypted: bool,
355}
356
357impl ServerSecurity {
358    /// Publishes the first generation and fixes the transport mode: TLS if
359    /// `tls` is present, plaintext otherwise. [`replace`](Self::replace)
360    /// cannot change that mode later.
361    ///
362    /// # Errors
363    ///
364    /// Returns a [`SecurityError`] if `policy` maps client certificates but
365    /// `tls` is absent or has no client CA.
366    pub fn new(policy: SecurityPolicy, tls: Option<TlsConfig>) -> Result<Arc<Self>, SecurityError> {
367        validate(&policy, tls.as_ref())?;
368        Ok(Arc::new(Self {
369            encrypted: tls.is_some(),
370            current: ArcSwap::from_pointee(SecurityBundle { policy, tls }),
371        }))
372    }
373
374    /// Existing connections consult the new role map on their next request.
375    /// Changing transport mode requires restarting the listener; TLS cannot be
376    /// removed by a credential reload on an exposed listener.
377    pub fn replace(
378        &self,
379        policy: SecurityPolicy,
380        tls: Option<TlsConfig>,
381    ) -> Result<(), SecurityError> {
382        validate(&policy, tls.as_ref())?;
383        if self.encrypted != tls.is_some() {
384            return Err(SecurityError(
385                "changing TLS mode requires a listener restart",
386            ));
387        }
388        self.current.store(Arc::new(SecurityBundle { policy, tls }));
389        Ok(())
390    }
391
392    /// Whether this server was constructed with TLS. Fixed for its lifetime;
393    /// [`serve`](crate::serve) refuses a non-loopback listener when it is
394    /// `false`.
395    pub fn encrypted(&self) -> bool {
396        self.encrypted
397    }
398}
399
400fn validate(policy: &SecurityPolicy, tls: Option<&TlsConfig>) -> Result<(), SecurityError> {
401    if !policy.certificates.is_empty() && tls.is_none_or(|tls| !tls.verifies_clients()) {
402        return Err(SecurityError(
403            "certificate identities require TLS with a client CA",
404        ));
405    }
406    Ok(())
407}
408
409#[derive(Clone)]
410pub(crate) struct Authorization {
411    pub security: Arc<ServerSecurity>,
412    pub clock: Arc<dyn Clock>,
413    /// The roles this router admits. Each route sits under exactly one
414    /// router, so each route admits exactly one fixed set.
415    pub roles: &'static [Role],
416}
417
418/// Transport framing is bounded and singular. Never log a rejected header.
419fn bearer(request: &Request) -> Result<Option<&[u8]>, ApiError> {
420    let mut values = request.headers().get_all(header::AUTHORIZATION).iter();
421    let Some(header) = values.next() else {
422        return Ok(None);
423    };
424    if values.next().is_some() {
425        return Err(ApiError::unauthorized());
426    }
427    let header = header.as_bytes();
428    // Google ID tokens and 256-bit static credentials fit comfortably. This is
429    // the supported token envelope, enforced before signature work.
430    if header.len() > 16 * 1024 {
431        return Err(ApiError::unauthorized());
432    }
433    let Some(separator) = header.iter().position(|b| *b == b' ') else {
434        return Err(ApiError::unauthorized());
435    };
436    let (scheme, token) = (&header[..separator], &header[separator + 1..]);
437    if !scheme.eq_ignore_ascii_case(b"Bearer")
438        || token.is_empty()
439        || !token.iter().all(|b| b.is_ascii_graphic())
440    {
441        return Err(ApiError::unauthorized());
442    }
443    Ok(Some(token))
444}
445
446pub(crate) async fn authorize(
447    State(auth): State<Authorization>,
448    mut request: Request,
449    next: Next,
450) -> Result<Response, ApiError> {
451    let bundle = auth.security.current.load_full();
452    let now = auth.clock.now();
453    let bearer = bearer(&request)?;
454    let peer = request
455        .extensions()
456        .get::<axum::extract::ConnectInfo<PeerIdentity>>();
457    let certificate = match peer.and_then(|peer| peer.0.certificates.as_deref()) {
458        Some(chain) => {
459            let tls = bundle.tls.as_ref().ok_or_else(ApiError::unauthorized)?;
460            // TLS proved key possession. Recheck current trust and validity so
461            // removing a CA or expiring a certificate affects keep-alive too.
462            tls.verify(chain, now)
463                .map_err(|_| ApiError::unauthorized())?;
464            let fingerprint = crate::transport::fingerprint(&chain[0]);
465            Some(
466                bundle
467                    .policy
468                    .certificates
469                    .get(&fingerprint)
470                    .ok_or_else(ApiError::forbidden)?
471                    .clone(),
472            )
473        }
474        None => None,
475    };
476    let token = bearer
477        .map(|token| bundle.policy.bearer(token, now))
478        .transpose()?;
479    let identity = match (token, certificate) {
480        (Some(a), Some(b)) if a != b => return Err(ApiError::unauthorized()),
481        (Some(identity), _) | (_, Some(identity)) => identity,
482        (None, None) => return Err(ApiError::unauthorized()),
483    };
484    if !auth.roles.contains(&identity.role()) {
485        // Audited, because a refused role is the signature of a credential
486        // used outside its purpose: a provisioner reaching for a deposit is
487        // what a compromised signup service looks like (#39). The route
488        // template names the action and the path is the resource; the
489        // credential itself is never logged.
490        let action = request
491            .extensions()
492            .get::<MatchedPath>()
493            .map_or("unmatched", MatchedPath::as_str);
494        tracing::warn!(target: "tollgate::audit", actor = identity.name(),
495            role = identity.role().as_str(), action = %format_args!("{} {action}", request.method()),
496            resource = request.extensions().get::<OriginalUri>().map_or_else(|| request.uri().path(), |uri| uri.path()),
497            at = %now, outcome = "refused",
498            code = "scope-forbidden", "control-plane request refused for its role");
499        return Err(ApiError::forbidden());
500    }
501    tracing::debug!(
502        actor = identity.name(),
503        role = identity.role().as_str(),
504        "control-plane request authenticated"
505    );
506    match identity.grant.clone() {
507        Grant::Instance => {
508            request.extensions_mut().insert(InstanceIdentity);
509        }
510        Grant::Operator => {
511            request
512                .extensions_mut()
513                .insert(AdminIdentity::Operator(identity.clone()));
514            request.extensions_mut().insert(OperatorIdentity(identity));
515        }
516        Grant::Provisioner(limits) => {
517            request
518                .extensions_mut()
519                .insert(AdminIdentity::Provisioner(identity, limits));
520        }
521    }
522    Ok(next.run(request).await)
523}
524
525// Requiring the proof in handler arguments also fails closed if someone wires
526// a handler without its router layer. Constructors stay private to this module.
527#[derive(Clone)]
528pub(crate) struct InstanceIdentity;
529#[derive(Clone)]
530pub(crate) struct OperatorIdentity(pub ControlIdentity);
531/// Evidence for a route operators and provisioners share. The router decided
532/// only that this identity may ask; the handler decides what a provisioner
533/// may send (#39).
534#[derive(Clone)]
535pub(crate) enum AdminIdentity {
536    Operator(ControlIdentity),
537    Provisioner(ControlIdentity, ProvisionerLimits),
538}
539
540macro_rules! identity_extractor {
541    ($name:ident) => {
542        impl<S: Send + Sync> FromRequestParts<S> for $name {
543            type Rejection = ApiError;
544            async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, ApiError> {
545                parts
546                    .extensions
547                    .get::<Self>()
548                    .cloned()
549                    .ok_or_else(ApiError::unauthorized)
550            }
551        }
552    };
553}
554identity_extractor!(InstanceIdentity);
555identity_extractor!(OperatorIdentity);
556identity_extractor!(AdminIdentity);
557
558#[cfg(test)]
559mod framing_tests {
560    use super::*;
561
562    #[test]
563    fn bearer_framing_checks_each_condition_before_scheme_verification() {
564        let request = |value: &[u8]| {
565            Request::builder()
566                .header(
567                    header::AUTHORIZATION,
568                    axum::http::HeaderValue::from_bytes(value).unwrap(),
569                )
570                .body(axum::body::Body::empty())
571                .unwrap()
572        };
573        for value in [
574            b"Basic token".as_slice(),
575            b"Bearer ",
576            b"Bearer one\ttwo",
577            b"Bearer  token",
578            b"Bearer",
579            b"Bearer \x80",
580        ] {
581            assert!(bearer(&request(value)).is_err());
582        }
583        for size in [1, 16 * 1024 - 7] {
584            let token = vec![b'x'; size];
585            let mut header = b"bEaReR ".to_vec();
586            header.extend_from_slice(&token);
587            let request = request(&header);
588            assert_eq!(bearer(&request).unwrap(), Some(token.as_slice()));
589        }
590        for size in [16 * 1024 + 1, 32 * 1024] {
591            let mut value = b"Bearer ".to_vec();
592            value.resize(size, b'x');
593            assert!(bearer(&request(&value)).is_err());
594        }
595        let mut duplicate = request(b"Bearer token");
596        duplicate
597            .headers_mut()
598            .append(header::AUTHORIZATION, "Bearer token".parse().unwrap());
599        assert!(bearer(&duplicate).is_err());
600        assert_eq!(
601            bearer(&Request::new(axum::body::Body::empty())).unwrap(),
602            None
603        );
604    }
605
606    #[test]
607    fn overlapping_bearer_schemes_must_agree_on_the_verified_identity() {
608        use tollgate_auth::{HmacRegistry, Verified};
609        let verifier = Arc::new(HmacRegistry::new(b"fixture-scheme-agreement-secret"));
610        let principal = verifier.install_credentials([b"shared-credential".as_slice()])[0];
611        let now = Timestamp::from_second(100).unwrap();
612        let instance = ControlIdentity::new("instance", Role::Instance).unwrap();
613        let operator = ControlIdentity::new("operator", Role::Operator).unwrap();
614        for (second, agrees) in [(instance.clone(), true), (operator, false)] {
615            let policy = SecurityPolicy::new()
616                .with_bearer(verifier.clone(), [(principal, instance.clone())])
617                .unwrap()
618                .with_bearer(verifier.clone(), [(principal, second)])
619                .unwrap();
620            assert_eq!(policy.bearer(b"shared-credential", now).is_ok(), agrees);
621            if agrees {
622                assert_eq!(policy.bearer(b"shared-credential", now).unwrap(), instance);
623            }
624        }
625        struct Expired(Principal);
626        impl CredentialVerifier for Expired {
627            fn verify(&self, _: &[u8]) -> Option<Verified> {
628                Some(Verified::until(
629                    self.0,
630                    Timestamp::from_second(100).unwrap(),
631                ))
632            }
633        }
634        let policy = SecurityPolicy::new()
635            .with_bearer(Arc::new(Expired(principal)), [(principal, instance)])
636            .unwrap();
637        assert!(policy.bearer(b"shared-credential", now).is_err());
638    }
639}
640
641impl OperatorIdentity {
642    /// See [`audited`].
643    pub(crate) async fn run<T, E: Into<ApiError>>(
644        &self,
645        action: &'static str,
646        target: impl std::fmt::Display,
647        clock: &dyn Clock,
648        operation: impl Future<Output = Result<tollgate_store::AdminReceipt<T>, E>>,
649    ) -> Result<T, ApiError> {
650        audited(&self.0, action, target, clock, operation).await
651    }
652}
653
654impl AdminIdentity {
655    pub(crate) fn identity(&self) -> &ControlIdentity {
656        match self {
657            AdminIdentity::Operator(identity) | AdminIdentity::Provisioner(identity, _) => identity,
658        }
659    }
660
661    /// See [`audited`].
662    pub(crate) async fn run<T, E: Into<ApiError>>(
663        &self,
664        action: &'static str,
665        target: impl std::fmt::Display,
666        clock: &dyn Clock,
667        operation: impl Future<Output = Result<tollgate_store::AdminReceipt<T>, E>>,
668    ) -> Result<T, ApiError> {
669        audited(self.identity(), action, target, clock, operation).await
670    }
671
672    /// Refuse a request outside this identity's scope before any store call,
673    /// and audit the refusal: an attempt a role may not make is evidence,
674    /// not noise (#39).
675    pub(crate) fn refuse(
676        &self,
677        action: &'static str,
678        target: impl std::fmt::Display,
679        clock: &dyn Clock,
680        code: &'static str,
681        title: &'static str,
682    ) -> ApiError {
683        let identity = self.identity();
684        tracing::warn!(target: "tollgate::audit", actor = identity.name(),
685            role = identity.role().as_str(), action, resource = %target, at = %clock.now(),
686            outcome = "refused", code, reason = title, "administrative operation refused for its scope");
687        ApiError::refused_scope(code, title)
688    }
689
690    /// A provisioner may act only on an account a provisioner created (#39);
691    /// an operator passes unconditionally.
692    ///
693    /// A read separate from the operation it guards is sound here, where it
694    /// would not be for status: `origin` is written once at creation and
695    /// never changes, so no write can slip between this check and the
696    /// operation. The mutable fact, who set the status, is checked inside
697    /// the store's own transaction instead.
698    pub(crate) async fn check_account<S: tollgate_store::AdminStore + ?Sized>(
699        &self,
700        store: &S,
701        account: tollgate_core::AccountId,
702        action: &'static str,
703        target: impl std::fmt::Display,
704        clock: &dyn Clock,
705    ) -> Result<(), ApiError> {
706        if let AdminIdentity::Operator(_) = self {
707            return Ok(());
708        }
709        match store.account_view(account).await? {
710            None => Err(tollgate_store::SetStatusError::UnknownAccount.into()),
711            Some(view) if view.origin == tollgate_store::AdminAuthority::Provisioner => Ok(()),
712            Some(_) => Err(self.refuse(
713                action,
714                target,
715                clock,
716                "account-not-provisioned",
717                "account was not created by a provisioner",
718            )),
719        }
720    }
721}
722
723/// The receipt is the backend's proof. Errors and cancellation never
724/// manufacture a before/after pair or claim that an ambiguous write rolled back.
725async fn audited<T, E: Into<ApiError>>(
726    identity: &ControlIdentity,
727    action: &'static str,
728    target: impl std::fmt::Display,
729    clock: &dyn Clock,
730    operation: impl Future<Output = Result<tollgate_store::AdminReceipt<T>, E>>,
731) -> Result<T, ApiError> {
732    struct Attempt<'a> {
733        id: tollgate_core::RequestId,
734        identity: &'a ControlIdentity,
735        action: &'static str,
736        target: String,
737        clock: &'a dyn Clock,
738        finished: bool,
739    }
740    impl Drop for Attempt<'_> {
741        fn drop(&mut self) {
742            if !self.finished {
743                tracing::warn!(target: "tollgate::audit", actor = self.identity.name(),
744                        role = self.identity.role().as_str(), action = self.action,
745                        operation_id = %self.id,
746                        resource = self.target, at = %self.clock.now(), outcome = "cancelled_unknown",
747                        "administrative operation abandoned; commit outcome may be unknown");
748            }
749        }
750    }
751    let mut identifier = [0u8; 16];
752    getrandom::fill(&mut identifier).map_err(|_| {
753        ApiError::from(tollgate_store::StoreError(
754            "audit identity entropy unavailable".into(),
755        ))
756    })?;
757    let mut attempt = Attempt {
758        id: tollgate_core::RequestId(u128::from_be_bytes(identifier)),
759        identity,
760        action,
761        target: target.to_string(),
762        clock,
763        finished: false,
764    };
765    tracing::info!(target: "tollgate::audit", actor = identity.name(),
766            role = identity.role().as_str(), action,
767            operation_id = %attempt.id,
768            resource = attempt.target, at = %clock.now(), outcome = "started", "administrative operation started");
769    let result = operation.await;
770    attempt.finished = true;
771    match result {
772        Ok(receipt) => {
773            tracing::info!(target: "tollgate::audit", actor = identity.name(),
774            role = identity.role().as_str(), action,
775                    operation_id = %attempt.id,
776                    resource = attempt.target, at = %clock.now(), outcome = "confirmed",
777                    before = ?receipt.before, after = ?receipt.after, "administrative operation completed");
778            Ok(receipt.outcome)
779        }
780        Err(error) => {
781            let error = error.into();
782            tracing::warn!(target: "tollgate::audit", actor = identity.name(),
783            role = identity.role().as_str(), action,
784                    operation_id = %attempt.id,
785                    resource = attempt.target, at = %clock.now(), outcome = "failed",
786                    code = error.code, status = error.status.as_u16(), "administrative operation failed; storage errors may conceal a commit");
787            Err(error)
788        }
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    #[tokio::test]
797    async fn cancelled_admin_operations_report_an_unknown_commit_without_a_receipt() {
798        use tracing::instrument::WithSubscriber;
799        #[derive(Clone, Default)]
800        struct Capture(Arc<std::sync::Mutex<Vec<u8>>>);
801        impl std::io::Write for Capture {
802            fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
803                self.0.lock().unwrap().extend_from_slice(bytes);
804                Ok(bytes.len())
805            }
806            fn flush(&mut self) -> std::io::Result<()> {
807                Ok(())
808            }
809        }
810        let capture = Capture::default();
811        let output = capture.clone();
812        let subscriber = tracing_subscriber::fmt()
813            .without_time()
814            .with_ansi(false)
815            .with_writer(move || output.clone())
816            .finish();
817        let identity =
818            OperatorIdentity(ControlIdentity::new("fixture-operator", Role::Operator).unwrap());
819        async {
820            let operation = identity.run(
821                "deposit",
822                "fixture-account",
823                &tollgate_store::SystemClock,
824                std::future::pending::<
825                    Result<tollgate_store::AdminReceipt<()>, tollgate_store::StoreError>,
826                >(),
827            );
828            tokio::pin!(operation);
829            tokio::select! {
830                biased;
831                _ = &mut operation => panic!("the backend remains pending"),
832                _ = tokio::task::yield_now() => {},
833            }
834            // Dropping the pending operation must report an ambiguous outcome.
835        }
836        .with_subscriber(subscriber)
837        .await;
838        let bytes = capture.0.lock().unwrap();
839        let log = std::str::from_utf8(&bytes).unwrap();
840        assert!(
841            log.contains("started") && log.contains("cancelled_unknown"),
842            "{log}"
843        );
844        assert!(
845            log.contains("fixture-operator")
846                && log.contains("fixture-account")
847                && log.contains("operation_id=")
848        );
849        assert!(!log.contains("confirmed") && !log.contains("before=") && !log.contains("after="));
850    }
851}
852
853#[cfg(test)]
854mod provisioner_policy_tests {
855    use super::*;
856
857    fn template(cost: u64, permissions: u64) -> ProvisionerPolicyTemplate {
858        ProvisionerPolicyTemplate {
859            cost_table: Arc::new(CostTable::builder(CostUnits(cost), CostUnits(cost)).build()),
860            limits: ResolvedLimits::new(1),
861            permissions: PermissionBits(permissions),
862            policy_revision: PolicyRevision::UNSTATED,
863        }
864    }
865
866    fn snapshot(template: &ProvisionerPolicyTemplate) -> AccountSnapshot {
867        AccountSnapshot::builder(
868            AccountId(1),
869            Generation(1),
870            AccountStatus::Active,
871            Timestamp::UNIX_EPOCH,
872            template.permissions,
873            template.limits,
874            Arc::clone(&template.cost_table),
875        )
876        .build()
877    }
878
879    #[test]
880    fn provisioner_templates_match_whole_policies_and_are_identity_scoped() {
881        let first = template(1, 1);
882        let second = template(2, 2);
883        let limits =
884            ProvisionerLimits::new(CostUnits(100), vec![first.clone(), second.clone()]).unwrap();
885        assert_eq!(
886            limits,
887            ProvisionerLimits::new(
888                CostUnits(100),
889                vec![second.clone(), first.clone(), first.clone()]
890            )
891            .unwrap()
892        );
893        assert_ne!(
894            limits,
895            ProvisionerLimits::new(CostUnits(101), vec![first.clone(), second.clone()]).unwrap()
896        );
897        let subset = ProvisionerLimits::new(CostUnits(100), vec![first.clone()]).unwrap();
898        assert_ne!(limits, subset);
899        assert_ne!(subset, limits);
900        let mut candidate = snapshot(&first);
901        assert!(limits.allows_snapshot(&candidate));
902        candidate.generation = Generation(u64::MAX);
903        candidate.valid_until = Timestamp::MAX;
904        candidate.account_id = AccountId(2);
905        candidate.key_id = Some(tollgate_core::KeyId(3));
906        assert!(
907            limits.allows_snapshot(&candidate),
908            "binding and publication metadata are separate store contracts"
909        );
910        candidate.cost_table = Arc::clone(&second.cost_table);
911        assert!(
912            !limits.allows_snapshot(&candidate),
913            "a mixed policy is not an approved policy"
914        );
915        candidate.permissions = second.permissions;
916        assert!(
917            limits.allows_snapshot(&candidate),
918            "the second complete policy is approved too"
919        );
920        let other_identity = ProvisionerLimits::new(CostUnits(100), vec![first]).unwrap();
921        assert!(!other_identity.allows_snapshot(&candidate));
922        candidate.enforcement_mode = EnforcementMode::Elastic {
923            overage_cap: CostUnits(10),
924        };
925        assert!(
926            !limits.allows_snapshot(&candidate),
927            "templates cannot approve unfunded credit"
928        );
929    }
930
931    #[test]
932    fn provisioner_limits_refuse_missing_or_unpublishable_templates() {
933        assert!(ProvisionerLimits::new(CostUnits(100), vec![]).is_err());
934        let mut invalid = template(1, 1);
935        invalid.limits = ResolvedLimits::new(1).with_weighted_rate(0, 1);
936        assert!(ProvisionerLimits::new(CostUnits(100), vec![invalid]).is_err());
937    }
938}