Skip to content

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.sh
97 linesCodeBlameRaw
1//! Ask g1t's credentials: one per answer, so Ask reads g1t through the MCP
2//! server as the person asking, with exactly their access, and only reads.
3//! See `g1t_contracts::identity::CreateAskTokenArgs` for the policy, and
4//! migration 0048.
5
6use g1t_contracts::identity::{
7 ASK_TOKEN_NAME, ASK_TOKEN_PURPOSE, ASK_TOKEN_TTL_SECONDS, AskToken, CreateAskTokenArgs, RevokeAskTokenArgs,
8 ask_token_scopes,
9};
10use g1t_contracts::scopes::RepositorySelection;
11use g1t_contracts::time::SQL_NOW;
12use g1t_contracts::{FailureCode, Outcome, PrincipalKind};
13use worker::Result;
14
15use crate::Identity;
16use crate::tokens::{Grant, Owner};
17
18/// What an Ask credential may hold.
19pub(crate) fn ask_grant() -> Grant {
20 Grant { scopes: Some(ask_token_scopes()) }
21}
22
23impl Identity {
24 pub async fn create_ask_token(&self, a: CreateAskTokenArgs) -> Result<Outcome<AskToken>> {
25 let slug = a.workspace.trim().to_lowercase();
26 // A person, never a workspace's, a job's or a run's credential.
27 if a.actor.kind != PrincipalKind::User || a.actor.acting.is_some() || a.actor.id.is_empty() {
28 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can ask g1t."));
29 }
30 if !a.actor.is_member(&slug) {
31 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can use Ask in this workspace."));
32 }
33 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
34 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
35 };
36 let created = self
37 .mint(Owner::User(&a.actor.id), ASK_TOKEN_NAME, Some(ASK_TOKEN_TTL_SECONDS), &ask_grant(), false)
38 .await?;
39 // Made for the workspace alone, active, and marked as Ask's.
40 self.db
41 .prepare(
42 "UPDATE access_tokens SET owner_workspace_id = ?, repository_selection = ?, status = 'active', purpose = ?
43 WHERE id = ?",
44 )
45 .bind(&[
46 workspace_id.as_str().into(),
47 RepositorySelection::All.as_str().into(),
48 ASK_TOKEN_PURPOSE.into(),
49 created.info.id.as_str().into(),
50 ])?
51 .run()
52 .await?;
53 Ok(Outcome::Ok(AskToken { token: created.token, id: created.info.id }))
54 }
55
56 /// Ends an Ask credential: it stops working at once.
57 pub async fn revoke_ask_token(&self, a: RevokeAskTokenArgs) -> Result<bool> {
58 if a.id.trim().is_empty() {
59 return Ok(false);
60 }
61 self.db
62 .prepare(format!(
63 "UPDATE access_tokens SET expires_at = {SQL_NOW}
64 WHERE id = ? AND purpose = ? AND (expires_at IS NULL OR expires_at > {SQL_NOW})"
65 ))
66 .bind(&[a.id.as_str().into(), ASK_TOKEN_PURPOSE.into()])?
67 .run()
68 .await?;
69 Ok(true)
70 }
71}
72
73#[cfg(test)]
74mod tests {
75 use super::*;
76 use g1t_contracts::scopes::{Level, Scope, TokenAccess};
77
78 #[test]
79 fn an_ask_credential_only_reads() {
80 let scopes = ask_grant().scopes.expect("never full access");
81 assert!(!scopes.is_empty());
82 assert!(scopes.iter().all(|scope| scope.level() == Level::Read), "{scopes:?}");
83 // What a token holding them is let do, as the API decides it.
84 let access = TokenAccess {
85 token_id: "tok_ask".into(),
86 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
87 ..TokenAccess::default()
88 };
89 assert!(access.allows(Scope::PullRequestsRead));
90 assert!(access.allows(Scope::RepoRead));
91 assert!(!access.allows(Scope::PullRequestsWrite));
92 assert!(!access.allows(Scope::IssuesWrite));
93 assert!(!access.allows(Scope::CodeWrite));
94 // A person's conversations are never in it.
95 assert!(!access.allows(Scope::ChatRead));
96 }
97}