Skip to main content

liminal_server/services/site/
effects.rs

1//! Builder grants use Lys's existing delegation and revocation routes.
2
3use super::{BuilderGrants, SiteError};
4use crate::services::lys_http::{LysHttp, segment};
5use serde::Deserialize;
6use sha2::{Digest, Sha256};
7use std::sync::Arc;
8
9/// Operator-selected machine-held grants from which the two rights derive.
10#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct GrantSources {
13    /// A source allowing delegation of the service's plan action to agents.
14    pub plan: String,
15    /// A source allowing delegation of the service's serve action to agents.
16    pub serve: String,
17    /// The credential Lys admits on its grant routes for the account holding both sources (a
18    /// registrar or service-account app credential, mode 0600). Lys admits no agent or machine
19    /// pass there; absent, the machine pass is offered.
20    #[serde(default)]
21    pub credential_path: Option<std::path::PathBuf>,
22    /// The app credential that places resources (`[auth.lys].app_credential_path`) and the
23    /// network a site's service is placed under; filled from `[auth.lys]`, never from this table.
24    #[serde(skip)]
25    pub placement: Option<(std::path::PathBuf, String)>,
26}
27
28/// Remote effects are idempotent by the immutable draft and effect label.
29pub trait SiteEffects: Send + Sync {
30    /// Returns the active agent's responsible person from its durable record.
31    ///
32    /// # Errors
33    /// Unknown or retired agent; no guessed owner is returned.
34    fn agent_owner(&self, agent: &str) -> Result<String, SiteError>;
35    /// Delegates exactly two use-only grants on this service.
36    ///
37    /// # Errors
38    /// Lys refusal or uncertain effect; an explicit retry reuses each operation.
39    fn bind(
40        &self,
41        draft: &str,
42        site: &str,
43        agent: &str,
44        at_ms: u64,
45    ) -> Result<BuilderGrants, SiteError>;
46    /// Revokes the recorded grant, never an inferred relation or ancestor.
47    ///
48    /// # Errors
49    /// Lys refusal or transport failure.
50    fn revoke(&self, draft: &str, label: &str, grant: &str) -> Result<(), SiteError>;
51    /// Places `kind:id` under the exchange's network in Lys, so the network's grants reach it.
52    ///
53    /// # Errors
54    /// Lys refusal or transport failure.
55    fn place(&self, _kind: &str, _id: &str) -> Result<(), SiteError> {
56        Ok(())
57    }
58}
59
60/// Production grants use the renewing machine pass, never an agent's token.
61pub struct LysSiteEffects {
62    http: Arc<dyn LysHttp>,
63    pass: Option<crate::link_pass::MachinePass>,
64    agents: Arc<crate::agents::runtime::Agents>,
65    sources: GrantSources,
66    /// Bounds returned opaque grant ids before they enter the durable snapshot.
67    metadata_bytes: std::num::NonZeroUsize,
68}
69
70impl LysSiteEffects {
71    /// Binds configured sources to the existing machine identity and agent registry.
72    #[must_use]
73    pub fn new(
74        http: Arc<dyn LysHttp>,
75        pass: Option<crate::link_pass::MachinePass>,
76        agents: Arc<crate::agents::runtime::Agents>,
77        sources: GrantSources,
78        metadata_bytes: std::num::NonZeroUsize,
79    ) -> Self {
80        Self {
81            http,
82            pass,
83            agents,
84            sources,
85            metadata_bytes,
86        }
87    }
88
89    fn delegate(&self, request: Delegation<'_>) -> Result<String, SiteError> {
90        let Delegation {
91            draft,
92            site,
93            agent,
94            responsible,
95            action,
96            source,
97            at_ms,
98        } = request;
99        let pass = self.bearer()?;
100        let source_grant = self
101            .http
102            .get(&pass, &format!("grants/{}", segment(source)))
103            .map_err(|_| SiteError::Invalid("Lys source grant read failed"))?;
104        if !source_grant.succeeded() {
105            return Err(SiteError::Pass(source_grant.refusal()));
106        }
107        if source_grant.body["standing"]["stands"] != true {
108            return Err(SiteError::Invalid("Lys source grant is not standing"));
109        }
110        let ends_at = source_grant
111            .body
112            .get("effective_ends_at")
113            .ok_or(SiteError::Invalid("Lys source grant window"))?;
114        if !ends_at.is_null() && !ends_at.is_u64() {
115            return Err(SiteError::Invalid("Lys source grant window"));
116        }
117        // Lys passes a grant on only for its own resource: a service-held source yields the
118        // service, and a network-held source yields the network. liminal still lets the builder
119        // plan and serve only the site it is bound to; unbinding revokes both grants.
120        let resource = source_grant
121            .body
122            .get("resource")
123            .filter(|resource| {
124                resource["kind"] == "liminal.service" || resource["kind"] == "liminal.network"
125            })
126            .cloned()
127            .unwrap_or_else(|| serde_json::json!({"kind":"liminal.service","id":site}));
128        let answer = self.post(
129            "grants",
130            &serde_json::json!({
131                "operation": operation(draft, action), "route":"api", "source":source,
132                "recipient":agent, "responsible":responsible,
133                "resource":resource, "relation":relation_of(action),
134                "pass_on":{"kind":"use_only"}, "window":{"starts_at":at_ms / 1000,"ends_at":ends_at}
135            }),
136        )?;
137        let grant = answer
138            .get("grant")
139            .and_then(serde_json::Value::as_str)
140            .filter(|id| {
141                !id.is_empty()
142                    && id.len() <= self.metadata_bytes.get()
143                    && !id.chars().any(char::is_control)
144            })
145            .ok_or(SiteError::Invalid("Lys grant receipt"))?;
146        Ok(grant.into())
147    }
148
149    /// Places the site's service under its network, keyed by the service; Lys's own refusal of
150    /// an existing placement is the placement already standing.
151    fn place_in_network(&self, kind: &str, id: &str) -> Result<(), SiteError> {
152        let Some((path, network)) = &self.sources.placement else {
153            return Ok(());
154        };
155        let credential = zeroize::Zeroizing::new(
156            std::fs::read_to_string(path)
157                .map_err(|_| SiteError::Invalid("app credential unreadable"))?
158                .trim()
159                .to_owned(),
160        );
161        let answer = self
162            .http
163            .post(
164                &credential,
165                "apps/liminal/placements",
166                &serde_json::json!({
167                    "operation": operation(&format!("{kind}:{id}"), "placement"),
168                    "child": {"kind": kind, "id": id},
169                    "parent": {"kind": "liminal.network", "id": network},
170                }),
171            )
172            .map_err(|_| SiteError::Invalid("Lys placement transport failed"))?;
173        if answer.succeeded()
174            || answer.body["reason"]
175                == "placement_invalid: the resource is already placed in a parent"
176        {
177            return Ok(());
178        }
179        Err(SiteError::Pass(answer.refusal()))
180    }
181
182    /// The grant routes' bearer: the configured account credential, else the machine pass.
183    fn bearer(&self) -> Result<zeroize::Zeroizing<String>, SiteError> {
184        if let Some(path) = &self.sources.credential_path {
185            let credential = std::fs::read_to_string(path)
186                .map_err(|_| SiteError::Invalid("construction grant credential unreadable"))?;
187            return Ok(zeroize::Zeroizing::new(credential.trim().to_owned()));
188        }
189        self.pass
190            .as_ref()
191            .ok_or(SiteError::Invalid("construction machine pass absent"))?
192            .current()
193            .map_err(|_| SiteError::Invalid("machine pass unavailable"))
194    }
195
196    fn post(&self, route: &str, body: &serde_json::Value) -> Result<serde_json::Value, SiteError> {
197        let pass = self.bearer()?;
198        let answer = self
199            .http
200            .post(&pass, route, body)
201            .map_err(|_| SiteError::Invalid("Lys grant transport failed"))?;
202        if !answer.succeeded() {
203            return Err(SiteError::Pass(answer.refusal()));
204        }
205        if answer.body.get("degraded").is_some() {
206            return Err(SiteError::Invalid("Lys grant projection degraded"));
207        }
208        Ok(answer.body)
209    }
210}
211
212/// Domain separation gives every remote leg its own stable Lys operation id.
213#[must_use]
214pub fn operation(draft: &str, effect: &str) -> String {
215    let mut hash = Sha256::new();
216    for value in ["liminal-construction", draft, effect] {
217        hash.update(value.as_bytes());
218        hash.update([0]);
219    }
220    let bytes = hash.finalize();
221    format!("op-{}", hex::encode(&bytes[..16]))
222}
223
224impl SiteEffects for LysSiteEffects {
225    fn agent_owner(&self, agent: &str) -> Result<String, SiteError> {
226        let record = self
227            .agents
228            .registry()
229            .record(agent)
230            .map_err(|_| SiteError::Invalid("builder agent absent"))?;
231        if !record.active() {
232            return Err(SiteError::Refused(format!("agent {agent} is retired")));
233        }
234        Ok(record.owner)
235    }
236    fn bind(
237        &self,
238        draft: &str,
239        site: &str,
240        agent: &str,
241        at_ms: u64,
242    ) -> Result<BuilderGrants, SiteError> {
243        let owner = self.agent_owner(agent)?;
244        // The network's grants reach the service only once Lys knows it sits in that network.
245        self.place_in_network("liminal.service", site)?;
246        let plan = self.delegate(Delegation {
247            draft,
248            site,
249            agent,
250            responsible: &owner,
251            action: "plan",
252            source: &self.sources.plan,
253            at_ms,
254        })?;
255        let serve = self.delegate(Delegation {
256            draft,
257            site,
258            agent,
259            responsible: &owner,
260            action: "serve",
261            source: &self.sources.serve,
262            at_ms,
263        })?;
264        Ok(BuilderGrants { plan, serve })
265    }
266    fn place(&self, kind: &str, id: &str) -> Result<(), SiteError> {
267        self.place_in_network(kind, id)
268    }
269    fn revoke(&self, draft: &str, label: &str, grant: &str) -> Result<(), SiteError> {
270        self.post(&format!("grants/{}/revoke", segment(grant)), &serde_json::json!({
271            "operation":operation(draft,label), "route":"api", "reason":"construction binding ended"
272        })).map(|_| ())
273    }
274}
275
276/// The schema relation that carries one action: Lys holds each name of a kind once, so the
277/// relation granting `plan` is `planner` and the one granting `serve` is `server`.
278fn relation_of(action: &str) -> &str {
279    match action {
280        "plan" => "planner",
281        "serve" => "server",
282        other => other,
283    }
284}
285
286#[derive(Clone, Copy)]
287struct Delegation<'a> {
288    draft: &'a str,
289    site: &'a str,
290    agent: &'a str,
291    responsible: &'a str,
292    action: &'a str,
293    source: &'a str,
294    at_ms: u64,
295}