1use 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}