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    /// Whether these are an open exchange's effects (ACCESS-015), where
59    /// nothing is placed in Lys; a gated installation with no placer refuses.
60    fn open(&self) -> bool {
61        false
62    }
63}
64
65/// Production grants use the renewing machine pass, never an agent's token.
66pub struct LysSiteEffects {
67    http: Arc<dyn LysHttp>,
68    pass: Option<crate::link_pass::MachinePass>,
69    agents: Arc<crate::agents::runtime::Agents>,
70    sources: GrantSources,
71    /// Bounds returned opaque grant ids before they enter the durable snapshot.
72    metadata_bytes: std::num::NonZeroUsize,
73}
74
75impl LysSiteEffects {
76    /// Binds configured sources to the existing machine identity and agent registry.
77    #[must_use]
78    pub fn new(
79        http: Arc<dyn LysHttp>,
80        pass: Option<crate::link_pass::MachinePass>,
81        agents: Arc<crate::agents::runtime::Agents>,
82        sources: GrantSources,
83        metadata_bytes: std::num::NonZeroUsize,
84    ) -> Self {
85        Self {
86            http,
87            pass,
88            agents,
89            sources,
90            metadata_bytes,
91        }
92    }
93
94    fn delegate(&self, request: Delegation<'_>) -> Result<String, SiteError> {
95        let Delegation {
96            draft,
97            site,
98            agent,
99            responsible,
100            action,
101            source,
102            at_ms,
103        } = request;
104        let pass = self.bearer()?;
105        let source_grant = self
106            .http
107            .get(&pass, &format!("grants/{}", segment(source)))
108            .map_err(|_| SiteError::Invalid("Lys source grant read failed"))?;
109        if !source_grant.succeeded() {
110            return Err(SiteError::Pass(source_grant.refusal()));
111        }
112        if source_grant.body["standing"]["stands"] != true {
113            return Err(SiteError::Invalid("Lys source grant is not standing"));
114        }
115        let ends_at = source_grant
116            .body
117            .get("effective_ends_at")
118            .ok_or(SiteError::Invalid("Lys source grant window"))?;
119        if !ends_at.is_null() && !ends_at.is_u64() {
120            return Err(SiteError::Invalid("Lys source grant window"));
121        }
122        // Lys passes a grant on only for its own resource: a service-held source yields the
123        // service, and a network-held source yields the network. liminal still lets the builder
124        // plan and serve only the site it is bound to; unbinding revokes both grants.
125        let resource = source_grant
126            .body
127            .get("resource")
128            .filter(|resource| {
129                resource["kind"] == "liminal.service" || resource["kind"] == "liminal.network"
130            })
131            .cloned()
132            .unwrap_or_else(|| serde_json::json!({"kind":"liminal.service","id":site}));
133        let answer = self.post(
134            "grants",
135            &serde_json::json!({
136                "operation": operation(draft, action), "route":"api", "source":source,
137                "recipient":agent, "responsible":responsible,
138                "resource":resource, "relation":relation_of(action),
139                "pass_on":{"kind":"use_only"}, "window":{"starts_at":at_ms / 1000,"ends_at":ends_at}
140            }),
141        )?;
142        let grant = answer
143            .get("grant")
144            .and_then(serde_json::Value::as_str)
145            .filter(|id| {
146                !id.is_empty()
147                    && id.len() <= self.metadata_bytes.get()
148                    && !id.chars().any(char::is_control)
149            })
150            .ok_or(SiteError::Invalid("Lys grant receipt"))?;
151        Ok(grant.into())
152    }
153
154    /// Places the site's service under its network, keyed by the service; Lys's own refusal of
155    /// an existing placement is the placement already standing.
156    fn place_in_network(&self, kind: &str, id: &str) -> Result<(), SiteError> {
157        let Some((path, network)) = &self.sources.placement else {
158            return Ok(());
159        };
160        let credential = zeroize::Zeroizing::new(
161            std::fs::read_to_string(path)
162                .map_err(|_| SiteError::Invalid("app credential unreadable"))?
163                .trim()
164                .to_owned(),
165        );
166        let answer = self
167            .http
168            .post(
169                &credential,
170                "apps/liminal/placements",
171                &serde_json::json!({
172                    "operation": operation(&format!("{kind}:{id}"), "placement"),
173                    "child": {"kind": kind, "id": id},
174                    "parent": {"kind": "liminal.network", "id": network},
175                }),
176            )
177            .map_err(|_| SiteError::Invalid("Lys placement transport failed"))?;
178        if answer.succeeded()
179            || answer.body["reason"]
180                == "placement_invalid: the resource is already placed in a parent"
181        {
182            return Ok(());
183        }
184        Err(SiteError::Pass(answer.refusal()))
185    }
186
187    /// The grant routes' bearer: the configured account credential, else the machine pass.
188    fn bearer(&self) -> Result<zeroize::Zeroizing<String>, SiteError> {
189        if let Some(path) = &self.sources.credential_path {
190            let credential = std::fs::read_to_string(path)
191                .map_err(|_| SiteError::Invalid("construction grant credential unreadable"))?;
192            return Ok(zeroize::Zeroizing::new(credential.trim().to_owned()));
193        }
194        self.pass
195            .as_ref()
196            .ok_or(SiteError::Invalid("construction machine pass absent"))?
197            .current()
198            .map_err(|_| SiteError::Invalid("machine pass unavailable"))
199    }
200
201    fn post(&self, route: &str, body: &serde_json::Value) -> Result<serde_json::Value, SiteError> {
202        let pass = self.bearer()?;
203        let answer = self
204            .http
205            .post(&pass, route, body)
206            .map_err(|_| SiteError::Invalid("Lys grant transport failed"))?;
207        if !answer.succeeded() {
208            return Err(SiteError::Pass(answer.refusal()));
209        }
210        if answer.body.get("degraded").is_some() {
211            return Err(SiteError::Invalid("Lys grant projection degraded"));
212        }
213        Ok(answer.body)
214    }
215}
216
217/// Domain separation gives every remote leg its own stable Lys operation id.
218#[must_use]
219pub fn operation(draft: &str, effect: &str) -> String {
220    let mut hash = Sha256::new();
221    for value in ["liminal-construction", draft, effect] {
222        hash.update(value.as_bytes());
223        hash.update([0]);
224    }
225    let bytes = hash.finalize();
226    format!("op-{}", hex::encode(&bytes[..16]))
227}
228
229impl SiteEffects for LysSiteEffects {
230    fn agent_owner(&self, agent: &str) -> Result<String, SiteError> {
231        let record = self
232            .agents
233            .registry()
234            .record(agent)
235            .map_err(|_| SiteError::Invalid("builder agent absent"))?;
236        if !record.active() {
237            return Err(SiteError::Refused(format!("agent {agent} is retired")));
238        }
239        Ok(record.owner)
240    }
241    fn bind(
242        &self,
243        draft: &str,
244        site: &str,
245        agent: &str,
246        at_ms: u64,
247    ) -> Result<BuilderGrants, SiteError> {
248        let owner = self.agent_owner(agent)?;
249        // The network's grants reach the service only once Lys knows it sits in that network.
250        self.place_in_network("liminal.service", site)?;
251        let plan = self.delegate(Delegation {
252            draft,
253            site,
254            agent,
255            responsible: &owner,
256            action: "plan",
257            source: &self.sources.plan,
258            at_ms,
259        })?;
260        let serve = self.delegate(Delegation {
261            draft,
262            site,
263            agent,
264            responsible: &owner,
265            action: "serve",
266            source: &self.sources.serve,
267            at_ms,
268        })?;
269        Ok(BuilderGrants { plan, serve })
270    }
271    fn place(&self, kind: &str, id: &str) -> Result<(), SiteError> {
272        self.place_in_network(kind, id)
273    }
274    fn revoke(&self, draft: &str, label: &str, grant: &str) -> Result<(), SiteError> {
275        self.post(&format!("grants/{}/revoke", segment(grant)), &serde_json::json!({
276            "operation":operation(draft,label), "route":"api", "reason":"construction binding ended"
277        })).map(|_| ())
278    }
279}
280
281/// The schema relation that carries one action: Lys holds each name of a kind once, so the
282/// relation granting `plan` is `planner` and the one granting `serve` is `server`.
283fn relation_of(action: &str) -> &str {
284    match action {
285        "plan" => "planner",
286        "serve" => "server",
287        other => other,
288    }
289}
290
291#[derive(Clone, Copy)]
292struct Delegation<'a> {
293    draft: &'a str,
294    site: &'a str,
295    agent: &'a str,
296    responsible: &'a str,
297    action: &'a str,
298    source: &'a str,
299    at_ms: u64,
300}