mirror of
https://github.com/Twigpine/node.git
synced 2026-11-08 17:55:53 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08e62694cb | ||
|
|
50e614a522 | ||
|
|
2baaf27892 | ||
|
|
10645adae9 | ||
|
|
37764c7ddb | ||
|
|
be55f8bd46 | ||
|
|
abc03d65c7 | ||
|
|
64ef51d775 |
No files matched your search
@@ -80,15 +80,20 @@ pub(crate) fn did_matches(a: &str, b: &str) -> bool {
|
||||
!ka.contains(':') && !kb.contains(':') && ka == kb
|
||||
}
|
||||
|
||||
/// Client-facing denial shared by every repo-owner gate copy. `require_repo_owner`
|
||||
/// renders it as `AppError::Forbidden`; a handler that emits its own
|
||||
/// `(StatusCode, Json)` envelope instead of `AppError` (e.g.
|
||||
/// `api::tasks::create_task`) must use this constant too, so two copies of one
|
||||
/// gate cannot drift in observable output.
|
||||
pub(crate) const REPO_OWNER_ONLY_MESSAGE: &str = "only the repo owner can perform this action";
|
||||
|
||||
/// 403 unless `caller` is the repo owner. Uses [`did_matches`] so the owner check
|
||||
/// and the author check (close policy) share one normalization.
|
||||
pub(crate) fn require_repo_owner(record: &RepoRecord, caller: &str) -> Result<()> {
|
||||
if did_matches(caller, &record.owner_did) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::Forbidden(
|
||||
"only the repo owner can perform this action".into(),
|
||||
))
|
||||
Err(AppError::Forbidden(REPO_OWNER_ONLY_MESSAGE.into()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +247,11 @@ mod authz_guard {
|
||||
(events, "list_repo_events", "authorize_repo_read("),
|
||||
// Bucket C — signer-self: the acting DID is matched/bound to auth.0
|
||||
(tasks, "create_task", "did_matches("),
|
||||
// #496: create_task is signer-self AND owner-when-repo-scoped — a
|
||||
// repo_id naming a hosted repo admits tasks only from its owner.
|
||||
// Both halves are pinned: the did_matches row guards the signer
|
||||
// binding, this one the repo-ownership gate.
|
||||
(tasks, "create_task", "require_repo_owner("),
|
||||
(tasks, "claim_task", "did_matches("),
|
||||
(tasks, "complete_task", "did_matches("),
|
||||
(tasks, "fail_task", "did_matches("),
|
||||
@@ -341,7 +351,11 @@ mod authz_guard {
|
||||
(
|
||||
"create_task",
|
||||
"createTask",
|
||||
&["require_signer(ctx)?", "did_matches("],
|
||||
&[
|
||||
"require_signer(ctx)?",
|
||||
"did_matches(",
|
||||
"require_repo_owner(",
|
||||
],
|
||||
),
|
||||
(
|
||||
"claim_task",
|
||||
|
||||
@@ -30,6 +30,33 @@ fn forbidden(msg: &str) -> (StatusCode, Json<Value>) {
|
||||
)
|
||||
}
|
||||
|
||||
/// This module's error shape with an opaque body: the real error is logged
|
||||
/// server-side and never serialized (#250), matching the envelope
|
||||
/// `AppError::Db` renders. Also mirrors its status split: pool-class failures
|
||||
/// are a retryable 503 `db_unavailable`, everything else the opaque 500
|
||||
/// `db_error`, so a transient outage is not reported as permanent.
|
||||
fn db_error(e: anyhow::Error) -> (StatusCode, Json<Value>) {
|
||||
tracing::error!(error = %format!("{e:#}"), "task database error");
|
||||
if let Some(sql) = e.chain().find_map(|c| c.downcast_ref::<sqlx::Error>()) {
|
||||
if crate::error::db_unavailable(sql) {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": crate::error::DB_UNAVAILABLE_CODE,
|
||||
"message": crate::error::DB_UNAVAILABLE_MESSAGE,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "db_error",
|
||||
"message": crate::error::DB_ERROR_MESSAGE,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Request / response types ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -101,6 +128,37 @@ pub async fn create_task(
|
||||
if !crate::api::did_matches(&auth.0, &body.delegator_did) {
|
||||
return Err(forbidden("delegator_did must be the authenticated signer"));
|
||||
}
|
||||
// #496: a caller-supplied repo_id must name a repo the caller owns. Without
|
||||
// this, any signed caller can plant a task — payload and UCAN included —
|
||||
// under a foreign repo id, where the named assignee can claim it. Resolve
|
||||
// the id against hosted repos: a hosted, non-quarantined repo admits tasks
|
||||
// only from its owner (403 otherwise). An id naming no hosted repo
|
||||
// (unknown, or quarantined — which is hidden as if it did not exist, so
|
||||
// 403ing it would confirm a real id) is stored verbatim as an opaque
|
||||
// label: this is a write gate and does not bind an unresolved id to a
|
||||
// repo. Task reads are caller-independent on this base (the list endpoints
|
||||
// are not repo-scoped), before and after this change. The 403-vs-201 split
|
||||
// between a hosted repo id and an unknown one is itself a one-bit oracle —
|
||||
// a stranger holding a private repo's id learns it is hosted here — but
|
||||
// denying foreign repos requires drawing exactly that line, and repo ids
|
||||
// already surface via the task list on this base. Repo-less tasks are
|
||||
// unaffected.
|
||||
if let Some(repo_id) = body.repo_id.as_deref() {
|
||||
let record = state.db.get_repo_by_id(repo_id).await.map_err(db_error)?;
|
||||
if let Some(record) = record {
|
||||
let quarantined = state
|
||||
.db
|
||||
.is_repo_quarantined(&record.id)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if !quarantined && crate::api::require_repo_owner(&record, &auth.0).is_err() {
|
||||
// Same denial text the GraphQL copy relays from
|
||||
// `require_repo_owner`, so the two copies of this gate do not
|
||||
// disagree in observable output.
|
||||
return Err(forbidden(crate::api::REPO_OWNER_ONLY_MESSAGE));
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -117,12 +175,7 @@ pub async fn create_task(
|
||||
updated_at: now,
|
||||
deadline: body.deadline,
|
||||
};
|
||||
state.db.create_task(&task).await.map_err(|e| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({ "error": e.to_string() })),
|
||||
)
|
||||
})?;
|
||||
state.db.create_task(&task).await.map_err(db_error)?;
|
||||
Ok((StatusCode::CREATED, Json(task_to_json(&task))))
|
||||
}
|
||||
|
||||
@@ -297,3 +350,36 @@ pub async fn fail_task(
|
||||
});
|
||||
Ok(Json(task_to_json(&task)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The gate's DB helper must mirror `AppError::Db`: a pool-level failure is
|
||||
/// the retryable 503 `db_unavailable`, a query fault the opaque 500.
|
||||
#[test]
|
||||
fn db_error_matches_app_error_status_split() {
|
||||
let unavailable = db_error(anyhow::Error::from(sqlx::Error::PoolTimedOut));
|
||||
assert_eq!(unavailable.0, StatusCode::SERVICE_UNAVAILABLE);
|
||||
assert_eq!(unavailable.1 .0["error"], crate::error::DB_UNAVAILABLE_CODE);
|
||||
assert_eq!(
|
||||
unavailable.1 .0["message"],
|
||||
crate::error::DB_UNAVAILABLE_MESSAGE
|
||||
);
|
||||
|
||||
let query = db_error(anyhow::Error::from(sqlx::Error::Protocol(
|
||||
"column \"machine_id\" does not exist".into(),
|
||||
)));
|
||||
assert_eq!(query.0, StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert_eq!(query.1 .0["error"], "db_error");
|
||||
assert_eq!(query.1 .0["message"], crate::error::DB_ERROR_MESSAGE);
|
||||
assert!(!query.1 .0.to_string().contains("machine_id"));
|
||||
}
|
||||
|
||||
/// `.context()` layers must not hide the sqlx error from the status split.
|
||||
#[test]
|
||||
fn db_error_sees_sqlx_through_context() {
|
||||
let wrapped = anyhow::Error::from(sqlx::Error::PoolClosed).context("repo lookup failed");
|
||||
assert_eq!(db_error(wrapped).0, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ pub const DB_ERROR_MESSAGE: &str = "a database error occurred";
|
||||
|
||||
/// Connection-level sqlx failures that mean the database is unreachable right
|
||||
/// now (retryable, 503), as opposed to server-reported query errors.
|
||||
fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
pub(crate) fn db_unavailable(e: &sqlx::Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
sqlx::Error::PoolTimedOut
|
||||
|
||||
@@ -36,6 +36,33 @@ impl MutationRoot {
|
||||
}
|
||||
let delegator_did = caller.to_string();
|
||||
let db = ctx.data_unchecked::<Arc<Db>>();
|
||||
// #496: same write gate as the REST handler — a repo_id naming a
|
||||
// hosted, non-quarantined repo admits tasks only from its owner, so a
|
||||
// stranger cannot file under a foreign repo id. An id naming no hosted
|
||||
// repo (unknown, or quarantined — hidden as if it did not exist) is
|
||||
// stored verbatim as an opaque label; the gate does not bind an
|
||||
// unresolved id to a repo. Task reads are caller-independent on this
|
||||
// base (the tasks resolver is not repo-scoped), before and after this
|
||||
// change. As in tasks.rs, the success-vs-denial split between a hosted
|
||||
// repo id and an unknown one is itself a one-bit oracle — a stranger
|
||||
// holding a private repo's id learns it is hosted here — but denying
|
||||
// foreign repos requires drawing exactly that line.
|
||||
if let Some(repo_id) = input.repo_id.as_deref() {
|
||||
let record = db
|
||||
.get_repo_by_id(repo_id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if let Some(record) = record {
|
||||
let quarantined = db
|
||||
.is_repo_quarantined(&record.id)
|
||||
.await
|
||||
.map_err(crate::graphql::graphql_db_err)?;
|
||||
if !quarantined {
|
||||
crate::api::require_repo_owner(&record, caller)
|
||||
.map_err(crate::graphql::graphql_app_err)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let task = AgentTask {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
@@ -186,6 +213,22 @@ mod tests {
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
/// The `id` scalar returned by a successful `createTask` mutation.
|
||||
fn created_id(resp: &Response) -> String {
|
||||
assert!(resp.errors.is_empty(), "graphql errors: {:?}", resp.errors);
|
||||
let async_graphql::Value::Object(obj) = &resp.data else {
|
||||
panic!("data not an object: {:?}", resp.data);
|
||||
};
|
||||
let async_graphql::Value::Object(task) = obj.get("createTask").expect("createTask key")
|
||||
else {
|
||||
panic!("createTask not an object");
|
||||
};
|
||||
let async_graphql::Value::String(id) = task.get("id").expect("id key") else {
|
||||
panic!("id not a string");
|
||||
};
|
||||
id.clone()
|
||||
}
|
||||
|
||||
/// N2: every GraphQL mutation requires a verified signer and binds the acting
|
||||
/// DID to it. Unsigned is rejected; a signer other than the claimed actor is
|
||||
/// rejected; the matching signer passes the auth gate.
|
||||
@@ -353,4 +396,181 @@ mod tests {
|
||||
errors(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): createTask applies the same repo-ownership gate as the
|
||||
/// REST handler — a stranger naming a hosted repo is rejected without
|
||||
/// persisting anything, while the owner files. The quarantined and
|
||||
/// unknown-id arms are pinned too, including that each accepted write
|
||||
/// stores the supplied id verbatim, so this copy of the gate cannot regress
|
||||
/// silently.
|
||||
#[sqlx::test]
|
||||
async fn create_task_rejects_foreign_repo_id(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool).await;
|
||||
let owner = "did:key:zGQLTASKOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zGQLTASKSTRANGERBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
let schema = state.graphql_schema.as_ref();
|
||||
|
||||
let q = |actor: &str| {
|
||||
format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{actor}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
)
|
||||
};
|
||||
|
||||
// Stranger signs as themselves (so the signer binding passes) but
|
||||
// names the victim's repo → rejected by the ownership gate, leaking
|
||||
// nothing about the repo.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::api::REPO_OWNER_ONLY_MESSAGE),
|
||||
"a non-owner must not file under a foreign repo id (shared denial text): {errs}"
|
||||
);
|
||||
|
||||
// The denial must not persist: no task row may carry the victim's
|
||||
// repo id afterwards.
|
||||
let stored = state
|
||||
.db
|
||||
.list_tasks(None, None, 50)
|
||||
.await
|
||||
.expect("list tasks");
|
||||
assert!(
|
||||
stored
|
||||
.iter()
|
||||
.all(|t| t.repo_id.as_deref() != Some(repo.id.as_str())),
|
||||
"a denied write must not leave a task under the foreign repo id"
|
||||
);
|
||||
|
||||
// The owner files under their own repo id.
|
||||
let resp = schema
|
||||
.execute(Request::new(q(owner)).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"the owner should file against their repo: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
|
||||
// Unknown repo ids stay accepted as opaque labels.
|
||||
let q_unknown = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{stranger}", input: {{ kind: "build", capability: "repo:write", repoId: "no-such-repo-id" }}) {{ id }} }}"#
|
||||
);
|
||||
let resp = schema
|
||||
.execute(Request::new(q_unknown).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"an id naming no hosted repo stays an opaque label: {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the unresolved id must be stored verbatim, not
|
||||
// dropped or rewritten to a repo it never named.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some("no-such-repo-id"),
|
||||
"an unresolved id must be stored verbatim as a label"
|
||||
);
|
||||
|
||||
// A quarantined repo id is treated as unknown (accepted, never a 403
|
||||
// that would confirm the id names a real repo). This pins the
|
||||
// `!quarantined` arm on this copy of the gate.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&repo.id, true)
|
||||
.await
|
||||
.expect("quarantine");
|
||||
let resp = schema
|
||||
.execute(Request::new(q(stranger)).data(AuthenticatedDid(stranger.into())))
|
||||
.await;
|
||||
assert!(
|
||||
errors(&resp).is_empty(),
|
||||
"a quarantined repo id must not 403 (existence oracle): {}",
|
||||
errors(&resp)
|
||||
);
|
||||
// Read back the row: the quarantined id is stored verbatim as a label
|
||||
// too, so the accepted write is not silently dropped.
|
||||
let stored = state
|
||||
.db
|
||||
.get_task(&created_id(&resp))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
stored.repo_id.as_deref(),
|
||||
Some(repo.id.as_str()),
|
||||
"a quarantined id must be stored verbatim as a label"
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (GraphQL): a repo-lookup fault in createTask's gate must stay
|
||||
/// opaque. Dropping a column `get_repo_by_id` selects faults that lookup;
|
||||
/// the resolver must return the fixed DB message and leak no schema text
|
||||
/// (#250), as `create_task_db_error_message_is_opaque` pins for the
|
||||
/// agent_tasks fault.
|
||||
#[sqlx::test]
|
||||
async fn create_task_gate_repo_lookup_fault_is_opaque(pool: PgPool) {
|
||||
let state = crate::test_support::test_state(pool.clone()).await;
|
||||
let owner = "did:key:zGQLTASKFAULTOWNERAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let now = chrono::Utc::now();
|
||||
let repo = crate::db::RepoRecord {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: "gql-task-gate-fault-repo".to_string(),
|
||||
owner_did: owner.to_string(),
|
||||
description: None,
|
||||
is_public: true,
|
||||
default_branch: "main".to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
disk_path: "/tmp/gql-task-gate-fault-repo".to_string(),
|
||||
forked_from: None,
|
||||
machine_id: None,
|
||||
};
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop machine_id");
|
||||
|
||||
let q = format!(
|
||||
r#"mutation {{ createTask(delegatorDid: "{owner}", input: {{ kind: "build", capability: "repo:write", repoId: "{}" }}) {{ id }} }}"#,
|
||||
repo.id
|
||||
);
|
||||
let resp = state
|
||||
.graphql_schema
|
||||
.execute(Request::new(&q).data(AuthenticatedDid(owner.into())))
|
||||
.await;
|
||||
let errs = errors(&resp);
|
||||
assert!(
|
||||
errs.contains(crate::graphql::GRAPHQL_DB_ERROR_MESSAGE),
|
||||
"gate sqlx fault must be opaque: {errs}"
|
||||
);
|
||||
assert!(
|
||||
!errs.contains("machine_id") && !errs.contains("column"),
|
||||
"schema text leaked through the gate: {errs}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1036,6 +1036,345 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #496: create_task must reject a caller-supplied repo_id that names a
|
||||
/// hosted repo the caller does not own. A stranger naming the victim's repo
|
||||
/// gets 403, leaking nothing about the repo and persisting nothing; the
|
||||
/// owner files (201). Repo-less tasks stay allowed; an id naming no hosted
|
||||
/// repo (unknown, or quarantined, hidden as if it did not exist) is stored
|
||||
/// verbatim as an opaque label, and the 403-vs-201 split between a hosted
|
||||
/// id and an unknown one is itself a one-bit oracle. This is a write gate:
|
||||
/// task reads are caller-independent and not repo-scoped on this base.
|
||||
#[sqlx::test]
|
||||
async fn create_task_rejects_foreign_repo_id(pool: PgPool) {
|
||||
let owner = "did:key:zTASKREPOOWNERAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let stranger = "did:key:zTASKREPOSTRANGERBBBBBBBBBBBBBBBBBBBBBB";
|
||||
let state = test_state(pool).await;
|
||||
let repo = seed_repo(owner, "task-gate-repo");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/tasks",
|
||||
axum::routing::post(crate::api::tasks::create_task),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let post_as = |signer: &str, body: String| {
|
||||
router().oneshot(signed_request_as(
|
||||
signer,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Body::from(body),
|
||||
))
|
||||
};
|
||||
let body_with = |signer: &str, repo_id: Option<&str>| {
|
||||
let repo_field = repo_id
|
||||
.map(|id| format!(r#","repo_id":"{id}""#))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"{{"kind":"build","capability":"repo:write","delegator_did":"{signer}"{repo_field}}}"#
|
||||
)
|
||||
};
|
||||
let created_id = |bytes: &[u8]| -> String {
|
||||
let v: serde_json::Value = serde_json::from_slice(bytes).expect("task json");
|
||||
v["id"].as_str().expect("task id").to_string()
|
||||
};
|
||||
|
||||
// Stranger filing under the victim's repo id → exact 403.
|
||||
let resp = post_as(stranger, body_with(stranger, Some(&repo.id)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::FORBIDDEN,
|
||||
"a non-owner must not file tasks against a foreign repo id"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
text.contains(r#""error":"forbidden""#),
|
||||
"denial must keep the forbidden envelope: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(crate::api::REPO_OWNER_ONLY_MESSAGE),
|
||||
"denial must carry the shared owner-denial text (same as GraphQL): {text}"
|
||||
);
|
||||
|
||||
// The denial must not persist: no task row may carry the victim's
|
||||
// repo id afterwards — a 403 that still writes is the injection.
|
||||
let stored = state
|
||||
.db
|
||||
.list_tasks(None, None, 50)
|
||||
.await
|
||||
.expect("list tasks");
|
||||
assert!(
|
||||
stored
|
||||
.iter()
|
||||
.all(|t| t.repo_id.as_deref() != Some(repo.id.as_str())),
|
||||
"a denied write must not leave a task under the foreign repo id"
|
||||
);
|
||||
|
||||
// Owner filing under their own repo id → 201.
|
||||
let resp = post_as(owner, body_with(owner, Some(&repo.id)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"the owner must be able to file tasks against their repo"
|
||||
);
|
||||
|
||||
// Repo-less task → 201 (unaffected).
|
||||
let resp = post_as(stranger, body_with(stranger, None)).await.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"repo-less task creation must keep working"
|
||||
);
|
||||
|
||||
// Unknown repo id → 201 as an opaque label.
|
||||
let resp = post_as(stranger, body_with(stranger, Some("no-such-repo-id")))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"an id naming no hosted repo stays an opaque label"
|
||||
);
|
||||
// Read back the row: the unresolved id must be stored verbatim, not
|
||||
// dropped or rewritten to a repo it never named.
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let task = state
|
||||
.db
|
||||
.get_task(&created_id(&bytes))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
task.repo_id.as_deref(),
|
||||
Some("no-such-repo-id"),
|
||||
"an unresolved id must be stored verbatim as a label"
|
||||
);
|
||||
|
||||
// Quarantined repo id → treated as unknown (201), never a 403 that
|
||||
// would confirm the id names a real repo.
|
||||
state
|
||||
.db
|
||||
.set_repo_quarantine(&repo.id, true)
|
||||
.await
|
||||
.expect("quarantine");
|
||||
let resp = post_as(stranger, body_with(stranger, Some(&repo.id)))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::CREATED,
|
||||
"a quarantined repo id must not 403 (existence oracle)"
|
||||
);
|
||||
// Read back the row: the quarantined id is stored verbatim as a label
|
||||
// too, so the accepted write is not silently dropped.
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let task = state
|
||||
.db
|
||||
.get_task(&created_id(&bytes))
|
||||
.await
|
||||
.expect("get task")
|
||||
.expect("task must be stored");
|
||||
assert_eq!(
|
||||
task.repo_id.as_deref(),
|
||||
Some(repo.id.as_str()),
|
||||
"a quarantined id must be stored verbatim as a label"
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (REST): a repo-lookup fault in the create_task gate must render the
|
||||
/// module's opaque `db_error` envelope, not the raw sqlx text. Dropping a
|
||||
/// column `get_repo_by_id` selects faults that first lookup; the 500 body
|
||||
/// stays fixed and names no column (#250/#226).
|
||||
#[sqlx::test]
|
||||
async fn create_task_gate_repo_lookup_fault_is_opaque(pool: PgPool) {
|
||||
let owner = "did:key:zTASKFAULTOWNERAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let state = test_state(pool.clone()).await;
|
||||
let repo = seed_repo(owner, "task-gate-fault-repo");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/tasks",
|
||||
axum::routing::post(crate::api::tasks::create_task),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let body = format!(
|
||||
r#"{{"kind":"build","capability":"repo:write","delegator_did":"{owner}","repo_id":"{}"}}"#,
|
||||
repo.id
|
||||
);
|
||||
|
||||
// Drop a column the lookup's SELECT names so get_repo_by_id faults.
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN machine_id")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop machine_id");
|
||||
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
owner,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Body::from(body),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"a faulted repo lookup must be a 500"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
text.contains(r#""error":"db_error""#),
|
||||
"gate DB fault must keep the opaque envelope: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(crate::error::DB_ERROR_MESSAGE),
|
||||
"gate DB fault must carry the fixed message: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("machine_id") && !text.contains("column"),
|
||||
"schema text leaked through the gate: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (REST): the gate's second lookup (`is_repo_quarantined`) runs through
|
||||
/// the same opaque helper. Dropping the column only it selects leaves
|
||||
/// `get_repo_by_id` succeeding, so this pins that second call site too.
|
||||
#[sqlx::test]
|
||||
async fn create_task_gate_quarantine_lookup_fault_is_opaque(pool: PgPool) {
|
||||
let owner = "did:key:zTASKFAULT2OWNERAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let state = test_state(pool.clone()).await;
|
||||
let repo = seed_repo(owner, "task-gate-fault2-repo");
|
||||
state.db.create_repo(&repo).await.expect("seed repo");
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/tasks",
|
||||
axum::routing::post(crate::api::tasks::create_task),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let body = format!(
|
||||
r#"{{"kind":"build","capability":"repo:write","delegator_did":"{owner}","repo_id":"{}"}}"#,
|
||||
repo.id
|
||||
);
|
||||
|
||||
// `quarantined` is not in get_repo_by_id's SELECT, so that lookup still
|
||||
// succeeds and the fault lands on is_repo_quarantined.
|
||||
sqlx::query("ALTER TABLE repos DROP COLUMN quarantined")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop quarantined");
|
||||
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
owner,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Body::from(body),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"a faulted quarantine lookup must be a 500"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
text.contains(r#""error":"db_error""#),
|
||||
"quarantine DB fault must keep the opaque envelope: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(crate::error::DB_ERROR_MESSAGE),
|
||||
"quarantine DB fault must carry the fixed message: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("quarantined") && !text.contains("column"),
|
||||
"schema text leaked through the gate: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #496 (REST): the create_task INSERT goes through the same opaque helper,
|
||||
/// so a write fault must not serialize sqlx text either. A repo-less task
|
||||
/// reaches the INSERT without a gate lookup; dropping a column the INSERT
|
||||
/// names faults it.
|
||||
#[sqlx::test]
|
||||
async fn create_task_insert_fault_is_opaque(pool: PgPool) {
|
||||
let signer = "did:key:zTASKINSERTFAULTAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||
let state = test_state(pool.clone()).await;
|
||||
|
||||
let router = || {
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/v1/tasks",
|
||||
axum::routing::post(crate::api::tasks::create_task),
|
||||
)
|
||||
.with_state(state.clone())
|
||||
};
|
||||
let body =
|
||||
format!(r#"{{"kind":"build","capability":"repo:write","delegator_did":"{signer}"}}"#);
|
||||
|
||||
sqlx::query("ALTER TABLE agent_tasks DROP COLUMN status")
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("drop status");
|
||||
|
||||
let resp = router()
|
||||
.oneshot(signed_request_as(
|
||||
signer,
|
||||
Method::POST,
|
||||
"/api/v1/tasks",
|
||||
Body::from(body),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"a faulted INSERT must be a 500"
|
||||
);
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
assert!(
|
||||
text.contains(r#""error":"db_error""#),
|
||||
"INSERT fault must keep the opaque envelope: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(crate::error::DB_ERROR_MESSAGE),
|
||||
"INSERT fault must carry the fixed message: {text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("status") && !text.contains("column"),
|
||||
"schema text leaked through the INSERT: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// N3: get_tree gates on the REQUESTED subtree, not the repo root. A caller
|
||||
/// denied a withheld subtree is rejected there (404) but passes the gate on a
|
||||
/// non-withheld path (so the rejection is path-scoped, not repo-wide).
|
||||
|
||||
Reference in new issue
Block a user