Skip to main content

feltdb/
authorization.rs

1//! Canonical resource authorization and portable signed grants.
2//!
3//! This module is deliberately independent of HTTP and Studio. Rust owns every
4//! security decision; other surfaces serialize and consume these contracts.
5
6use 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/// File-backed control-plane state. Every mutation is persisted before it is
442/// returned to callers, so revocation and delegation survive process restarts.
443#[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}