feat(auth): a credential narrow enough to hand to an agent
`docs/TOOL-CALL-ARCHITECTURE.md` §3 calls deploying the MCP door "config, not code". It is not, and the reason is authentication. `/mcp/skills` authenticates with `AuthService::authenticate`, which returns a full `AuthedUser` carrying the user's role. There is no narrower credential in the system. So pointing a mission container at the door means writing a bearer token into a file inside that container — and mission agents run arbitrary `Bash` with egress and no read gate, which is this platform's own documented security posture. An owner-scoped token there turns "the agent runs commands in a sandbox" into "the agent drives the whole ClawMates API as the owner". Checked before building this rather than assumed: no such credential is in a mission container today. The runtime's config.toml has no `[mcp.servers]` block and no bearer, so the door would have been a NEW exposure, not an existing one. So: `auth_sessions.scope`, defaulting to `full`. `authenticate` now delegates to `authenticate_scoped(token, SCOPE_FULL)`, which means **every existing caller rejects a narrow token** and a route must opt in by naming the scope it accepts. `/mcp/skills` is the only opt-in. Fail closed on purpose. The likely mistake here is adding a scope and forgetting to wire its check; this way that mistake grants nothing rather than granting everything. `mint_scoped` refuses to mint a `full` token — a caller reaching for it wants a narrow credential, and handing back a full one because an argument was wrong is exactly the failure the column exists to prevent, and it would be invisible because the token would work. The test that matters is not that the door accepts the token, it is that nothing else does. Negative-controlled: removing the scope comparison fails `a_scoped_token_is_refused_by_every_unscoped_caller`. `.sqlx` regenerated — `authenticate` is a compile-checked query and CI builds with SQLX_OFFLINE=true. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_018i9Ten1LU4jUr5d7TAWda9
This commit is contained in:
co-authored by
Claude Opus 5
parent
8591585e60
commit
2668191e30
@@ -10,6 +10,17 @@ use crate::token::{hash_token, SessionToken};
|
||||
/// How long a login session stays valid.
|
||||
pub const SESSION_TTL: Duration = Duration::days(7);
|
||||
|
||||
/// A person's session. Accepted by every route.
|
||||
pub const SCOPE_FULL: &str = "full";
|
||||
|
||||
/// Read the skills catalogue over MCP, and nothing else.
|
||||
///
|
||||
/// The credential a mission container is given so its agent can retrieve skill
|
||||
/// bodies on demand. Deliberately its own constant rather than a string
|
||||
/// literal at the two call sites: a typo in one of them would produce a token
|
||||
/// that authenticates nowhere, which fails safely but silently.
|
||||
pub const SCOPE_SKILLS_READ: &str = "skills:read";
|
||||
|
||||
/// The authenticated caller attached to every API request: everything RBAC
|
||||
/// decisions need, nothing more.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -263,13 +274,44 @@ impl AuthService {
|
||||
/// JWTs (three dot-separated segments) take the hosted-identity path;
|
||||
/// everything else is a local opaque session token.
|
||||
pub async fn authenticate(&self, token_secret: &str) -> Result<AuthedUser, AuthError> {
|
||||
self.authenticate_scoped(token_secret, SCOPE_FULL).await
|
||||
}
|
||||
|
||||
/// Resolve a bearer token that is allowed to be narrow.
|
||||
///
|
||||
/// `required` is the scope this call site accepts *in addition to*
|
||||
/// [`SCOPE_FULL`], which is a person's session and is accepted everywhere.
|
||||
///
|
||||
/// # Fail closed
|
||||
///
|
||||
/// [`authenticate`](Self::authenticate) delegates here with `SCOPE_FULL`,
|
||||
/// so a narrow token is **rejected by every existing caller** and a route
|
||||
/// has to opt in by naming the scope it accepts. That direction matters:
|
||||
/// the likely mistake is adding a scope and forgetting to wire a check, and
|
||||
/// this way that mistake grants nothing instead of granting everything.
|
||||
///
|
||||
/// A narrow credential exists because the alternative is worse. Reaching
|
||||
/// `/mcp/skills` from a mission container means putting a bearer token in a
|
||||
/// file inside it, and mission agents run arbitrary `Bash` with egress and
|
||||
/// no read gate — so a full session there is an owner-privileged API key
|
||||
/// handed to something explicitly untrusted.
|
||||
pub async fn authenticate_scoped(
|
||||
&self,
|
||||
token_secret: &str,
|
||||
required: &str,
|
||||
) -> Result<AuthedUser, AuthError> {
|
||||
if let Some(verifier) = self.verifier.clone() {
|
||||
if token_secret.matches('.').count() == 2 {
|
||||
// An external issuer JWT is always a person. There is no
|
||||
// narrow form of it, so it satisfies only `full`.
|
||||
if required != SCOPE_FULL {
|
||||
return Err(AuthError::Unauthenticated);
|
||||
}
|
||||
return self.authenticate_external(token_secret, &verifier).await;
|
||||
}
|
||||
}
|
||||
let row = sqlx::query!(
|
||||
"SELECT u.id, u.workspace_id, u.role
|
||||
"SELECT u.id, u.workspace_id, u.role, s.scope
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
||||
@@ -278,6 +320,9 @@ impl AuthService {
|
||||
.fetch_optional(&self.pool)
|
||||
.await?
|
||||
.ok_or(AuthError::Unauthenticated)?;
|
||||
if row.scope != SCOPE_FULL && row.scope != required {
|
||||
return Err(AuthError::Unauthenticated);
|
||||
}
|
||||
Ok(AuthedUser {
|
||||
user_id: UserId::from(row.id),
|
||||
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||
@@ -289,6 +334,35 @@ impl AuthService {
|
||||
})
|
||||
}
|
||||
|
||||
/// Mint a narrow, short-lived credential for something that is not a person.
|
||||
///
|
||||
/// Returns the secret, which is the only time it exists in plaintext here.
|
||||
pub async fn mint_scoped(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
scope: &str,
|
||||
ttl: Duration,
|
||||
) -> Result<String, AuthError> {
|
||||
if scope == SCOPE_FULL {
|
||||
// A caller reaching for this wants a narrow token; handing back a
|
||||
// full one because the argument was wrong is the failure this
|
||||
// whole change exists to prevent.
|
||||
return Err(AuthError::Unauthenticated);
|
||||
}
|
||||
let token = SessionToken::generate();
|
||||
sqlx::query!(
|
||||
"INSERT INTO auth_sessions (token_hash, user_id, expires_at, scope)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
hash_token(token.secret()),
|
||||
user_id.as_uuid(),
|
||||
OffsetDateTime::now_utc() + ttl,
|
||||
scope,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(token.secret().to_string())
|
||||
}
|
||||
|
||||
/// Mint a long-lived opaque session for an internal service caller
|
||||
/// (e.g. the per-team ZeroClaw runtime calling back into the MCP door).
|
||||
/// Returns the plaintext token — the caller is responsible for handing
|
||||
|
||||
Reference in New Issue
Block a user