| 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 | |
| 6 | use g1t_contracts::identity::{ |
| 7 | ASK_TOKEN_NAME, ASK_TOKEN_PURPOSE, ASK_TOKEN_TTL_SECONDS, AskToken, CreateAskTokenArgs, RevokeAskTokenArgs, |
| 8 | ask_token_scopes, |
| 9 | }; |
| 10 | use g1t_contracts::scopes::RepositorySelection; |
| 11 | use g1t_contracts::time::SQL_NOW; |
| 12 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind}; |
| 13 | use worker::Result; |
| 14 | |
| 15 | use crate::Identity; |
| 16 | use crate::tokens::{Grant, Owner}; |
| 17 | |
| 18 | /// What an Ask credential may hold. |
| 19 | pub(crate) fn ask_grant() -> Grant { |
| 20 | Grant { scopes: Some(ask_token_scopes()) } |
| 21 | } |
| 22 | |
| 23 | impl 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)] |
| 74 | mod 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 | } |