1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum Role {
28 Instance,
31 Operator,
34 Provisioner,
40}
41
42impl Role {
43 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct ProvisionerPolicyTemplate {
60 pub cost_table: Arc<CostTable>,
62 pub limits: ResolvedLimits,
64 pub permissions: PermissionBits,
66 #[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#[derive(Debug, Clone, Eq)]
102pub struct ProvisionerLimits {
103 max_budget_allowance: CostUnits,
104 policy_templates: Arc<[ProvisionerPolicyTemplate]>,
105}
106
107impl 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 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 pub fn max_budget_allowance(&self) -> CostUnits {
149 self.max_budget_allowance
150 }
151
152 pub fn allows_snapshot(&self, snapshot: &AccountSnapshot) -> bool {
155 self.policy_templates
156 .iter()
157 .any(|template| template.matches(snapshot))
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
165enum Grant {
166 Instance,
167 Operator,
168 Provisioner(ProvisionerLimits),
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct ControlIdentity {
174 name: String,
175 grant: Grant,
176}
177
178impl ControlIdentity {
179 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 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 pub fn name(&self) -> &str {
230 &self.name
231 }
232 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 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#[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#[derive(Default)]
272pub struct SecurityPolicy {
273 bearers: Vec<BearerScheme>,
274 certificates: HashMap<[u8; 32], ControlIdentity>,
275}
276
277impl SecurityPolicy {
278 pub fn new() -> Self {
280 Self::default()
281 }
282
283 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 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
351pub struct ServerSecurity {
353 pub(crate) current: ArcSwap<SecurityBundle>,
354 encrypted: bool,
355}
356
357impl ServerSecurity {
358 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 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 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 pub roles: &'static [Role],
416}
417
418fn 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 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.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 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#[derive(Clone)]
528pub(crate) struct InstanceIdentity;
529#[derive(Clone)]
530pub(crate) struct OperatorIdentity(pub ControlIdentity);
531#[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 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 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 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 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
723async 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 }
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}