flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/contracts/src/credentials.rs

971 lines32,645 bytesCodeBlame
1//! Run credentials: the least-privilege tokens a sandbox works with.
2//!
3//! Every sandbox run gets its own tokens, bound to the run, its repository
4//! (and the pull request's fork), what that kind of run needs to do, and an
5//! expiry no later than the run's timeout. Each carries a composite
6//! identity: an agent acting on behalf of the person who started the work.
7//! What it may do is the intersection of the two: the run's scope, and what
8//! that person may do right now.
9//!
10//! The policy lives here, as pure functions, so that identity (which mints
11//! the tokens), the API (which serves REST and MCP) and repos (which serves
12//! git) all enforce the same rules, and so the rules can be tested.
13
14use serde::{Deserialize, Serialize};
15
16use crate::identity::AgentScope;
17use crate::repos::RepoPath;
18use crate::{Membership, PrincipalKind, Role, User};
19
20/// What a run does, as far as its credentials are concerned. The same names
21/// as [`crate::agents::RunKind`], plus `deploy`, a build of one commit.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum RunCredentialKind {
25 Implement,
26 Revise,
27 Review,
28 Answer,
29 Update,
30 Plan,
31 Checks,
32 Queue,
33 Mergecheck,
34 Deploy,
35}
36
37impl RunCredentialKind {
38 pub const ALL: [RunCredentialKind; 10] = [
39 RunCredentialKind::Implement,
40 RunCredentialKind::Revise,
41 RunCredentialKind::Review,
42 RunCredentialKind::Answer,
43 RunCredentialKind::Update,
44 RunCredentialKind::Plan,
45 RunCredentialKind::Checks,
46 RunCredentialKind::Queue,
47 RunCredentialKind::Mergecheck,
48 RunCredentialKind::Deploy,
49 ];
50
51 pub fn as_str(self) -> &'static str {
52 match self {
53 RunCredentialKind::Implement => "implement",
54 RunCredentialKind::Revise => "revise",
55 RunCredentialKind::Review => "review",
56 RunCredentialKind::Answer => "answer",
57 RunCredentialKind::Update => "update",
58 RunCredentialKind::Plan => "plan",
59 RunCredentialKind::Checks => "checks",
60 RunCredentialKind::Queue => "queue",
61 RunCredentialKind::Mergecheck => "mergecheck",
62 RunCredentialKind::Deploy => "deploy",
63 }
64 }
65
66 /// Whether the run works on one pull request, whose session and
67 /// readiness it reports.
68 fn works_on_a_pull(self) -> bool {
69 matches!(
70 self,
71 RunCredentialKind::Implement
72 | RunCredentialKind::Revise
73 | RunCredentialKind::Answer
74 | RunCredentialKind::Update
75 )
76 }
77}
78
79/// Which part of a sandbox a credential is for.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum CredentialUse {
83 /// g1t's runner: cloning, pushing the result, recording the session.
84 /// It acts as the person downstream, so that what it pushes and records
85 /// is theirs, within the run's scope.
86 Runner,
87 /// The agent's own tools, over MCP. It acts as the agent.
88 Tools,
89}
90
91impl CredentialUse {
92 pub fn as_str(self) -> &'static str {
93 match self {
94 CredentialUse::Runner => "runner",
95 CredentialUse::Tools => "tools",
96 }
97 }
98}
99
100/// A repository a run may push to, and the one branch, if only one.
101#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
102pub struct GitGrant {
103 pub repo: RepoPath,
104 /// Null: any branch. A pull request's fork is its own repository, so
105 /// the whole of it is the pull request's.
106 #[serde(default)]
107 pub branch: Option<String>,
108}
109
110/// What binds an agent's token to one run. Absent on agent tokens made
111/// before run credentials, which keep working for the API only.
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "camelCase")]
114pub struct RunBinding {
115 pub kind: RunCredentialKind,
116 #[serde(rename = "use")]
117 pub usage: CredentialUse,
118 /// The agent run, once the sandbox has recorded it.
119 #[serde(default)]
120 pub run_id: Option<String>,
121 /// The pull request the run works on, for the kinds that work on one.
122 #[serde(default)]
123 pub number: Option<u32>,
124 /// The agent's name, such as `g1t-agent`.
125 pub agent: String,
126 /// Repositories it may clone and fetch, besides those it may push to.
127 #[serde(default)]
128 pub read: Vec<RepoPath>,
129 /// Where it may push.
130 #[serde(default)]
131 pub push: Vec<GitGrant>,
132}
133
134/// A person, by id and name.
135#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
136pub struct Principal {
137 pub id: String,
138 pub username: String,
139}
140
141/// Set on a [`User`] resolved from an agent's token: the composite
142/// identity, "g1t-agent on behalf of syntaqx", and what it may do.
143#[derive(Clone, Debug, Serialize, Deserialize)]
144#[serde(rename_all = "camelCase")]
145pub struct Acting {
146 /// The token's id, as audit entries name it.
147 pub credential_id: String,
148 pub agent: String,
149 pub on_behalf_of: Principal,
150 pub scope: AgentScope,
151}
152
153impl Acting {
154 pub fn run(&self) -> Option<&RunBinding> {
155 self.scope.run.as_ref()
156 }
157}
158
159/// `create_run_credential`: a token for one sandbox run. It acts as
160/// `agent` on behalf of `on_behalf_of`, can do only what `kind` and `usage`
161/// allow in `repo`, and expires after `ttl_seconds`, which should be the
162/// run's timeout. Returns `CreatedAccessToken`.
163#[derive(Clone, Debug, Serialize, Deserialize)]
164#[serde(rename_all = "camelCase")]
165pub struct CreateRunCredentialArgs {
166 pub on_behalf_of: User,
167 pub repo: RepoPath,
168 pub kind: RunCredentialKind,
169 #[serde(rename = "use")]
170 pub usage: CredentialUse,
171 #[serde(default)]
172 pub number: Option<u32>,
173 #[serde(default)]
174 pub read: Vec<RepoPath>,
175 #[serde(default)]
176 pub push: Vec<GitGrant>,
177 pub ttl_seconds: u64,
178 /// Defaults to `g1t-agent`.
179 #[serde(default)]
180 pub agent: Option<String>,
181}
182
183/// `bind_run_credentials`: ties tokens, named by the SHA-256 of their
184/// text in hex, to the agent run their sandbox recorded. Returns how many.
185#[derive(Clone, Debug, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct BindRunCredentialsArgs {
188 pub token_hashes: Vec<String>,
189 pub run_id: String,
190}
191
192/// `revoke_run_credentials`: ends tokens when their sandbox stops, by hash
193/// or by run. Only run credentials are touched, never a token a person
194/// made. Returns how many.
195#[derive(Clone, Debug, Default, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub struct RevokeRunCredentialsArgs {
198 #[serde(default)]
199 pub token_hashes: Vec<String>,
200 #[serde(default)]
201 pub run_id: Option<String>,
202}
203
204// --- Policy --------------------------------------------------------------
205
206/// Operations that only read.
207pub const READ_OPERATIONS: &[&str] = &[
208 "whoami",
209 "list_repos",
210 "get_repo",
211 "get_repo_settings",
212 "get_merge_queue",
213 "recall",
214 "list_issues",
215 "get_issue",
216 "get_plan",
217 "list_labels",
218 "list_pull_requests",
219 "get_pull_request",
220 "read_session",
221 "get_pull_request_changes",
222 "list_events",
223 "get_context",
224 "search_context",
225 "get_entity",
226 "search",
227 "list_workflows",
228 "list_workflow_runs",
229 "get_workflow_run",
230 "get_job_logs",
231 "list_integrations",
232 "get_model_routes",
233 "list_webhooks",
234 "list_webhook_deliveries",
235 "list_actions_secrets",
236 "list_actions_variables",
237];
238
239/// What no agent's token may ever do, whatever its scope says: workspaces,
240/// repositories' settings, members, tokens, billing, integrations,
241/// webhooks, secrets, workflows' controls, merging, and putting more agents
242/// to work.
243pub const NEVER: &[&str] = &[
244 "create_workspace",
245 "create_repo",
246 "update_repo",
247 "update_repo_settings",
248 "merge_pull_request",
249 "assign_issue",
250 "plan_work",
251 "apply_plan",
252 "import_issue",
253 "list_integrations",
254 "connect_integration",
255 "disconnect_integration",
256 "test_integration",
257 "get_model_routes",
258 "set_model_routes",
259 "list_webhooks",
260 "create_webhook",
261 "update_webhook",
262 "delete_webhook",
263 "ping_webhook",
264 "list_webhook_deliveries",
265 "redeliver_webhook",
266 "dispatch_workflow",
267 "cancel_workflow_run",
268 "rerun_workflow_run",
269 "update_workflow",
270 "list_actions_secrets",
271 "set_actions_secret",
272 "delete_actions_secret",
273 "list_actions_variables",
274 "set_actions_variable",
275 "delete_actions_variable",
276];
277
278/// Reading what an agent needs to know about its repository.
279const TOOLS_READ: &[&str] = &[
280 "get_repo",
281 "list_issues",
282 "get_issue",
283 "list_labels",
284 "list_pull_requests",
285 "get_pull_request",
286 "get_pull_request_changes",
287 "read_session",
288 "get_merge_queue",
289 "list_events",
290 "recall",
291 "search_context",
292 "get_entity",
293 "search",
294 "list_workflows",
295 "list_workflow_runs",
296 "get_workflow_run",
297 "get_job_logs",
298];
299
300pub fn is_read(operation: &str) -> bool {
301 READ_OPERATIONS.contains(&operation)
302}
303
304/// The API and MCP operations a run of `kind` may use with a credential
305/// for `usage`. Git is separate: see [`decide_git`].
306pub fn operations_for(kind: RunCredentialKind, usage: CredentialUse) -> Vec<&'static str> {
307 use RunCredentialKind as K;
308 let mut operations: Vec<&'static str> = Vec::new();
309 match usage {
310 CredentialUse::Runner => {
311 if kind.works_on_a_pull() {
312 operations.extend(["get_repo", "get_pull_request", "record_session"]);
313 }
314 if kind == K::Implement {
315 operations.push("mark_pull_request_ready");
316 }
317 }
318 CredentialUse::Tools => match kind {
319 K::Implement | K::Revise | K::Answer => {
320 operations.extend(TOOLS_READ.iter().copied());
321 operations.extend([
322 "create_issue",
323 "add_comment",
324 "take_messages",
325 "remember",
326 "message_agent",
327 "answer_message",
328 "get_context",
329 ]);
330 }
331 K::Review => {
332 operations.extend(TOOLS_READ.iter().copied());
333 operations.extend(["add_comment", "review_pull_request", "get_context"]);
334 }
335 K::Plan => {
336 operations.extend(TOOLS_READ.iter().copied());
337 operations.extend(["create_issue", "get_context"]);
338 }
339 K::Update => operations.extend(TOOLS_READ.iter().copied()),
340 K::Checks | K::Queue | K::Mergecheck | K::Deploy => {}
341 },
342 }
343 operations
344}
345
346/// Operations that change a pull request, which a runner may do only to
347/// the pull request its run works on.
348const PULL_WRITES: &[&str] = &["record_session", "mark_pull_request_ready"];
349
350/// Whether something was allowed, and the rule that decided it.
351#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
352pub struct Decision {
353 pub allowed: bool,
354 /// A short, stable name: `run:implement/tools`, `never`,
355 /// `scope:repository` and so on. Shown in the audit log.
356 pub rule: String,
357 /// Why it was refused, for the caller.
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub reason: Option<String>,
360}
361
362impl Decision {
363 pub fn allow(rule: impl Into<String>) -> Self {
364 Decision {
365 allowed: true,
366 rule: rule.into(),
367 reason: None,
368 }
369 }
370
371 pub fn deny(rule: impl Into<String>, reason: impl Into<String>) -> Self {
372 Decision {
373 allowed: false,
374 rule: rule.into(),
375 reason: Some(reason.into()),
376 }
377 }
378}
379
380fn same_repo(a: &RepoPath, b: &RepoPath) -> bool {
381 a.namespace.eq_ignore_ascii_case(&b.namespace) && a.name.eq_ignore_ascii_case(&b.name)
382}
383
384fn scope_rule(scope: &AgentScope) -> String {
385 match &scope.run {
386 Some(run) => format!("run:{}/{}", run.kind.as_str(), run.usage.as_str()),
387 None => "agent-token".to_owned(),
388 }
389}
390
391/// Whether `user`, resolved from an agent's token with `scope`, may use
392/// `operation`. `repo` is the repository the call names, if any, and
393/// `needs_repo` whether the operation is about one; `number` the issue or
394/// pull request it names.
395pub fn decide_operation(
396 user: &User,
397 scope: &AgentScope,
398 operation: &str,
399 repo: Option<&RepoPath>,
400 needs_repo: bool,
401 number: Option<u32>,
402) -> Decision {
403 let who = "A g1t agent's token";
404 if NEVER.contains(&operation) {
405 return Decision::deny(
406 "never",
407 format!(
408 "{who} can never use {operation}: settings, members, tokens, billing, integrations, webhooks, secrets and merging are for people."
409 ),
410 );
411 }
412 if !scope.operations.iter().any(|name| name == operation) {
413 return Decision::deny(
414 "scope:operation",
415 format!("{who} for this run cannot use {operation}."),
416 );
417 }
418 if needs_repo && !repo.is_some_and(|asked| same_repo(asked, &scope.repo)) {
419 return Decision::deny(
420 "scope:repository",
421 format!(
422 "{who} works in {}/{} only.",
423 scope.repo.namespace, scope.repo.name
424 ),
425 );
426 }
427 // The intersection: the person it acts for must still be able to work
428 // in the repository's workspace.
429 if !user.is_member(&scope.repo.namespace.to_lowercase()) {
430 return Decision::deny(
431 "on-behalf-of:membership",
432 format!(
433 "The person this agent works for is no longer a member of {}.",
434 scope.repo.namespace
435 ),
436 );
437 }
438 if let Some(run) = &scope.run
439 && run.usage == CredentialUse::Runner
440 && PULL_WRITES.contains(&operation)
441 && run.number.is_some()
442 && number != run.number
443 {
444 return Decision::deny(
445 "scope:pull",
446 format!(
447 "{who} can change pull request #{} only.",
448 run.number.unwrap_or_default()
449 ),
450 );
451 }
452 Decision::allow(scope_rule(scope))
453}
454
455/// Whether a run credential may clone or fetch (`write` false), or push to
456/// (`write` true), the repository at `repo`.
457pub fn decide_git(scope: &AgentScope, repo: &RepoPath, write: bool) -> Decision {
458 let Some(run) = scope
459 .run
460 .as_ref()
461 .filter(|run| run.usage == CredentialUse::Runner)
462 else {
463 return Decision::deny(
464 "git:not-a-run",
465 "A g1t agent's tools token cannot be used with git.",
466 );
467 };
468 let pushable = run.push.iter().any(|grant| same_repo(&grant.repo, repo));
469 if write {
470 return if pushable {
471 Decision::allow(format!("{}:push", scope_rule(scope)))
472 } else {
473 Decision::deny(
474 "git:push",
475 format!(
476 "A {} run cannot push to {}/{}.",
477 run.kind.as_str(),
478 repo.namespace,
479 repo.name
480 ),
481 )
482 };
483 }
484 let readable = pushable
485 || same_repo(&scope.repo, repo)
486 || run.read.iter().any(|path| same_repo(path, repo));
487 if readable {
488 Decision::allow(format!("{}:read", scope_rule(scope)))
489 } else {
490 Decision::deny(
491 "git:read",
492 format!(
493 "A {} run cannot read {}/{}.",
494 run.kind.as_str(),
495 repo.namespace,
496 repo.name
497 ),
498 )
499 }
500}
501
502/// Whether a push to `repo` is limited to certain branches, so that the
503/// refs it moves have to be read and checked with [`decide_refs`].
504pub fn limits_branches(scope: &AgentScope, repo: &RepoPath) -> bool {
505 scope
506 .run
507 .iter()
508 .flat_map(|run| run.push.iter())
509 .any(|grant| same_repo(&grant.repo, repo) && grant.branch.is_some())
510}
511
512/// Whether a push to `repo` may move `refs` (full refs, such as
513/// `refs/heads/main`). Tags are never a run's to move.
514pub fn decide_refs(scope: &AgentScope, repo: &RepoPath, refs: &[String]) -> Decision {
515 let repo_decision = decide_git(scope, repo, true);
516 if !repo_decision.allowed {
517 return repo_decision;
518 }
519 let grants: Vec<&GitGrant> = scope
520 .run
521 .iter()
522 .flat_map(|run| run.push.iter())
523 .filter(|grant| same_repo(&grant.repo, repo))
524 .collect();
525 for git_ref in refs {
526 let Some(branch) = git_ref.strip_prefix("refs/heads/") else {
527 return Decision::deny("git:ref", format!("A run cannot push {git_ref}."));
528 };
529 let allowed = grants
530 .iter()
531 .any(|grant| grant.branch.as_deref().is_none_or(|only| only == branch));
532 if !allowed {
533 return Decision::deny(
534 "git:ref",
535 format!(
536 "A run cannot push to {branch} in {}/{}.",
537 repo.namespace, repo.name
538 ),
539 );
540 }
541 }
542 repo_decision
543}
544
545/// The memberships an agent working for `person` has: the run's
546/// workspace, as a member, only if the person is in it now.
547pub fn intersect(person: &[Membership], namespace: &str) -> Vec<Membership> {
548 let namespace = namespace.to_lowercase();
549 person
550 .iter()
551 .filter(|membership| membership.slug == namespace)
552 .map(|membership| Membership {
553 role: Role::Member,
554 ..membership.clone()
555 })
556 .collect()
557}
558
559/// Who a runner's credential acts as downstream: the person, with only the
560/// agent's (already intersected) memberships. `None` for anything else.
561pub fn as_person(user: &User) -> Option<User> {
562 let acting = user.acting.as_ref()?;
563 if user.kind != PrincipalKind::Agent {
564 return None;
565 }
566 let run = acting.run()?;
567 if run.usage != CredentialUse::Runner {
568 return None;
569 }
570 Some(User {
571 id: acting.on_behalf_of.id.clone(),
572 username: acting.on_behalf_of.username.clone(),
573 kind: PrincipalKind::User,
574 verified: user.verified,
575 workspaces: user.workspaces.clone(),
576 avatar: None,
577 acting: None,
578 })
579}
580
581/// How an actor is described: "g1t-agent on behalf of syntaqx".
582pub fn describe(user: &User) -> String {
583 match &user.acting {
584 Some(acting) => format!(
585 "{} on behalf of {}",
586 acting.agent, acting.on_behalf_of.username
587 ),
588 None => user.username.clone(),
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595
596 #[test]
597 fn agents_can_search_the_context_hub() {
598 for kind in [RunCredentialKind::Implement, RunCredentialKind::Review, RunCredentialKind::Plan] {
599 let tools = operations_for(kind, CredentialUse::Tools);
600 assert!(tools.contains(&"search_context") && tools.contains(&"get_entity"));
601 }
602 assert!(is_read("search_context") && is_read("get_entity"));
603 }
604
605 #[test]
606 fn agents_can_search_all_of_g1t() {
607 // Site-wide search only reads: every run that reads its repository
608 // may use it, and nothing that never reads gets it.
609 assert!(is_read("search"));
610 assert!(!NEVER.contains(&"search"));
611 for kind in [
612 RunCredentialKind::Implement,
613 RunCredentialKind::Revise,
614 RunCredentialKind::Answer,
615 RunCredentialKind::Review,
616 RunCredentialKind::Plan,
617 RunCredentialKind::Update,
618 ] {
619 let tools = operations_for(kind, CredentialUse::Tools);
620 assert!(tools.contains(&"search"), "{kind:?} should search");
621 // The context hub's search stays its own tool beside it.
622 assert!(tools.contains(&"search_context"), "{kind:?} keeps search_context");
623 }
624 for kind in [RunCredentialKind::Checks, RunCredentialKind::Queue, RunCredentialKind::Mergecheck, RunCredentialKind::Deploy] {
625 assert!(!operations_for(kind, CredentialUse::Tools).contains(&"search"));
626 }
627 assert!(!operations_for(RunCredentialKind::Implement, CredentialUse::Runner).contains(&"search"));
628 }
629
630 fn path(namespace: &str, name: &str) -> RepoPath {
631 RepoPath {
632 namespace: namespace.to_owned(),
633 name: name.to_owned(),
634 }
635 }
636
637 fn scope(kind: RunCredentialKind, usage: CredentialUse) -> AgentScope {
638 AgentScope {
639 repo: path("acme", "rocket"),
640 operations: operations_for(kind, usage)
641 .into_iter()
642 .map(str::to_owned)
643 .collect(),
644 run: Some(RunBinding {
645 kind,
646 usage,
647 run_id: Some("run_1".to_owned()),
648 number: Some(7),
649 agent: "g1t-agent".to_owned(),
650 read: vec![path("acme", "rocket")],
651 push: match kind {
652 RunCredentialKind::Implement
653 | RunCredentialKind::Revise
654 | RunCredentialKind::Answer => vec![GitGrant {
655 repo: path("pulls", "pul_7"),
656 branch: None,
657 }],
658 RunCredentialKind::Update => vec![GitGrant {
659 repo: path("acme", "rocket"),
660 branch: Some("fix-login".to_owned()),
661 }],
662 _ => vec![],
663 },
664 }),
665 }
666 }
667
668 fn agent(member_of: &[&str], scope: AgentScope) -> User {
669 User {
670 id: "usr_g1t_agent".to_owned(),
671 username: "g1t-agent".to_owned(),
672 kind: PrincipalKind::Agent,
673 verified: true,
674 workspaces: member_of
675 .iter()
676 .map(|slug| Membership::member(*slug))
677 .collect(),
678 avatar: None,
679 acting: Some(Box::new(Acting {
680 credential_id: "tok_1".to_owned(),
681 agent: "g1t-agent".to_owned(),
682 on_behalf_of: Principal {
683 id: "usr_1".to_owned(),
684 username: "syntaqx".to_owned(),
685 },
686 scope,
687 })),
688 }
689 }
690
691 fn op(kind: RunCredentialKind, usage: CredentialUse, operation: &str) -> Decision {
692 let scope = scope(kind, usage);
693 let user = agent(&["acme"], scope.clone());
694 decide_operation(
695 &user,
696 &scope,
697 operation,
698 Some(&path("acme", "rocket")),
699 true,
700 Some(7),
701 )
702 }
703
704 use CredentialUse::{Runner, Tools};
705 use RunCredentialKind as K;
706
707 /// Which operations each kind of run may use through its tools: the
708 /// allowed and denied matrix.
709 #[test]
710 fn tools_matrix() {
711 let cases: [(&str, [bool; 6]); 12] = [
712 // implement revise answer review plan checks
713 ("get_issue", [true, true, true, true, true, false]),
714 ("create_issue", [true, true, true, false, true, false]),
715 ("add_comment", [true, true, true, true, false, false]),
716 (
717 "review_pull_request",
718 [false, false, false, true, false, false],
719 ),
720 ("remember", [true, true, true, false, false, false]),
721 ("take_messages", [true, true, true, false, false, false]),
722 ("record_session", [false, false, false, false, false, false]),
723 (
724 "merge_pull_request",
725 [false, false, false, false, false, false],
726 ),
727 (
728 "update_repo_settings",
729 [false, false, false, false, false, false],
730 ),
731 ("create_webhook", [false, false, false, false, false, false]),
732 (
733 "set_actions_secret",
734 [false, false, false, false, false, false],
735 ),
736 ("assign_issue", [false, false, false, false, false, false]),
737 ];
738 let kinds = [
739 K::Implement,
740 K::Revise,
741 K::Answer,
742 K::Review,
743 K::Plan,
744 K::Checks,
745 ];
746 for (operation, expected) in cases {
747 for (kind, allowed) in kinds.into_iter().zip(expected) {
748 assert_eq!(
749 op(kind, Tools, operation).allowed,
750 allowed,
751 "{operation} by a {} run's tools",
752 kind.as_str()
753 );
754 }
755 }
756 }
757
758 #[test]
759 fn runner_matrix() {
760 assert!(op(K::Implement, Runner, "record_session").allowed);
761 assert!(op(K::Implement, Runner, "mark_pull_request_ready").allowed);
762 assert!(op(K::Revise, Runner, "record_session").allowed);
763 assert!(!op(K::Revise, Runner, "mark_pull_request_ready").allowed);
764 assert!(!op(K::Implement, Runner, "create_issue").allowed);
765 assert!(!op(K::Review, Runner, "record_session").allowed);
766 assert!(!op(K::Checks, Runner, "get_issue").allowed);
767 }
768
769 #[test]
770 fn settings_billing_tokens_and_members_are_never_reachable() {
771 for kind in RunCredentialKind::ALL {
772 for usage in [Runner, Tools] {
773 for operation in NEVER.iter().copied() {
774 let decision = op(kind, usage, operation);
775 assert!(!decision.allowed);
776 assert_eq!(decision.rule, "never");
777 }
778 }
779 }
780 // Even a scope that lists one is refused.
781 let mut wide = scope(K::Implement, Tools);
782 wide.operations.push("merge_pull_request".to_owned());
783 let user = agent(&["acme"], wide.clone());
784 let decision = decide_operation(
785 &user,
786 &wide,
787 "merge_pull_request",
788 Some(&path("acme", "rocket")),
789 true,
790 Some(7),
791 );
792 assert_eq!(decision.rule, "never");
793 }
794
795 #[test]
796 fn another_repository_is_refused() {
797 let scope = scope(K::Implement, Tools);
798 let user = agent(&["acme"], scope.clone());
799 let decision = decide_operation(
800 &user,
801 &scope,
802 "create_issue",
803 Some(&path("acme", "other")),
804 true,
805 None,
806 );
807 assert!(!decision.allowed);
808 assert_eq!(decision.rule, "scope:repository");
809 let decision = decide_operation(&user, &scope, "create_issue", None, true, None);
810 assert_eq!(decision.rule, "scope:repository");
811 // The repository's name is matched without regard to case.
812 let decision = decide_operation(
813 &user,
814 &scope,
815 "create_issue",
816 Some(&path("Acme", "Rocket")),
817 true,
818 None,
819 );
820 assert!(decision.allowed);
821 assert_eq!(decision.rule, "run:implement/tools");
822 }
823
824 #[test]
825 fn the_permission_is_the_intersection_with_the_person() {
826 let scope = scope(K::Implement, Tools);
827 // The person left the workspace: their agent can do nothing there.
828 let user = agent(&[], scope.clone());
829 let decision = decide_operation(
830 &user,
831 &scope,
832 "get_issue",
833 Some(&path("acme", "rocket")),
834 true,
835 Some(1),
836 );
837 assert!(!decision.allowed);
838 assert_eq!(decision.rule, "on-behalf-of:membership");
839 // And an owner's agent is only ever a member.
840 let owner = vec![
841 Membership {
842 slug: "acme".to_owned(),
843 role: Role::Owner,
844 name: None,
845 avatar: None,
846 },
847 Membership::member("elsewhere"),
848 ];
849 let memberships = intersect(&owner, "Acme");
850 assert_eq!(memberships.len(), 1);
851 assert_eq!(memberships[0].slug, "acme");
852 assert_eq!(memberships[0].role, Role::Member);
853 assert!(intersect(&owner, "nowhere").is_empty());
854 }
855
856 #[test]
857 fn a_runner_changes_only_its_own_pull_request() {
858 let scope = scope(K::Implement, Runner);
859 let user = agent(&["acme"], scope.clone());
860 let repo = path("acme", "rocket");
861 let other = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(8));
862 assert!(!other.allowed);
863 assert_eq!(other.rule, "scope:pull");
864 let own = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(7));
865 assert!(own.allowed);
866 // Reading another is fine.
867 assert!(
868 decide_operation(
869 &user,
870 &scope,
871 "get_pull_request",
872 Some(&repo),
873 true,
874 Some(8)
875 )
876 .allowed
877 );
878 }
879
880 #[test]
881 fn git_matrix() {
882 let fork = path("pulls", "pul_7");
883 let upstream = path("acme", "rocket");
884 let elsewhere = path("acme", "billing");
885 let implement = scope(K::Implement, Runner);
886 assert!(decide_git(&implement, &fork, true).allowed);
887 assert!(decide_git(&implement, &fork, false).allowed);
888 assert!(decide_git(&implement, &upstream, false).allowed);
889 assert_eq!(decide_git(&implement, &upstream, true).rule, "git:push");
890 assert_eq!(decide_git(&implement, &elsewhere, false).rule, "git:read");
891 let review = scope(K::Review, Runner);
892 assert!(decide_git(&review, &upstream, false).allowed);
893 assert!(!decide_git(&review, &upstream, true).allowed);
894 assert!(!decide_git(&review, &fork, true).allowed);
895 // A tools token made before run credentials never reaches git.
896 let old = AgentScope {
897 repo: upstream.clone(),
898 operations: vec!["get_issue".to_owned()],
899 run: None,
900 };
901 assert_eq!(decide_git(&old, &upstream, false).rule, "git:not-a-run");
902 // Nor does an agent's tools token.
903 assert_eq!(
904 decide_git(&scope(K::Implement, Tools), &upstream, false).rule,
905 "git:not-a-run"
906 );
907 }
908
909 #[test]
910 fn a_push_moves_only_granted_branches() {
911 let update = scope(K::Update, Runner);
912 let repo = path("acme", "rocket");
913 let refs = |names: &[&str]| {
914 names
915 .iter()
916 .map(|name| (*name).to_owned())
917 .collect::<Vec<_>>()
918 };
919 assert!(decide_refs(&update, &repo, &refs(&["refs/heads/fix-login"])).allowed);
920 assert_eq!(
921 decide_refs(&update, &repo, &refs(&["refs/heads/main"])).rule,
922 "git:ref"
923 );
924 assert_eq!(
925 decide_refs(
926 &update,
927 &repo,
928 &refs(&["refs/heads/fix-login", "refs/tags/v1"])
929 )
930 .rule,
931 "git:ref"
932 );
933 let implement = scope(K::Implement, Runner);
934 assert!(
935 decide_refs(
936 &implement,
937 &path("pulls", "pul_7"),
938 &refs(&["refs/heads/main"])
939 )
940 .allowed
941 );
942 }
943
944 #[test]
945 fn a_runner_acts_downstream_as_the_person() {
946 let user = agent(&["acme"], scope(K::Implement, Runner));
947 let person = as_person(&user).unwrap();
948 assert_eq!(person.id, "usr_1");
949 assert_eq!(person.username, "syntaqx");
950 assert_eq!(person.kind, PrincipalKind::User);
951 assert!(person.is_member("acme"));
952 assert!(person.acting.is_none());
953 assert_eq!(describe(&user), "g1t-agent on behalf of syntaqx");
954 // The tools act as the agent.
955 assert!(as_person(&agent(&["acme"], scope(K::Implement, Tools))).is_none());
956 }
957
958 #[test]
959 fn scopes_without_a_run_still_parse() {
960 let old: AgentScope = serde_json::from_str(
961 r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_issue"]}"#,
962 )
963 .unwrap();
964 assert!(old.run.is_none());
965 let written = serde_json::to_string(&scope(K::Review, Tools)).unwrap();
966 assert!(written.contains(r#""use":"tools""#));
967 assert!(written.contains(r#""kind":"review""#));
968 let back: AgentScope = serde_json::from_str(&written).unwrap();
969 assert_eq!(back.run.unwrap().kind, K::Review);
970 }
971}