Skip to content

Commit c10ccf1

Browse files
authored
Merge pull request #255 from Ayush7614/fix/250-opaque-graphql-db-errors
fix(node): opaque GraphQL DB error messages (#250)
2 parents 7b365b0 + 726ba33 commit c10ccf1

3 files changed

Lines changed: 316 additions & 15 deletions

File tree

‎crates/gitlawb-node/src/graphql/mod.rs‎

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,68 @@ use subscription::SubscriptionRoot;
1414

1515
pub type GitlawbSchema = Schema<QueryRoot, MutationRoot, SubscriptionRoot>;
1616

17+
/// Client-facing message for GraphQL resolver failures that wrap a real
18+
/// `sqlx::Error`. The real error is logged server-side; never put sqlx/Postgres
19+
/// detail in the GraphQL `errors` array (#250).
20+
///
21+
/// Kept as its own constant on this PR's base (main still renders
22+
/// `AppError::Db` with `e.to_string()`). If/when #247's `DB_ERROR_MESSAGE`
23+
/// lands, fold this into that shared constant.
24+
pub const GRAPHQL_DB_ERROR_MESSAGE: &str = "a database error occurred";
25+
26+
fn anyhow_has_sqlx(e: &anyhow::Error) -> bool {
27+
e.chain().any(|c| c.downcast_ref::<sqlx::Error>().is_some())
28+
}
29+
30+
/// Map an `anyhow` failure from the db layer to a GraphQL error.
31+
///
32+
/// - Real DB faults (`sqlx::Error` anywhere in the chain) → opaque client
33+
/// message + `error!` log with the full `{e:#}` cause chain.
34+
/// - Application/business errors (e.g. claim race, not-in-claimed-state) →
35+
/// keep the actionable message; log at `warn!` so they are not mistaken for
36+
/// infrastructure failures (#250 review).
37+
pub(crate) fn graphql_db_err(e: anyhow::Error) -> async_graphql::Error {
38+
if anyhow_has_sqlx(&e) {
39+
tracing::error!(error = %format!("{e:#}"), "graphql database error");
40+
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
41+
} else {
42+
tracing::warn!(error = %format!("{e:#}"), "graphql application error");
43+
async_graphql::Error::new(e.to_string())
44+
}
45+
}
46+
47+
/// Map an `AppError` from a shared collector (e.g. ref-update feed) to a
48+
/// GraphQL error.
49+
///
50+
/// Fail closed: only explicitly curated variants surface their `Display`
51+
/// text. Unnamed variants (including `Git`, which may embed on-disk paths)
52+
/// render opaque so a future addition cannot leak by default (#255 review).
53+
pub(crate) fn graphql_app_err(e: crate::error::AppError) -> async_graphql::Error {
54+
match e {
55+
crate::error::AppError::Db(sql) => graphql_db_err(sql.into()),
56+
crate::error::AppError::Internal(err) => {
57+
tracing::error!(error = %format!("{err:#}"), "graphql internal error");
58+
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
59+
}
60+
// Curated client-safe variants — `Display` is intentional API text.
61+
safe @ (crate::error::AppError::RepoNotFound(_)
62+
| crate::error::AppError::RepoExists(_)
63+
| crate::error::AppError::NotFound(_)
64+
| crate::error::AppError::Unauthorized(_)
65+
| crate::error::AppError::Forbidden(_)
66+
| crate::error::AppError::BadRequest(_)
67+
| crate::error::AppError::TooManyRequests(_)
68+
| crate::error::AppError::Incomplete(_)) => {
69+
tracing::warn!(error = %safe, "graphql application error");
70+
async_graphql::Error::new(safe.to_string())
71+
}
72+
other => {
73+
tracing::error!(error = %other, "graphql unclassified AppError (opaque)");
74+
async_graphql::Error::new(GRAPHQL_DB_ERROR_MESSAGE)
75+
}
76+
}
77+
}
78+
1779
pub fn build_schema(
1880
db: Arc<Db>,
1981
ref_update_tx: tokio::sync::broadcast::Sender<RefUpdateBroadcast>,
@@ -25,3 +87,105 @@ pub fn build_schema(
2587
.data(task_event_tx)
2688
.finish()
2789
}
90+
91+
#[cfg(test)]
92+
mod tests {
93+
use super::*;
94+
95+
#[test]
96+
fn graphql_db_err_opaques_sqlx_chain() {
97+
let leak = "error returned from database: column \"is_public\" does not exist";
98+
// Context layer must not hide sqlx from the chain walk (db helpers
99+
// wrap with `.context(...)` in several places).
100+
let err = graphql_db_err(
101+
anyhow::Error::from(sqlx::Error::Protocol(leak.into())).context("loading repos"),
102+
);
103+
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
104+
assert!(!err.message.contains("is_public"));
105+
assert!(!err.message.contains(leak));
106+
assert!(!err.message.contains("loading repos"));
107+
}
108+
109+
#[test]
110+
fn graphql_db_err_keeps_business_message() {
111+
let msg = "task not claimable: not found or already claimed";
112+
let err = graphql_db_err(anyhow::anyhow!("{msg}"));
113+
assert_eq!(err.message, msg);
114+
}
115+
116+
#[test]
117+
fn graphql_app_err_opaques_db_and_internal() {
118+
let leak = "column \"is_public\" does not exist";
119+
let db_err = graphql_app_err(crate::error::AppError::Db(sqlx::Error::Protocol(
120+
leak.into(),
121+
)));
122+
assert_eq!(db_err.message, GRAPHQL_DB_ERROR_MESSAGE);
123+
assert!(!db_err.message.contains("is_public"));
124+
125+
let internal = graphql_app_err(crate::error::AppError::Internal(anyhow::anyhow!(
126+
"loading repo: {leak}"
127+
)));
128+
assert_eq!(internal.message, GRAPHQL_DB_ERROR_MESSAGE);
129+
assert!(!internal.message.contains("is_public"));
130+
}
131+
132+
#[test]
133+
fn graphql_app_err_keeps_safe_variant_messages() {
134+
let err = graphql_app_err(crate::error::AppError::NotFound("widget".into()));
135+
assert!(
136+
err.message.contains("widget"),
137+
"safe NotFound message must reach the client: {}",
138+
err.message
139+
);
140+
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
141+
142+
let err = graphql_app_err(crate::error::AppError::BadRequest("bad cid".into()));
143+
assert!(
144+
err.message.contains("bad cid"),
145+
"safe BadRequest message must reach the client: {}",
146+
err.message
147+
);
148+
assert_ne!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
149+
}
150+
151+
#[test]
152+
fn graphql_app_err_opaques_unclassified_variants() {
153+
// `Git` may embed on-disk paths from libgit2; fail closed.
154+
let path = "/var/lib/gitlawb/repos/owner/secret.git";
155+
let err = graphql_app_err(crate::error::AppError::Git(format!(
156+
"failed to open '{path}'"
157+
)));
158+
assert_eq!(err.message, GRAPHQL_DB_ERROR_MESSAGE);
159+
assert!(!err.message.contains(path));
160+
assert!(!err.message.contains("failed to open"));
161+
}
162+
163+
/// Every `.map_err(` in the GraphQL query/mutation resolvers must route
164+
/// through the opaque helpers, or discard the error (`|_|`). Same source-
165+
/// scrape pattern as `api::authz_guard` (#255 review).
166+
#[test]
167+
fn every_graphql_map_err_uses_opaque_helpers() {
168+
for (file, src) in [
169+
("query.rs", include_str!("query.rs")),
170+
("mutation.rs", include_str!("mutation.rs")),
171+
] {
172+
for (lineno, line) in src.lines().enumerate() {
173+
let code = line.split("//").next().unwrap_or(line);
174+
let Some(idx) = code.find(".map_err(") else {
175+
continue;
176+
};
177+
let after = code[idx + ".map_err(".len()..].trim_start();
178+
let ok = after.starts_with("crate::graphql::graphql_db_err")
179+
|| after.starts_with("crate::graphql::graphql_app_err")
180+
|| after.starts_with("|_|")
181+
|| after.starts_with("|_ ");
182+
assert!(
183+
ok,
184+
"{file}:{}: `.map_err(` must use graphql_db_err / graphql_app_err \
185+
or discard (`|_|`): {line}",
186+
lineno + 1
187+
);
188+
}
189+
}
190+
}
191+
}

‎crates/gitlawb-node/src/graphql/mutation.rs‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ impl MutationRoot {
5454
};
5555
db.create_task(&task)
5656
.await
57-
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
57+
.map_err(crate::graphql::graphql_db_err)?;
5858
Ok(AgentTaskType::from(task))
5959
}
6060

@@ -76,7 +76,7 @@ impl MutationRoot {
7676
let task = db
7777
.claim_task(&id, &assignee_did)
7878
.await
79-
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
79+
.map_err(crate::graphql::graphql_db_err)?;
8080
let _ = tx.send(TaskEventBroadcast {
8181
task_id: id,
8282
old_status: "pending".to_string(),
@@ -108,7 +108,7 @@ impl MutationRoot {
108108
let existing = db
109109
.get_task(&id)
110110
.await
111-
.map_err(|e| async_graphql::Error::new(e.to_string()))?
111+
.map_err(crate::graphql::graphql_db_err)?
112112
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
113113
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
114114
return Err(async_graphql::Error::new(
@@ -118,7 +118,7 @@ impl MutationRoot {
118118
let task = db
119119
.finish_task(&id, "completed", input.result.as_deref())
120120
.await
121-
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
121+
.map_err(crate::graphql::graphql_db_err)?;
122122
let _ = tx.send(TaskEventBroadcast {
123123
task_id: id,
124124
old_status: "claimed".to_string(),
@@ -149,7 +149,7 @@ impl MutationRoot {
149149
let existing = db
150150
.get_task(&id)
151151
.await
152-
.map_err(|e| async_graphql::Error::new(e.to_string()))?
152+
.map_err(crate::graphql::graphql_db_err)?
153153
.ok_or_else(|| async_graphql::Error::new("task not found"))?;
154154
if !crate::api::did_matches(caller, existing.assignee_did.as_deref().unwrap_or_default()) {
155155
return Err(async_graphql::Error::new(
@@ -160,7 +160,7 @@ impl MutationRoot {
160160
let task = db
161161
.finish_task(&id, "failed", Some(&reason))
162162
.await
163-
.map_err(|e| async_graphql::Error::new(e.to_string()))?;
163+
.map_err(crate::graphql::graphql_db_err)?;
164164
let _ = tx.send(TaskEventBroadcast {
165165
task_id: id,
166166
old_status: "claimed".to_string(),
@@ -218,8 +218,9 @@ mod tests {
218218
errors(&resp)
219219
);
220220

221-
// 3. Signed as the claimed assignee → passes the auth gate (any remaining
222-
// error is the missing task, not an auth error).
221+
// 3. Signed as the claimed assignee → passes the auth gate. The missing
222+
// task is a business error from claim_task, not a sqlx fault, so the
223+
// actionable message must survive (not the opaque DB string) (#250).
223224
let resp = schema
224225
.execute(Request::new(&q).data(AuthenticatedDid(assignee.into())))
225226
.await;
@@ -228,6 +229,47 @@ mod tests {
228229
!errs.contains("authentication required") && !errs.contains("authenticated signer"),
229230
"matching signer must pass the auth gate: {errs}"
230231
);
232+
assert!(
233+
errs.contains("task not claimable"),
234+
"claim race / missing task must keep its business message: {errs}"
235+
);
236+
assert!(
237+
!errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
238+
"business error must not be rewritten as opaque DB error: {errs}"
239+
);
240+
}
241+
242+
/// #250: mutation DB faults must be opaque; create_task hits agent_tasks.
243+
#[sqlx::test]
244+
async fn create_task_db_error_message_is_opaque(pool: PgPool) {
245+
let state = crate::test_support::test_state(pool.clone()).await;
246+
sqlx::query("ALTER TABLE agent_tasks DROP COLUMN status")
247+
.execute(&pool)
248+
.await
249+
.unwrap();
250+
251+
let delegator = "did:key:zGQLDELEGATORAAAAAAAAAAAAAAAAAAAAAAAAAA";
252+
let q = format!(
253+
r#"mutation {{
254+
createTask(
255+
delegatorDid: "{delegator}",
256+
input: {{ kind: "build", capability: "repo:write" }}
257+
) {{ id }}
258+
}}"#
259+
);
260+
let resp = state
261+
.graphql_schema
262+
.execute(Request::new(&q).data(AuthenticatedDid(delegator.into())))
263+
.await;
264+
let errs = errors(&resp);
265+
assert!(
266+
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
267+
"sqlx fault must be opaque: {errs}"
268+
);
269+
assert!(
270+
!errs.contains("column") && !errs.contains("status"),
271+
"schema text leaked: {errs}"
272+
);
231273
}
232274

233275
/// Adversarial-review GATE-1 (GraphQL): completing a task requires being its

0 commit comments

Comments
 (0)