@@ -18,32 +18,83 @@ struct TokenResponse {
1818 token : String ,
1919}
2020
21- /// Generates a fresh installation access token for the given installation ID.
22- /// Tokens expire after 1 hour; callers should cache and refresh.
23- pub async fn get_token ( app_id : u64 , private_key_pem : & str , installation_id : u64 ) -> Result < String > {
24- get_token_with_base_url (
21+ /// Authority role a scoped installation token is minted for (issue #4).
22+ ///
23+ /// Each role maps to the minimum GitHub App permission set for one phase of a
24+ /// task's lifecycle, constrained to the single target repository. The agent
25+ /// process only ever receives an `AgentGit` token; `Publication` is minted
26+ /// after the result envelope has passed contract validation.
27+ #[ derive( Debug , Clone , Copy , PartialEq , Eq , Hash ) ]
28+ pub enum TokenRole {
29+ /// Adapter-held: resolve refs, drive Check Runs, post progress comments.
30+ Orchestration ,
31+ /// Agent-held (`COVEN_GIT_TOKEN`): clone/fetch and push of the working
32+ /// branch. No issues, pull-request, or checks authority.
33+ AgentGit ,
34+ /// Adapter-held, minted post-validation: open the draft PR and post the
35+ /// PR-opened comment.
36+ Publication ,
37+ }
38+
39+ impl TokenRole {
40+ fn permissions ( self ) -> serde_json:: Value {
41+ match self {
42+ TokenRole :: Orchestration => serde_json:: json!( {
43+ "contents" : "read" ,
44+ "checks" : "write" ,
45+ "issues" : "write" ,
46+ "pull_requests" : "read" ,
47+ } ) ,
48+ TokenRole :: AgentGit => serde_json:: json!( { "contents" : "write" } ) ,
49+ TokenRole :: Publication => serde_json:: json!( {
50+ "contents" : "read" ,
51+ "issues" : "write" ,
52+ "pull_requests" : "write" ,
53+ } ) ,
54+ }
55+ }
56+ }
57+
58+ /// Generates a fresh installation access token constrained to one repository
59+ /// and one [`TokenRole`]'s permission set. Tokens expire after 1 hour.
60+ pub async fn get_scoped_token (
61+ app_id : u64 ,
62+ private_key_pem : & str ,
63+ installation_id : u64 ,
64+ repo_name : & str ,
65+ role : TokenRole ,
66+ ) -> Result < String > {
67+ get_scoped_token_with_base_url (
2568 DEFAULT_API_BASE_URL ,
2669 app_id,
2770 private_key_pem,
2871 installation_id,
72+ repo_name,
73+ role,
2974 )
3075 . await
3176}
3277
33- pub async fn get_token_with_base_url (
78+ pub async fn get_scoped_token_with_base_url (
3479 api_base_url : & str ,
3580 app_id : u64 ,
3681 private_key_pem : & str ,
3782 installation_id : u64 ,
83+ repo_name : & str ,
84+ role : TokenRole ,
3885) -> Result < String > {
39- tracing:: info!( installation_id, "generating installation access token" ) ;
86+ tracing:: info!(
87+ installation_id,
88+ ?role,
89+ "generating scoped installation access token"
90+ ) ;
4091 let jwt = app_jwt ( app_id, private_key_pem) ?;
4192 let client = client ( ) ?;
4293 let response = send_json (
4394 & client,
4495 api_base_url,
4596 & jwt,
46- access_token_request ( installation_id) ,
97+ scoped_access_token_request ( installation_id, repo_name , role ) ,
4798 )
4899 . await ?;
49100 let body: TokenResponse = response. json ( ) . await ?;
@@ -70,26 +121,77 @@ fn jwt_claims(app_id: u64, now: u64) -> JwtClaims {
70121 }
71122}
72123
73- fn access_token_request ( installation_id : u64 ) -> GitHubRequest {
124+ fn scoped_access_token_request (
125+ installation_id : u64 ,
126+ repo_name : & str ,
127+ role : TokenRole ,
128+ ) -> GitHubRequest {
74129 GitHubRequest {
75130 method : "POST" ,
76131 path : format ! ( "/app/installations/{installation_id}/access_tokens" ) ,
77- body : serde_json:: json!( { } ) ,
132+ // `repositories` takes bare repo names (not owner/name). Requesting a
133+ // permission the App was not installed with fails with 422; the App
134+ // manifest grants contents/checks/issues/pull_requests, all superset.
135+ body : serde_json:: json!( {
136+ "repositories" : [ repo_name] ,
137+ "permissions" : role. permissions( ) ,
138+ } ) ,
78139 }
79140}
80141
81142#[ cfg( test) ]
82143mod tests {
83144 use super :: * ;
84145
146+ use serde_json:: json;
147+
85148 #[ test]
86- fn access_token_request_targets_installation_endpoint ( ) {
87- let request = access_token_request ( 12345 ) ;
149+ fn scoped_token_request_targets_installation_endpoint ( ) {
150+ let request = scoped_access_token_request ( 12345 , "coven-code" , TokenRole :: Orchestration ) ;
88151
89152 assert_eq ! ( request. method, "POST" ) ;
90153 assert_eq ! ( request. path, "/app/installations/12345/access_tokens" ) ;
91154 }
92155
156+ #[ test]
157+ fn agent_git_scope_grants_contents_write_only ( ) {
158+ let request = scoped_access_token_request ( 1 , "coven-code" , TokenRole :: AgentGit ) ;
159+
160+ assert_eq ! ( request. body[ "repositories" ] , json!( [ "coven-code" ] ) ) ;
161+ assert_eq ! ( request. body[ "permissions" ] , json!( { "contents" : "write" } ) ) ;
162+ }
163+
164+ #[ test]
165+ fn orchestration_scope_cannot_write_contents_or_pulls ( ) {
166+ let request = scoped_access_token_request ( 1 , "coven-code" , TokenRole :: Orchestration ) ;
167+
168+ assert_eq ! ( request. body[ "repositories" ] , json!( [ "coven-code" ] ) ) ;
169+ assert_eq ! (
170+ request. body[ "permissions" ] ,
171+ json!( {
172+ "contents" : "read" ,
173+ "checks" : "write" ,
174+ "issues" : "write" ,
175+ "pull_requests" : "read" ,
176+ } )
177+ ) ;
178+ }
179+
180+ #[ test]
181+ fn publication_scope_grants_pr_write_without_checks_or_contents_write ( ) {
182+ let request = scoped_access_token_request ( 1 , "coven-code" , TokenRole :: Publication ) ;
183+
184+ assert_eq ! ( request. body[ "repositories" ] , json!( [ "coven-code" ] ) ) ;
185+ assert_eq ! (
186+ request. body[ "permissions" ] ,
187+ json!( {
188+ "contents" : "read" ,
189+ "issues" : "write" ,
190+ "pull_requests" : "write" ,
191+ } )
192+ ) ;
193+ }
194+
93195 #[ test]
94196 fn jwt_claims_use_app_id_as_issuer_and_short_expiry ( ) {
95197 let now = 1_700_000_000 ;
0 commit comments