1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::{BTreeMap, BTreeSet};
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub const AUTHORIZATION_CONTRACT_VERSION: u32 = 1;
15pub const POLICY_VERSION: &str = "feltdb.resource-authorization.v1";
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
18#[serde(tag = "kind", content = "id", rename_all = "snake_case")]
19pub enum Subject {
20 Human(String),
21 Service(String),
22 Workload(String),
23 Agent(String),
24 Connection(String),
25 Worker(String),
26}
27impl Subject {
28 pub fn id(&self) -> &str {
29 match self {
30 Self::Human(v)
31 | Self::Service(v)
32 | Self::Workload(v)
33 | Self::Agent(v)
34 | Self::Connection(v)
35 | Self::Worker(v) => v,
36 }
37 }
38 pub fn kind(&self) -> &'static str {
39 match self {
40 Self::Human(_) => "human",
41 Self::Service(_) => "service",
42 Self::Workload(_) => "workload",
43 Self::Agent(_) => "agent",
44 Self::Connection(_) => "connection",
45 Self::Worker(_) => "worker",
46 }
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct Resource {
52 pub uri: String,
53 #[serde(default)]
54 pub attributes: BTreeMap<String, String>,
55}
56impl Resource {
57 pub fn new(application_id: &str, path: &str) -> Result<Self, AuthorizationError> {
58 let uri = format!("flow://{}/{}", application_id, path.trim_start_matches('/'));
59 validate_resource_uri(&uri, application_id)?;
60 Ok(Self {
61 uri,
62 attributes: BTreeMap::new(),
63 })
64 }
65}
66
67#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
69pub enum ResourceScopeKind {
70 Application,
71 Collection,
72 Resource,
73 ResourcePrefix,
74 Workload,
75 Connection,
76 Artifact,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
80pub struct ResourceScope {
81 pub kind: ResourceScopeKind,
82 pub uri: String,
83}
84impl ResourceScope {
85 pub fn matches(&self, resource: &Resource) -> bool {
86 match self.kind {
87 ResourceScopeKind::Application => {
88 resource.uri == self.uri
89 || resource
90 .uri
91 .starts_with(&(self.uri.trim_end_matches('/').to_owned() + "/"))
92 }
93 ResourceScopeKind::ResourcePrefix => {
94 let prefix = self.uri.trim_end_matches('*').trim_end_matches('/');
95 resource.uri == prefix || resource.uri.starts_with(&(prefix.to_owned() + "/"))
96 }
97 _ => resource.uri == self.uri,
98 }
99 }
100 pub fn is_subset_of(&self, parent: &Self) -> bool {
101 parent.matches(&Resource {
102 uri: self.uri.trim_end_matches('*').trim_end_matches('/').into(),
103 attributes: BTreeMap::new(),
104 }) && (!matches!(self.kind, ResourceScopeKind::Application)
105 || matches!(parent.kind, ResourceScopeKind::Application))
106 }
107}
108
109#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
110pub struct AuthorizationContext {
111 pub environment: Option<String>,
112 pub execution_id: Option<String>,
113 pub workload_id: Option<String>,
114 pub worker_id: Option<String>,
115 pub connection_id: Option<String>,
116 pub timestamp: i64,
117 #[serde(default)]
118 pub attributes: BTreeMap<String, String>,
119}
120impl AuthorizationContext {
121 pub fn now() -> Self {
122 Self {
123 timestamp: unix_seconds(),
124 ..Default::default()
125 }
126 }
127}
128
129#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
130pub struct GrantConstraints {
131 #[serde(default)]
132 pub environments: BTreeSet<String>,
133 pub worker_class: Option<String>,
134 pub network: Option<String>,
135 pub workload_id: Option<String>,
136 pub connection_enabled: Option<bool>,
137 pub minimum_schema_version: Option<u64>,
138 #[serde(default)]
139 pub subject_attributes: BTreeMap<String, String>,
140 #[serde(default)]
141 pub resource_attributes: BTreeMap<String, String>,
142 #[serde(default)]
143 pub context_attributes: BTreeMap<String, String>,
144}
145impl GrantConstraints {
146 fn permits(
147 &self,
148 subject: &Subject,
149 resource: &Resource,
150 context: &AuthorizationContext,
151 ) -> bool {
152 (self.environments.is_empty()
153 || context
154 .environment
155 .as_ref()
156 .is_some_and(|v| self.environments.contains(v)))
157 && self
158 .workload_id
159 .as_ref()
160 .is_none_or(|v| context.workload_id.as_ref() == Some(v))
161 && self
162 .worker_class
163 .as_ref()
164 .is_none_or(|v| context.attributes.get("worker_class") == Some(v))
165 && self
166 .network
167 .as_ref()
168 .is_none_or(|v| context.attributes.get("network") == Some(v))
169 && self.connection_enabled.is_none_or(|v| {
170 context
171 .attributes
172 .get("connection_enabled")
173 .map(|x| x == "true")
174 == Some(v)
175 })
176 && self.minimum_schema_version.is_none_or(|v| {
177 context
178 .attributes
179 .get("schema_version")
180 .and_then(|x| x.parse().ok())
181 .is_some_and(|x: u64| x >= v)
182 })
183 && self.subject_attributes.iter().all(|(k, v)| {
184 (k == "type" && subject.kind() == v)
185 || context.attributes.get(&format!("subject.{k}")) == Some(v)
186 })
187 && self
188 .resource_attributes
189 .iter()
190 .all(|(k, v)| resource.attributes.get(k) == Some(v))
191 && self
192 .context_attributes
193 .iter()
194 .all(|(k, v)| context.attributes.get(k) == Some(v))
195 }
196 fn is_subset_of(&self, parent: &Self) -> bool {
197 (parent.environments.is_empty() || self.environments.is_subset(&parent.environments))
198 && parent
199 .worker_class
200 .as_ref()
201 .is_none_or(|v| self.worker_class.as_ref() == Some(v))
202 && parent
203 .network
204 .as_ref()
205 .is_none_or(|v| self.network.as_ref() == Some(v))
206 && parent
207 .workload_id
208 .as_ref()
209 .is_none_or(|v| self.workload_id.as_ref() == Some(v))
210 && parent
211 .connection_enabled
212 .is_none_or(|v| self.connection_enabled == Some(v))
213 && parent
214 .minimum_schema_version
215 .is_none_or(|v| self.minimum_schema_version.is_some_and(|child| child >= v))
216 && parent
217 .subject_attributes
218 .iter()
219 .all(|(k, v)| self.subject_attributes.get(k) == Some(v))
220 && parent
221 .resource_attributes
222 .iter()
223 .all(|(k, v)| self.resource_attributes.get(k) == Some(v))
224 && parent
225 .context_attributes
226 .iter()
227 .all(|(k, v)| self.context_attributes.get(k) == Some(v))
228 }
229}
230
231#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
232#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
233pub enum GrantStatus {
234 Active,
235 Revoked,
236 Expired,
237 Superseded,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
241pub struct Grant {
242 pub contract_version: u32,
243 pub id: String,
244 pub issuer: Subject,
245 pub subject: Subject,
246 pub tenant_id: String,
247 pub application_id: String,
248 pub capabilities: BTreeSet<String>,
249 pub resources: Vec<ResourceScope>,
250 #[serde(default)]
251 pub constraints: GrantConstraints,
252 pub issued_at: i64,
253 pub expires_at: i64,
254 pub parent_grant: Option<String>,
255 pub revision: u64,
256 pub status: GrantStatus,
257}
258impl Grant {
259 pub fn is_subset_of(&self, parent: &Grant) -> bool {
260 self.tenant_id == parent.tenant_id
261 && self.application_id == parent.application_id
262 && self.issued_at >= parent.issued_at
263 && self.expires_at <= parent.expires_at
264 && self.capabilities.is_subset(&parent.capabilities)
265 && self.resources.iter().all(|child| {
266 parent
267 .resources
268 .iter()
269 .any(|scope| child.is_subset_of(scope))
270 })
271 && self.constraints.is_subset_of(&parent.constraints)
272 }
273 pub fn canonical_bytes(&self) -> Result<Vec<u8>, AuthorizationError> {
274 serde_json::to_vec(self)
275 .map_err(|e| AuthorizationError::new("GRANT_INVALID", e.to_string()))
276 }
277 pub fn digest(&self) -> Result<String, AuthorizationError> {
278 Ok(hex(&Sha256::digest(self.canonical_bytes()?)))
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub struct SignedGrant {
284 pub algorithm: String,
285 pub key_id: String,
286 pub grant: Grant,
287 pub digest: String,
288 pub signature: String,
289}
290
291#[derive(Clone)]
292pub struct GrantSigner {
293 key_id: String,
294 signing: Option<SigningKey>,
295 verifying: VerifyingKey,
296}
297impl GrantSigner {
298 pub fn new(
299 key_id: impl Into<String>,
300 key: impl Into<Vec<u8>>,
301 ) -> Result<Self, AuthorizationError> {
302 let key = key.into();
303 if key.len() != 32 {
304 return Err(AuthorizationError::new(
305 "GRANT_INVALID",
306 "Ed25519 signing key must be 32 bytes",
307 ));
308 }
309 let bytes: [u8; 32] = key
310 .try_into()
311 .map_err(|_| AuthorizationError::new("GRANT_INVALID", "invalid signing key"))?;
312 let signing = SigningKey::from_bytes(&bytes);
313 let verifying = signing.verifying_key();
314 Ok(Self {
315 key_id: key_id.into(),
316 signing: Some(signing),
317 verifying,
318 })
319 }
320 pub fn verifier(
321 key_id: impl Into<String>,
322 public_key: &[u8],
323 ) -> Result<Self, AuthorizationError> {
324 let bytes: [u8; 32] = public_key.try_into().map_err(|_| {
325 AuthorizationError::new("GRANT_INVALID", "Ed25519 public key must be 32 bytes")
326 })?;
327 let verifying = VerifyingKey::from_bytes(&bytes)
328 .map_err(|_| AuthorizationError::new("GRANT_INVALID", "invalid Ed25519 public key"))?;
329 Ok(Self {
330 key_id: key_id.into(),
331 signing: None,
332 verifying,
333 })
334 }
335 pub fn verifier_hex(
336 key_id: impl Into<String>,
337 public_key: &str,
338 ) -> Result<Self, AuthorizationError> {
339 Self::verifier(key_id, &decode_hex(public_key)?)
340 }
341 pub fn public_key_hex(&self) -> String {
342 hex(self.verifying.as_bytes())
343 }
344 pub fn sign(&self, grant: Grant) -> Result<SignedGrant, AuthorizationError> {
345 let bytes = grant.canonical_bytes()?;
346 let digest = hex(&Sha256::digest(&bytes));
347 let signing = self.signing.as_ref().ok_or_else(|| {
348 AuthorizationError::new("GRANT_INVALID", "verification-only key cannot sign grants")
349 })?;
350 Ok(SignedGrant {
351 algorithm: "ED25519-V1".into(),
352 key_id: self.key_id.clone(),
353 signature: hex(&signing.sign(&bytes).to_bytes()),
354 digest,
355 grant,
356 })
357 }
358 pub fn verify_signature(&self, signed: &SignedGrant) -> Result<(), AuthorizationError> {
359 if signed.algorithm != "ED25519-V1" || signed.key_id != self.key_id {
360 return Err(AuthorizationError::new(
361 "GRANT_INVALID",
362 "unsupported signer",
363 ));
364 }
365 let bytes = signed.grant.canonical_bytes()?;
366 let expected_digest = hex(&Sha256::digest(&bytes));
367 let signature = Signature::from_slice(&decode_hex(&signed.signature)?)
368 .map_err(|_| AuthorizationError::new("GRANT_INVALID", "invalid Ed25519 signature"))?;
369 if self.verifying.verify(&bytes, &signature).is_err() || expected_digest != signed.digest {
370 return Err(AuthorizationError::new(
371 "GRANT_INVALID",
372 "grant signature or digest is invalid",
373 ));
374 }
375 Ok(())
376 }
377}
378
379#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
380pub struct AuthorizationRequest {
381 pub subject: Subject,
382 pub tenant_id: String,
383 pub application_id: String,
384 pub resource: Resource,
385 pub capability: String,
386 #[serde(default)]
387 pub context: AuthorizationContext,
388 pub grant_id: Option<String>,
389 #[serde(default)]
390 pub subject_attributes: BTreeMap<String, String>,
391}
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
393pub struct AuthorizationDecision {
394 pub decision_id: String,
395 pub request_hash: String,
396 pub decision_hash: String,
397 pub allowed: bool,
398 pub subject: Subject,
399 pub tenant_id: String,
400 pub application_id: String,
401 pub resource: Resource,
402 pub capability: String,
403 pub grant_id: Option<String>,
404 pub policy_version: String,
405 pub evaluated_at: i64,
406 pub reason: String,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410pub struct AuthorizationError {
411 pub code: String,
412 pub message: String,
413}
414impl AuthorizationError {
415 pub fn new(code: &str, message: impl Into<String>) -> Self {
416 Self {
417 code: code.into(),
418 message: message.into(),
419 }
420 }
421}
422impl std::fmt::Display for AuthorizationError {
423 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424 write!(f, "{}: {}", self.code, self.message)
425 }
426}
427impl std::error::Error for AuthorizationError {}
428
429#[derive(Debug, Clone, Default, Serialize, Deserialize)]
430pub struct RevocationState {
431 #[serde(default)]
432 pub grants: BTreeSet<String>,
433 #[serde(default)]
434 pub subjects: BTreeSet<Subject>,
435 #[serde(default)]
436 pub applications: BTreeSet<String>,
437 #[serde(default)]
438 pub tenants: BTreeSet<String>,
439}
440
441#[derive(Debug, Clone, Serialize, Deserialize)]
444pub struct GrantStore {
445 #[serde(skip)]
446 path: PathBuf,
447 #[serde(default)]
448 pub grants: BTreeMap<String, SignedGrant>,
449 #[serde(default)]
450 pub revocations: RevocationState,
451 #[serde(default)]
452 pub decisions: BTreeMap<String, AuthorizationDecision>,
453}
454impl GrantStore {
455 pub fn load(path: impl AsRef<Path>) -> Result<Self, AuthorizationError> {
456 let path = path.as_ref().to_path_buf();
457 if !path.exists() {
458 return Ok(Self {
459 path,
460 grants: BTreeMap::new(),
461 revocations: Default::default(),
462 decisions: BTreeMap::new(),
463 });
464 }
465 let mut store: Self = serde_json::from_slice(
466 &fs::read(&path)
467 .map_err(|e| AuthorizationError::new("GRANT_INVALID", e.to_string()))?,
468 )
469 .map_err(|e| AuthorizationError::new("GRANT_INVALID", e.to_string()))?;
470 store.path = path;
471 Ok(store)
472 }
473 fn persist(&self) -> Result<(), AuthorizationError> {
474 if let Some(parent) = self.path.parent() {
475 fs::create_dir_all(parent)
476 .map_err(|e| AuthorizationError::new("GRANT_INVALID", e.to_string()))?;
477 }
478 let temporary = self.path.with_extension("tmp");
479 fs::write(
480 &temporary,
481 serde_json::to_vec_pretty(self)
482 .map_err(|e| AuthorizationError::new("GRANT_INVALID", e.to_string()))?,
483 )
484 .map_err(|e| AuthorizationError::new("GRANT_INVALID", e.to_string()))?;
485 fs::rename(temporary, &self.path)
486 .map_err(|e| AuthorizationError::new("GRANT_INVALID", e.to_string()))
487 }
488 pub fn create(
489 &mut self,
490 grant: Grant,
491 signer: &GrantSigner,
492 ) -> Result<SignedGrant, AuthorizationError> {
493 if grant.contract_version != AUTHORIZATION_CONTRACT_VERSION
494 || grant.expires_at <= grant.issued_at
495 || grant.status != GrantStatus::Active
496 || self.grants.contains_key(&grant.id)
497 {
498 return Err(AuthorizationError::new(
499 "GRANT_INVALID",
500 "invalid or duplicate grant lifecycle",
501 ));
502 }
503 if grant.parent_grant.is_some() {
504 return Err(AuthorizationError::new(
505 "GRANT_DELEGATION_INVALID",
506 "use delegate for a derived grant",
507 ));
508 }
509 validate_scopes(&grant)?;
510 let signed = signer.sign(grant)?;
511 self.grants.insert(signed.grant.id.clone(), signed.clone());
512 self.persist()?;
513 Ok(signed)
514 }
515 pub fn delegate(
516 &mut self,
517 mut grant: Grant,
518 parent_id: &str,
519 signer: &GrantSigner,
520 ) -> Result<SignedGrant, AuthorizationError> {
521 let parent = self.grants.get(parent_id).ok_or_else(|| {
522 AuthorizationError::new("GRANT_DELEGATION_INVALID", "parent grant not found")
523 })?;
524 if self.revocations.grants.contains(parent_id) || parent.grant.status != GrantStatus::Active
525 {
526 return Err(AuthorizationError::new(
527 "GRANT_REVOKED",
528 "parent grant is not active",
529 ));
530 }
531 grant.parent_grant = Some(parent_id.into());
532 if grant.issuer != parent.grant.subject {
533 return Err(AuthorizationError::new(
534 "GRANT_DELEGATION_INVALID",
535 "derived grant issuer must be the parent subject",
536 ));
537 }
538 if !grant.is_subset_of(&parent.grant) {
539 return Err(AuthorizationError::new(
540 "GRANT_DELEGATION_INVALID",
541 "delegation broadens parent authority",
542 ));
543 }
544 validate_scopes(&grant)?;
545 let signed = signer.sign(grant)?;
546 self.grants.insert(signed.grant.id.clone(), signed.clone());
547 self.persist()?;
548 Ok(signed)
549 }
550 pub fn revoke(
551 &mut self,
552 grant_id: &str,
553 signer: &GrantSigner,
554 ) -> Result<SignedGrant, AuthorizationError> {
555 let signed = self
556 .grants
557 .get_mut(grant_id)
558 .ok_or_else(|| AuthorizationError::new("GRANT_INVALID", "grant not found"))?;
559 signed.grant.status = GrantStatus::Revoked;
560 *signed = signer.sign(signed.grant.clone())?;
561 self.revocations.grants.insert(grant_id.into());
562 let result = signed.clone();
563 self.persist()?;
564 Ok(result)
565 }
566 pub fn check(
567 &mut self,
568 request: &AuthorizationRequest,
569 signer: &GrantSigner,
570 ) -> AuthorizationDecision {
571 let signed = request.grant_id.as_ref().and_then(|id| self.grants.get(id));
572 let decision = authorize(request, signed, signer, &self.grants, &self.revocations);
573 self.decisions
574 .insert(decision.decision_id.clone(), decision.clone());
575 let _ = self.persist();
576 decision
577 }
578}
579
580fn validate_scopes(grant: &Grant) -> Result<(), AuthorizationError> {
581 if grant.capabilities.is_empty() || grant.resources.is_empty() {
582 return Err(AuthorizationError::new(
583 "GRANT_INVALID",
584 "grant requires capabilities and resource scopes",
585 ));
586 }
587 for scope in &grant.resources {
588 validate_resource_uri(
589 scope.uri.trim_end_matches('*').trim_end_matches('/'),
590 &grant.application_id,
591 )?;
592 }
593 Ok(())
594}
595
596pub fn verify_grant(
597 signed: &SignedGrant,
598 signer: &GrantSigner,
599 request: &AuthorizationRequest,
600 grants: &BTreeMap<String, SignedGrant>,
601 revoked: &RevocationState,
602) -> Result<(), AuthorizationError> {
603 signer.verify_signature(signed)?;
604 let grant = &signed.grant;
605 if grant.subject != request.subject {
606 return Err(AuthorizationError::new(
607 "GRANT_SCOPE_DENIED",
608 "wrong subject",
609 ));
610 }
611 if grant.tenant_id != request.tenant_id || grant.application_id != request.application_id {
612 return Err(AuthorizationError::new(
613 "GRANT_SCOPE_DENIED",
614 "cross-tenant or cross-application grant",
615 ));
616 }
617 validate_resource_uri(&request.resource.uri, &request.application_id)?;
618 if grant.expires_at <= request.context.timestamp || grant.status == GrantStatus::Expired {
619 return Err(AuthorizationError::new(
620 "GRANT_EXPIRED",
621 "grant has expired",
622 ));
623 }
624 if grant.status == GrantStatus::Revoked
625 || revoked.grants.contains(&grant.id)
626 || revoked.subjects.contains(&grant.subject)
627 || revoked.applications.contains(&grant.application_id)
628 || revoked.tenants.contains(&grant.tenant_id)
629 {
630 return Err(AuthorizationError::new("GRANT_REVOKED", "grant is revoked"));
631 }
632 if !grant.capabilities.contains(&request.capability)
633 || !grant
634 .resources
635 .iter()
636 .any(|scope| scope.matches(&request.resource))
637 {
638 return Err(AuthorizationError::new(
639 "GRANT_SCOPE_DENIED",
640 "capability has no applicable resource scope",
641 ));
642 }
643 if !grant
644 .constraints
645 .permits(&grant.subject, &request.resource, &request.context)
646 {
647 return Err(AuthorizationError::new(
648 "GRANT_SCOPE_DENIED",
649 "authorization context does not satisfy grant constraints",
650 ));
651 }
652 if let Some(parent_id) = &grant.parent_grant {
653 let parent = grants.get(parent_id).ok_or_else(|| {
654 AuthorizationError::new("GRANT_DELEGATION_INVALID", "parent grant not found")
655 })?;
656 let parent_request = AuthorizationRequest {
657 subject: parent.grant.subject.clone(),
658 grant_id: Some(parent_id.clone()),
659 ..request.clone()
660 };
661 verify_grant(parent, signer, &parent_request, grants, revoked)?;
662 if !grant.is_subset_of(&parent.grant) {
663 return Err(AuthorizationError::new(
664 "GRANT_DELEGATION_INVALID",
665 "derived grant broadens parent authority",
666 ));
667 }
668 }
669 Ok(())
670}
671
672pub fn authorize(
673 request: &AuthorizationRequest,
674 signed: Option<&SignedGrant>,
675 signer: &GrantSigner,
676 grants: &BTreeMap<String, SignedGrant>,
677 revoked: &RevocationState,
678) -> AuthorizationDecision {
679 let evaluated_at = request.context.timestamp;
680 let request_bytes = serde_json::to_vec(request).unwrap_or_default();
681 let request_hash = hex(&Sha256::digest(request_bytes));
682 let result = signed
683 .ok_or_else(|| AuthorizationError::new("GRANT_SCOPE_DENIED", "a scoped grant is required"))
684 .and_then(|grant| verify_grant(grant, signer, request, grants, revoked));
685 let allowed = result.is_ok();
686 let reason = result
687 .err()
688 .map(|e| format!("{}: {}", e.code, e.message))
689 .unwrap_or_else(|| "GRANT_ALLOWED: signed grant authorizes resource operation".into());
690 let seed = format!("{request_hash}:{evaluated_at}:{allowed}:{reason}");
691 let decision_id = format!("decision_{}", &hex(&Sha256::digest(seed.as_bytes()))[..24]);
692 let mut decision = AuthorizationDecision {
693 decision_id,
694 request_hash,
695 decision_hash: String::new(),
696 allowed,
697 subject: request.subject.clone(),
698 tenant_id: request.tenant_id.clone(),
699 application_id: request.application_id.clone(),
700 resource: request.resource.clone(),
701 capability: request.capability.clone(),
702 grant_id: signed.map(|v| v.grant.id.clone()),
703 policy_version: POLICY_VERSION.into(),
704 evaluated_at,
705 reason,
706 };
707 decision.decision_hash = hex(&Sha256::digest(
708 serde_json::to_vec(&decision).unwrap_or_default(),
709 ));
710 decision
711}
712
713#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct ReconciliationRequest {
715 pub original_grant: SignedGrant,
716 pub current_request: AuthorizationRequest,
717 pub original_revision: String,
718 pub current_revision: String,
719}
720pub fn reauthorize_reconciliation(
721 input: &ReconciliationRequest,
722 signer: &GrantSigner,
723 grants: &BTreeMap<String, SignedGrant>,
724 revoked: &RevocationState,
725) -> Result<AuthorizationDecision, AuthorizationError> {
726 let decision = authorize(
727 &input.current_request,
728 Some(&input.original_grant),
729 signer,
730 grants,
731 revoked,
732 );
733 if !decision.allowed {
734 return Err(AuthorizationError::new(
735 "REAUTHORIZATION_REQUIRED",
736 decision.reason,
737 ));
738 }
739 if input.original_revision != input.current_revision {
740 return Err(AuthorizationError::new(
741 "REAUTHORIZATION_REQUIRED",
742 "application revision changed while offline",
743 ));
744 }
745 Ok(decision)
746}
747
748#[derive(Debug, Clone, Serialize, Deserialize)]
749pub struct ExecutionClaim {
750 pub execution_id: String,
751 pub workload_id: String,
752 pub worker: Subject,
753 pub grant: SignedGrant,
754}
755#[derive(Default)]
756pub struct ClaimRegistry {
757 claimed: BTreeSet<String>,
758}
759impl ClaimRegistry {
760 pub fn claim(
761 &mut self,
762 claim: &ExecutionClaim,
763 request: &AuthorizationRequest,
764 signer: &GrantSigner,
765 grants: &BTreeMap<String, SignedGrant>,
766 revoked: &RevocationState,
767 ) -> Result<AuthorizationDecision, AuthorizationError> {
768 if !matches!(&claim.worker, Subject::Worker(_))
769 || claim.worker != request.subject
770 || request.context.execution_id.as_ref() != Some(&claim.execution_id)
771 || request.context.workload_id.as_ref() != Some(&claim.workload_id)
772 {
773 return Err(AuthorizationError::new(
774 "GRANT_SCOPE_DENIED",
775 "worker is not assigned to this workload execution",
776 ));
777 }
778 if self.claimed.contains(&claim.execution_id) {
779 return Err(AuthorizationError::new(
780 "GRANT_INVALID",
781 "execution already claimed",
782 ));
783 }
784 let decision = authorize(request, Some(&claim.grant), signer, grants, revoked);
785 if !decision.allowed {
786 return Err(AuthorizationError::new(
787 "GRANT_SCOPE_DENIED",
788 decision.reason,
789 ));
790 }
791 self.claimed.insert(claim.execution_id.clone());
792 Ok(decision)
793 }
794}
795
796pub fn validate_resource_uri(uri: &str, application_id: &str) -> Result<(), AuthorizationError> {
797 let prefix = format!("flow://{application_id}/");
798 let root = format!("flow://{application_id}");
799 if (uri != root && !uri.starts_with(&prefix))
800 || uri.contains("..")
801 || uri.contains('?')
802 || uri.contains('#')
803 {
804 return Err(AuthorizationError::new(
805 "GRANT_SCOPE_DENIED",
806 "resource is not a stable application-scoped FeltDB identity",
807 ));
808 }
809 Ok(())
810}
811fn unix_seconds() -> i64 {
812 SystemTime::now()
813 .duration_since(UNIX_EPOCH)
814 .unwrap_or_default()
815 .as_secs() as i64
816}
817fn hex(bytes: &[u8]) -> String {
818 bytes.iter().map(|v| format!("{v:02x}")).collect()
819}
820fn decode_hex(value: &str) -> Result<Vec<u8>, AuthorizationError> {
821 if value.len() % 2 != 0 {
822 return Err(AuthorizationError::new(
823 "GRANT_INVALID",
824 "invalid hex encoding",
825 ));
826 }
827 (0..value.len())
828 .step_by(2)
829 .map(|index| {
830 u8::from_str_radix(&value[index..index + 2], 16)
831 .map_err(|_| AuthorizationError::new("GRANT_INVALID", "invalid hex encoding"))
832 })
833 .collect()
834}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 fn signer() -> GrantSigner {
840 GrantSigner::new("test", vec![7; 32]).unwrap()
841 }
842 fn grant() -> Grant {
843 Grant {
844 contract_version: 1,
845 id: "g1".into(),
846 issuer: Subject::Human("owner".into()),
847 subject: Subject::Agent("coordinator".into()),
848 tenant_id: "relief".into(),
849 application_id: "relief-fabric".into(),
850 capabilities: ["state:read".into(), "state:write".into()].into(),
851 resources: vec![ResourceScope {
852 kind: ResourceScopeKind::ResourcePrefix,
853 uri: "flow://relief-fabric/incidents/*".into(),
854 }],
855 constraints: Default::default(),
856 issued_at: 100,
857 expires_at: 300,
858 parent_grant: None,
859 revision: 1,
860 status: GrantStatus::Active,
861 }
862 }
863 fn request() -> AuthorizationRequest {
864 AuthorizationRequest {
865 subject: Subject::Agent("coordinator".into()),
866 tenant_id: "relief".into(),
867 application_id: "relief-fabric".into(),
868 resource: Resource {
869 uri: "flow://relief-fabric/incidents/123".into(),
870 attributes: BTreeMap::new(),
871 },
872 capability: "state:read".into(),
873 context: AuthorizationContext {
874 timestamp: 200,
875 ..Default::default()
876 },
877 grant_id: Some("g1".into()),
878 subject_attributes: BTreeMap::new(),
879 }
880 }
881 #[test]
882 fn signed_scope_and_tampering() {
883 let signer = signer();
884 let signed = signer.sign(grant()).unwrap();
885 let grants = [("g1".into(), signed.clone())].into();
886 assert!(
887 authorize(
888 &request(),
889 Some(&signed),
890 &signer,
891 &grants,
892 &Default::default()
893 )
894 .allowed
895 );
896 let mut bad = signed.clone();
897 bad.grant.capabilities.insert("secret:read".into());
898 assert_eq!(
899 verify_grant(&bad, &signer, &request(), &grants, &Default::default())
900 .unwrap_err()
901 .code,
902 "GRANT_INVALID"
903 );
904 }
905 #[test]
906 fn cross_boundary_and_context_denied() {
907 let signer = signer();
908 let signed = signer.sign(grant()).unwrap();
909 let grants = [("g1".into(), signed.clone())].into();
910 let mut r = request();
911 r.application_id = "other".into();
912 assert!(!authorize(&r, Some(&signed), &signer, &grants, &Default::default()).allowed);
913 let mut r = request();
914 r.resource.uri = "flow://relief-fabric/assignments/1".into();
915 assert!(!authorize(&r, Some(&signed), &signer, &grants, &Default::default()).allowed);
916 }
917 #[test]
918 fn delegation_only_narrows() {
919 let signer = signer();
920 let parent = signer.sign(grant()).unwrap();
921 let mut child = grant();
922 child.id = "g2".into();
923 child.issuer = child.subject.clone();
924 child.subject = Subject::Workload("w1".into());
925 child.capabilities = ["state:write".into()].into();
926 child.resources = vec![ResourceScope {
927 kind: ResourceScopeKind::Resource,
928 uri: "flow://relief-fabric/incidents/123".into(),
929 }];
930 child.expires_at = 250;
931 child.parent_grant = Some("g1".into());
932 assert!(child.is_subset_of(&parent.grant));
933 child.capabilities.insert("secret:read".into());
934 assert!(!child.is_subset_of(&parent.grant));
935 }
936 #[test]
937 fn expiration_and_parent_revocation_propagate() {
938 let signer = signer();
939 let signed = signer.sign(grant()).unwrap();
940 let grants = [("g1".into(), signed.clone())].into();
941 let mut r = request();
942 r.context.timestamp = 301;
943 assert!(
944 authorize(&r, Some(&signed), &signer, &grants, &Default::default())
945 .reason
946 .starts_with("GRANT_EXPIRED")
947 );
948 let mut revoked = RevocationState::default();
949 revoked.grants.insert("g1".into());
950 assert!(
951 authorize(&request(), Some(&signed), &signer, &grants, &revoked)
952 .reason
953 .starts_with("GRANT_REVOKED")
954 );
955 }
956 #[test]
957 fn connection_use_never_implies_secret_read() {
958 let signer = signer();
959 let mut g = grant();
960 g.capabilities = ["connection:use".into()].into();
961 g.resources = vec![ResourceScope {
962 kind: ResourceScopeKind::Connection,
963 uri: "flow://relief-fabric/connections/weather".into(),
964 }];
965 let signed = signer.sign(g).unwrap();
966 let grants = [("g1".into(), signed.clone())].into();
967 let mut r = request();
968 r.resource.uri = "flow://relief-fabric/connections/weather".into();
969 r.capability = "connection:use".into();
970 assert!(authorize(&r, Some(&signed), &signer, &grants, &Default::default()).allowed);
971 r.capability = "secret:read".into();
972 assert!(!authorize(&r, Some(&signed), &signer, &grants, &Default::default()).allowed);
973 }
974 #[test]
975 fn offline_reconciliation_rechecks_revision_and_revocation() {
976 let signer = signer();
977 let signed = signer.sign(grant()).unwrap();
978 let grants = [("g1".into(), signed.clone())].into();
979 let input = ReconciliationRequest {
980 original_grant: signed,
981 current_request: request(),
982 original_revision: "r1".into(),
983 current_revision: "r2".into(),
984 };
985 assert_eq!(
986 reauthorize_reconciliation(&input, &signer, &grants, &Default::default())
987 .unwrap_err()
988 .code,
989 "REAUTHORIZATION_REQUIRED"
990 );
991 }
992 #[test]
993 fn public_verifier_and_worker_claim_enforce_assignment_and_replay() {
994 let signer = signer();
995 let verifier = GrantSigner::verifier_hex("test", &signer.public_key_hex()).unwrap();
996 let mut g = grant();
997 g.subject = Subject::Worker("worker-42".into());
998 g.capabilities = ["workload:claim".into()].into();
999 g.resources = vec![ResourceScope {
1000 kind: ResourceScopeKind::Workload,
1001 uri: "flow://relief-fabric/workloads/w1".into(),
1002 }];
1003 let signed = signer.sign(g).unwrap();
1004 assert!(verifier.verify_signature(&signed).is_ok());
1005 let grants = [("g1".into(), signed.clone())].into();
1006 let mut request = request();
1007 request.subject = Subject::Worker("worker-42".into());
1008 request.capability = "workload:claim".into();
1009 request.resource.uri = "flow://relief-fabric/workloads/w1".into();
1010 request.context.execution_id = Some("e1".into());
1011 request.context.workload_id = Some("w1".into());
1012 let claim = ExecutionClaim {
1013 execution_id: "e1".into(),
1014 workload_id: "w1".into(),
1015 worker: request.subject.clone(),
1016 grant: signed,
1017 };
1018 let mut registry = ClaimRegistry::default();
1019 assert!(registry
1020 .claim(&claim, &request, &verifier, &grants, &Default::default())
1021 .is_ok());
1022 assert_eq!(
1023 registry
1024 .claim(&claim, &request, &verifier, &grants, &Default::default())
1025 .unwrap_err()
1026 .message,
1027 "execution already claimed"
1028 );
1029 }
1030}