Skip to content

Commit e6304e7

Browse files
authored
feat(worker): split agent git auth from publication write auth (#4) (#37)
* feat(worker): add token redaction and result-envelope sanitizer (#4) Signed-off-by: Val Alexander <bunsthedev@gmail.com> * feat(worker): split orchestration, agent-git, and publication token phases (#4) Installation tokens are now minted per role and constrained to the single target repository: orchestration (contents:read, checks:write, issues:write, pull_requests:read) held by the adapter; agent git (contents:write only) injected via COVEN_GIT_TOKEN; publication (pull_requests:write, issues:write) minted only after the result envelope passes contract validation. The result envelope is sanitized before any persistence or publication, and the worker refuses to write a session brief containing a live token. Signed-off-by: Val Alexander <bunsthedev@gmail.com> * test(worker): end-to-end token isolation and redaction coverage (#4) Signed-off-by: Val Alexander <bunsthedev@gmail.com> * docs: document scoped three-role token flow (#4) Signed-off-by: Val Alexander <bunsthedev@gmail.com> --------- Signed-off-by: Val Alexander <bunsthedev@gmail.com>
1 parent b21d10e commit e6304e7

8 files changed

Lines changed: 874 additions & 77 deletions

File tree

Cargo.lock

Lines changed: 129 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,4 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3636
uuid = { version = "1", features = ["v4"] }
3737
hex = "0.4"
3838
toml = "0.8"
39+
wiremock = "0.6"

crates/github/src/installation.rs

Lines changed: 113 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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)]
82143
mod 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;

crates/worker/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ tracing.workspace = true
1313
uuid.workspace = true
1414
coven-github-api = { path = "../github" }
1515
coven-github-config = { path = "../config" }
16+
17+
[dev-dependencies]
18+
wiremock.workspace = true

0 commit comments

Comments
 (0)