Skip to main content

runx_runtime/effects/
provider_permission.rs

1// rust-style-allow: large-file -- provider permission admission keeps effect
2// parsing, operator-grant validation, witness projection, and tests together so
3// self-attested scope grants remain audited in one place.
4use std::collections::{BTreeMap, BTreeSet};
5
6use runx_contracts::{AuthorityVerb, JsonObject, JsonValue, Reference, ReferenceType};
7use runx_core::state_machine::AuthorityAdmissionWitness;
8
9use super::{EffectAdmission, EffectStepRequest, RuntimeEffect, RuntimeEffectError};
10
11pub const PROVIDER_PERMISSION_EFFECT_FAMILY: &str = "provider_permission";
12pub const PROVIDER_PERMISSION_GRANT_ID_ENV: &str = "RUNX_PROVIDER_PERMISSION_GRANT_ID";
13pub const PROVIDER_PERMISSION_GRANTED_SCOPES_ENV: &str = "RUNX_PROVIDER_PERMISSION_GRANTED_SCOPES";
14
15#[derive(Clone, Debug, Default)]
16pub struct ProviderPermissionEffect;
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct ProviderPermissionAdmission {
20    pub grant_id: String,
21    pub required_scopes: Vec<String>,
22    pub granted_scopes: Vec<String>,
23}
24
25impl RuntimeEffect for ProviderPermissionEffect {
26    fn family(&self) -> &'static str {
27        PROVIDER_PERMISSION_EFFECT_FAMILY
28    }
29
30    fn admit(
31        &self,
32        request: EffectStepRequest<'_>,
33    ) -> Result<Option<EffectAdmission>, RuntimeEffectError> {
34        let Some(policy) = provider_permission_policy(request.step.policy.as_ref()) else {
35            return Ok(None);
36        };
37        let Some(plan) = provider_permission_plan(&request, policy)? else {
38            return Ok(None);
39        };
40        if !plan.missing_scopes.is_empty() {
41            return Err(provider_permission_denial(&request, &plan));
42        }
43
44        let witness = provider_permission_witness(&request, &plan);
45        Ok(Some(EffectAdmission::new(
46            PROVIDER_PERMISSION_EFFECT_FAMILY,
47            plan.verb.clone(),
48            witness,
49            ProviderPermissionAdmission {
50                grant_id: plan.grant_id,
51                required_scopes: plan.required_scopes,
52                granted_scopes: plan.granted_scopes,
53            },
54        )))
55    }
56
57    fn authority_grant_refs(
58        &self,
59        admission: &EffectAdmission,
60    ) -> Result<Vec<Reference>, RuntimeEffectError> {
61        let context = admission
62            .context::<ProviderPermissionAdmission>()
63            .ok_or_else(|| RuntimeEffectError::Failed {
64                family: PROVIDER_PERMISSION_EFFECT_FAMILY.to_owned(),
65                operation: "authority grant evidence",
66                message: "provider permission admission context is missing".to_owned(),
67            })?;
68        Ok(vec![Reference::runx(
69            ReferenceType::Grant,
70            &context.grant_id,
71        )])
72    }
73}
74
75#[derive(Debug)]
76struct ProviderPermissionPlan {
77    grant_id: String,
78    required_scopes: Vec<String>,
79    granted_scopes: Vec<String>,
80    missing_scopes: Vec<String>,
81    verb: AuthorityVerb,
82}
83
84fn provider_permission_plan(
85    request: &EffectStepRequest<'_>,
86    policy: &JsonObject,
87) -> Result<Option<ProviderPermissionPlan>, RuntimeEffectError> {
88    let verb = required_verb_field(policy)?;
89    if policy.contains_key("granted_scopes") {
90        return Err(RuntimeEffectError::Denied {
91            family: PROVIDER_PERMISSION_EFFECT_FAMILY.to_owned(),
92            verb,
93            message: "provider_permission.granted_scopes is self-attested by the graph policy; provide granted scopes through the operator grant environment instead".to_owned(),
94        });
95    }
96    let required_scopes = string_array_field(policy, "required_scopes")?
97        .unwrap_or_else(|| request.step.scopes.clone());
98    if required_scopes.is_empty() {
99        return Ok(None);
100    }
101    let granted_scopes = granted_scopes_from_env(request.env);
102    let missing_scopes = missing_scopes(&required_scopes, &granted_scopes);
103    let expected_grant_id = string_field(policy, "grant_id");
104    let grant_id = provider_grant_id(request.env, &verb)?;
105    if let Some(expected) = expected_grant_id
106        && expected != grant_id
107    {
108        return Err(RuntimeEffectError::Denied {
109            family: PROVIDER_PERMISSION_EFFECT_FAMILY.to_owned(),
110            verb,
111            message: format!(
112                "step '{}' requires provider grant '{}', but operator grant '{}' was supplied",
113                request.step.id, expected, grant_id
114            ),
115        });
116    }
117
118    Ok(Some(ProviderPermissionPlan {
119        grant_id,
120        required_scopes,
121        granted_scopes,
122        missing_scopes,
123        verb,
124    }))
125}
126
127fn provider_permission_denial(
128    request: &EffectStepRequest<'_>,
129    plan: &ProviderPermissionPlan,
130) -> RuntimeEffectError {
131    RuntimeEffectError::Denied {
132        family: PROVIDER_PERMISSION_EFFECT_FAMILY.to_owned(),
133        verb: plan.verb.clone(),
134        message: format!(
135            "step '{}' requires scopes [{}], but grant '{}' only provides [{}]",
136            request.step.id,
137            plan.required_scopes.join(", "),
138            plan.grant_id,
139            plan.granted_scopes.join(", ")
140        ),
141    }
142}
143
144fn provider_permission_witness(
145    request: &EffectStepRequest<'_>,
146    plan: &ProviderPermissionPlan,
147) -> AuthorityAdmissionWitness {
148    AuthorityAdmissionWitness {
149        verb: plan.verb.clone(),
150        parent_term_id: format!("provider-permission:{}", plan.grant_id),
151        child_term_id: format!(
152            "provider-permission:{}:{}",
153            request.step.id,
154            plan.required_scopes.join("+")
155        ),
156        idempotency_key: request.step.idempotency_key.clone(),
157        capability_ref: None,
158    }
159}
160
161fn provider_permission_policy(policy: Option<&JsonObject>) -> Option<&JsonObject> {
162    policy?
163        .get(PROVIDER_PERMISSION_EFFECT_FAMILY)
164        .and_then(JsonValue::as_object)
165}
166
167fn string_field<'a>(object: &'a JsonObject, key: &str) -> Option<&'a str> {
168    object.get(key).and_then(JsonValue::as_str)
169}
170
171fn string_array_field(
172    object: &JsonObject,
173    key: &str,
174) -> Result<Option<Vec<String>>, RuntimeEffectError> {
175    let Some(value) = object.get(key) else {
176        return Ok(None);
177    };
178    let Some(values) = value.as_array() else {
179        return Err(provider_permission_policy_error(format!(
180            "{key} must be an array"
181        )));
182    };
183    values
184        .iter()
185        .enumerate()
186        .map(|(index, value)| match value {
187            JsonValue::String(scope) if !scope.trim().is_empty() => Ok(scope.trim().to_owned()),
188            JsonValue::String(_) => Err(provider_permission_policy_error(format!(
189                "{key}[{index}] must be a non-empty string"
190            ))),
191            _ => Err(provider_permission_policy_error(format!(
192                "{key}[{index}] must be a string"
193            ))),
194        })
195        .collect::<Result<Vec<_>, _>>()
196        .map(Some)
197}
198
199fn provider_grant_id(
200    env: &BTreeMap<String, String>,
201    verb: &AuthorityVerb,
202) -> Result<String, RuntimeEffectError> {
203    env.get(PROVIDER_PERMISSION_GRANT_ID_ENV)
204        .map(|value| value.trim())
205        .filter(|value| !value.is_empty())
206        .map(str::to_owned)
207        .ok_or_else(|| RuntimeEffectError::Denied {
208            family: PROVIDER_PERMISSION_EFFECT_FAMILY.to_owned(),
209            verb: verb.clone(),
210            message: format!(
211                "provider permission requires explicit operator grant id in {PROVIDER_PERMISSION_GRANT_ID_ENV}"
212            ),
213        })
214}
215
216fn granted_scopes_from_env(env: &BTreeMap<String, String>) -> Vec<String> {
217    env.get(PROVIDER_PERMISSION_GRANTED_SCOPES_ENV)
218        .map(|value| parse_scope_list(value))
219        .unwrap_or_default()
220}
221
222fn parse_scope_list(value: &str) -> Vec<String> {
223    value
224        .split([',', '\n', '\t', ' '])
225        .map(str::trim)
226        .filter(|scope| !scope.is_empty())
227        .map(str::to_owned)
228        .collect()
229}
230
231fn required_verb_field(object: &JsonObject) -> Result<AuthorityVerb, RuntimeEffectError> {
232    let Some(value) = object.get("verb") else {
233        return Err(provider_permission_policy_error(
234            "verb is required".to_owned(),
235        ));
236    };
237    let Some(verb) = value.as_str() else {
238        return Err(provider_permission_policy_error(
239            "verb must be a string".to_owned(),
240        ));
241    };
242    match verb {
243        "read" => Ok(AuthorityVerb::Read),
244        "write" => Ok(AuthorityVerb::Write),
245        "comment" => Ok(AuthorityVerb::Comment),
246        "review" => Ok(AuthorityVerb::Review),
247        "merge" => Ok(AuthorityVerb::Merge),
248        "create" => Ok(AuthorityVerb::Create),
249        "update" => Ok(AuthorityVerb::Update),
250        "delete" => Ok(AuthorityVerb::Delete),
251        "execute" => Ok(AuthorityVerb::Execute),
252        "revoke" => Ok(AuthorityVerb::Revoke),
253        _ => Err(provider_permission_policy_error(format!(
254            "verb {verb:?} is not supported"
255        ))),
256    }
257}
258
259fn provider_permission_policy_error(message: String) -> RuntimeEffectError {
260    RuntimeEffectError::Failed {
261        family: PROVIDER_PERMISSION_EFFECT_FAMILY.to_owned(),
262        operation: "parse provider permission policy",
263        message,
264    }
265}
266
267fn missing_scopes(required: &[String], granted: &[String]) -> Vec<String> {
268    let granted = granted.iter().collect::<BTreeSet<_>>();
269    required
270        .iter()
271        .filter(|scope| !granted.contains(scope))
272        .cloned()
273        .collect()
274}
275
276#[cfg(test)]
277mod tests {
278    use std::collections::BTreeMap;
279    use std::io;
280    use std::path::Path;
281
282    use runx_contracts::{JsonObject, JsonValue, ReferenceType};
283    use runx_parser::GraphStep;
284
285    use super::*;
286
287    #[test]
288    fn admits_when_required_scopes_are_granted() -> Result<(), io::Error> {
289        let effect = ProviderPermissionEffect;
290        let step = test_step("read_issue", vec!["repo.read"], false, "read", false);
291        let inputs = JsonObject::new();
292        let env = provider_env("github-mcp-read", "repo.read");
293
294        let result = effect.admit(EffectStepRequest {
295            step: &step,
296            inputs: &inputs,
297            env: &env,
298            graph_dir: Path::new("."),
299        });
300        let admission = match result {
301            Ok(Some(admission)) => admission,
302            other => {
303                return Err(io::Error::other(format!(
304                    "unexpected provider permission admission: {other:?}"
305                )));
306            }
307        };
308
309        assert_eq!(admission.family(), PROVIDER_PERMISSION_EFFECT_FAMILY);
310        assert_eq!(admission.verb(), AuthorityVerb::Read);
311        let context = match admission.context::<ProviderPermissionAdmission>() {
312            Some(context) => context,
313            None => {
314                return Err(io::Error::other(
315                    "missing provider permission admission context",
316                ));
317            }
318        };
319        assert_eq!(context.required_scopes, vec!["repo.read"]);
320        assert_eq!(context.granted_scopes, vec!["repo.read"]);
321        let grant_refs = effect
322            .authority_grant_refs(&admission)
323            .map_err(|error| io::Error::other(error.to_string()))?;
324        assert_eq!(grant_refs.len(), 1);
325        assert_eq!(grant_refs[0].reference_type, ReferenceType::Grant);
326        assert_eq!(grant_refs[0].uri, "runx:grant:github-mcp-read");
327        Ok(())
328    }
329
330    #[test]
331    fn admits_revoke_verb_through_grant_ref_path() -> Result<(), io::Error> {
332        let effect = ProviderPermissionEffect;
333        let step = test_step("revoke_grant", vec!["repo.write"], true, "revoke", false);
334        let inputs = JsonObject::new();
335        let env = provider_env("github-mcp-read", "repo.write");
336
337        let result = effect.admit(EffectStepRequest {
338            step: &step,
339            inputs: &inputs,
340            env: &env,
341            graph_dir: Path::new("."),
342        });
343        let admission = match result {
344            Ok(Some(admission)) => admission,
345            other => {
346                return Err(io::Error::other(format!(
347                    "unexpected provider permission admission: {other:?}"
348                )));
349            }
350        };
351
352        assert_eq!(admission.family(), PROVIDER_PERMISSION_EFFECT_FAMILY);
353        assert_eq!(admission.verb(), AuthorityVerb::Revoke);
354        let grant_refs = effect
355            .authority_grant_refs(&admission)
356            .map_err(|error| io::Error::other(error.to_string()))?;
357        assert_eq!(grant_refs.len(), 1);
358        assert_eq!(grant_refs[0].reference_type, ReferenceType::Grant);
359        assert_eq!(grant_refs[0].uri, "runx:grant:github-mcp-read");
360        Ok(())
361    }
362
363    #[test]
364    fn denies_when_required_scope_is_not_granted() -> Result<(), io::Error> {
365        let effect = ProviderPermissionEffect;
366        let step = test_step("comment_issue", vec!["repo.write"], true, "write", false);
367        let inputs = JsonObject::new();
368        let env = provider_env("github-mcp-read", "repo.read");
369
370        let result = effect.admit(EffectStepRequest {
371            step: &step,
372            inputs: &inputs,
373            env: &env,
374            graph_dir: Path::new("."),
375        });
376        let error = match result {
377            Err(error) => error,
378            other => {
379                return Err(io::Error::other(format!(
380                    "unexpected provider permission result: {other:?}"
381                )));
382            }
383        };
384
385        match error {
386            RuntimeEffectError::Denied {
387                family,
388                verb: AuthorityVerb::Write,
389                message,
390            } if family == PROVIDER_PERMISSION_EFFECT_FAMILY
391                && message.contains("repo.write")
392                && message.contains("repo.read") =>
393            {
394                Ok(())
395            }
396            other => Err(io::Error::other(format!(
397                "unexpected denial error: {other:?}"
398            ))),
399        }
400    }
401
402    #[test]
403    fn denies_when_operator_grant_id_is_missing() -> Result<(), io::Error> {
404        let effect = ProviderPermissionEffect;
405        let step = test_step("read_issue", vec!["repo.read"], false, "read", false);
406        let inputs = JsonObject::new();
407        let env = scopes_only_env("repo.read");
408
409        let result = effect.admit(EffectStepRequest {
410            step: &step,
411            inputs: &inputs,
412            env: &env,
413            graph_dir: Path::new("."),
414        });
415        let error = match result {
416            Err(error) => error,
417            other => {
418                return Err(io::Error::other(format!(
419                    "unexpected provider permission result: {other:?}"
420                )));
421            }
422        };
423
424        match error {
425            RuntimeEffectError::Denied { message, .. }
426                if message.contains(PROVIDER_PERMISSION_GRANT_ID_ENV) =>
427            {
428                Ok(())
429            }
430            other => Err(io::Error::other(format!(
431                "unexpected missing-grant denial error: {other:?}"
432            ))),
433        }
434    }
435
436    #[test]
437    fn rejects_self_attested_granted_scopes_in_policy() -> Result<(), io::Error> {
438        let effect = ProviderPermissionEffect;
439        let step = test_step("read_issue", vec!["repo.read"], false, "read", true);
440        let inputs = JsonObject::new();
441        let env = provider_env("github-mcp-read", "repo.read");
442
443        let result = effect.admit(EffectStepRequest {
444            step: &step,
445            inputs: &inputs,
446            env: &env,
447            graph_dir: Path::new("."),
448        });
449        let error = match result {
450            Err(error) => error,
451            other => {
452                return Err(io::Error::other(format!(
453                    "unexpected provider permission result: {other:?}"
454                )));
455            }
456        };
457
458        match error {
459            RuntimeEffectError::Denied { message, .. } if message.contains("self-attested") => {
460                Ok(())
461            }
462            other => Err(io::Error::other(format!(
463                "unexpected self-attested denial error: {other:?}"
464            ))),
465        }
466    }
467
468    #[test]
469    fn rejects_missing_or_unknown_policy_verb() -> Result<(), io::Error> {
470        let effect = ProviderPermissionEffect;
471        let inputs = JsonObject::new();
472        let env = provider_env("github-mcp-read", "repo.read");
473
474        let mut missing_verb = test_step("read_issue", vec!["repo.read"], false, "read", false);
475        provider_permission_policy_mut(&mut missing_verb)?.remove("verb");
476        let error = match effect.admit(EffectStepRequest {
477            step: &missing_verb,
478            inputs: &inputs,
479            env: &env,
480            graph_dir: Path::new("."),
481        }) {
482            Ok(_) => {
483                return Err(io::Error::other(
484                    "missing provider permission verb should fail",
485                ));
486            }
487            Err(error) => error,
488        };
489        assert_policy_error(error, "verb is required")?;
490
491        let unknown_verb = test_step("read_issue", vec!["repo.read"], false, "publish", false);
492        let error = match effect.admit(EffectStepRequest {
493            step: &unknown_verb,
494            inputs: &inputs,
495            env: &env,
496            graph_dir: Path::new("."),
497        }) {
498            Ok(_) => {
499                return Err(io::Error::other(
500                    "unknown provider permission verb should fail",
501                ));
502            }
503            Err(error) => error,
504        };
505        assert_policy_error(error, "not supported")
506    }
507
508    #[test]
509    fn rejects_malformed_required_scopes() -> Result<(), io::Error> {
510        let effect = ProviderPermissionEffect;
511        let mut step = test_step("read_issue", vec!["repo.read"], false, "read", false);
512        provider_permission_policy_mut(&mut step)?.insert(
513            "required_scopes".to_owned(),
514            JsonValue::Array(vec![
515                JsonValue::String("repo.read".to_owned()),
516                JsonValue::Bool(false),
517            ]),
518        );
519        let inputs = JsonObject::new();
520        let env = provider_env("github-mcp-read", "repo.read");
521
522        let error = match effect.admit(EffectStepRequest {
523            step: &step,
524            inputs: &inputs,
525            env: &env,
526            graph_dir: Path::new("."),
527        }) {
528            Ok(_) => {
529                return Err(io::Error::other(
530                    "malformed provider permission required_scopes should fail",
531                ));
532            }
533            Err(error) => error,
534        };
535
536        assert_policy_error(error, "required_scopes[1] must be a string")
537    }
538
539    fn test_step(
540        id: &str,
541        required_scopes: Vec<&str>,
542        mutating: bool,
543        verb: &str,
544        self_attested_granted_scopes: bool,
545    ) -> GraphStep {
546        let mut permission = JsonObject::new();
547        permission.insert(
548            "grant_id".to_owned(),
549            JsonValue::String("github-mcp-read".to_owned()),
550        );
551        permission.insert("verb".to_owned(), JsonValue::String(verb.to_owned()));
552        if self_attested_granted_scopes {
553            permission.insert(
554                "granted_scopes".to_owned(),
555                JsonValue::Array(vec![JsonValue::String("repo.read".to_owned())]),
556            );
557        }
558        let mut policy = JsonObject::new();
559        policy.insert(
560            PROVIDER_PERMISSION_EFFECT_FAMILY.to_owned(),
561            JsonValue::Object(permission),
562        );
563        GraphStep {
564            id: id.to_owned(),
565            label: None,
566            skill: None,
567            tool: None,
568            run: None,
569            instructions: None,
570            artifacts: None,
571            runner: None,
572            inputs: JsonObject::new(),
573            context: BTreeMap::new(),
574            context_edges: Vec::new(),
575            context_skills: Vec::new(),
576            scopes: required_scopes
577                .into_iter()
578                .map(str::to_owned)
579                .collect::<Vec<_>>(),
580            allowed_tools: None,
581            retry: None,
582            policy: Some(policy),
583            fanout_group: None,
584            when: None,
585            mutating,
586            idempotency_key: Some(format!("{id}-key")),
587            mint_authority: None,
588            requested_scope_from: None,
589        }
590    }
591
592    fn provider_env(grant_id: &str, scopes: &str) -> BTreeMap<String, String> {
593        [
594            (
595                PROVIDER_PERMISSION_GRANT_ID_ENV.to_owned(),
596                grant_id.to_owned(),
597            ),
598            (
599                PROVIDER_PERMISSION_GRANTED_SCOPES_ENV.to_owned(),
600                scopes.to_owned(),
601            ),
602        ]
603        .into_iter()
604        .collect()
605    }
606
607    fn scopes_only_env(scopes: &str) -> BTreeMap<String, String> {
608        [(
609            PROVIDER_PERMISSION_GRANTED_SCOPES_ENV.to_owned(),
610            scopes.to_owned(),
611        )]
612        .into_iter()
613        .collect()
614    }
615
616    fn provider_permission_policy_mut(step: &mut GraphStep) -> Result<&mut JsonObject, io::Error> {
617        let Some(value) = step
618            .policy
619            .as_mut()
620            .and_then(|policy| policy.get_mut(PROVIDER_PERMISSION_EFFECT_FAMILY))
621        else {
622            return Err(io::Error::other(
623                "test step should carry provider permission policy",
624            ));
625        };
626        let JsonValue::Object(object) = value else {
627            return Err(io::Error::other(
628                "test step provider permission policy should be an object",
629            ));
630        };
631        Ok(object)
632    }
633
634    fn assert_policy_error(error: RuntimeEffectError, needle: &str) -> Result<(), io::Error> {
635        match error {
636            RuntimeEffectError::Failed {
637                family,
638                operation: "parse provider permission policy",
639                message,
640            } if family == PROVIDER_PERMISSION_EFFECT_FAMILY && message.contains(needle) => Ok(()),
641            other => Err(io::Error::other(format!(
642                "unexpected provider permission policy error: {other:?}"
643            ))),
644        }
645    }
646}