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
+17
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO auth_sessions (token_hash, user_id, expires_at, scope)\n VALUES ($1, $2, $3, $4)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Text",
|
||||||
|
"Uuid",
|
||||||
|
"Timestamptz",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "105f8cc147247c69b3c45e2e3eb27fc33b1976accdda66ec3ccc7c57afecc8b9"
|
||||||
|
}
|
||||||
+8
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "PostgreSQL",
|
"db_name": "PostgreSQL",
|
||||||
"query": "SELECT u.id, u.workspace_id, u.role\n FROM auth_sessions s\n JOIN users u ON u.id = s.user_id\n WHERE s.token_hash = $1 AND s.expires_at > now()",
|
"query": "SELECT u.id, u.workspace_id, u.role, s.scope\n FROM auth_sessions s\n JOIN users u ON u.id = s.user_id\n WHERE s.token_hash = $1 AND s.expires_at > now()",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,11 @@
|
|||||||
"ordinal": 2,
|
"ordinal": 2,
|
||||||
"name": "role",
|
"name": "role",
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "scope",
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -25,10 +30,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"nullable": [
|
"nullable": [
|
||||||
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "900827c5c8c24f4861120e98e3cc8a5b70f22e9f4b4168c9e8eb51c53d68bdae"
|
"hash": "e8f7cb9c34be37fe16c5406e9263159693674dda567b60a1f87c6763ec448951"
|
||||||
}
|
}
|
||||||
@@ -60,12 +60,26 @@ fn err(id: Option<Value>, code: i64, message: &str) -> Json<Value> {
|
|||||||
|
|
||||||
// ── Auth ─────────────────────────────────────────────────────────
|
// ── Auth ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// This endpoint accepts a **narrow** credential as well as a person's session.
|
||||||
|
///
|
||||||
|
/// It is the one route a mission container is given a token for, and that token
|
||||||
|
/// sits in a file the agent can `cat`. Mission agents run arbitrary `Bash` with
|
||||||
|
/// egress and no read gate, so a full session here would be an owner-privileged
|
||||||
|
/// API key handed to something explicitly untrusted — which is why
|
||||||
|
/// `SCOPE_SKILLS_READ` exists and why this is the only call site that names it.
|
||||||
|
///
|
||||||
|
/// `authenticate_scoped` still accepts `full`, so the UI and any human caller
|
||||||
|
/// are unaffected.
|
||||||
async fn authed(state: &AppState, headers: &HeaderMap) -> Option<cm_auth::AuthedUser> {
|
async fn authed(state: &AppState, headers: &HeaderMap) -> Option<cm_auth::AuthedUser> {
|
||||||
let token = headers
|
let token = headers
|
||||||
.get(AUTHORIZATION)
|
.get(AUTHORIZATION)
|
||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))?;
|
.and_then(|v| v.strip_prefix("Bearer "))?;
|
||||||
state.auth.authenticate(token).await.ok()
|
state
|
||||||
|
.auth
|
||||||
|
.authenticate_scoped(token, cm_auth::SCOPE_SKILLS_READ)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the calling agent via `X-ZeroClaw-Agent` header
|
/// Resolve the calling agent via `X-ZeroClaw-Agent` header
|
||||||
|
|||||||
@@ -12,5 +12,7 @@ mod token;
|
|||||||
|
|
||||||
pub use bootstrap::bootstrap_owner;
|
pub use bootstrap::bootstrap_owner;
|
||||||
pub use jwt::{ExternalClaims, JwtError, JwtVerifier};
|
pub use jwt::{ExternalClaims, JwtError, JwtVerifier};
|
||||||
pub use service::{AuthError, AuthService, AuthedUser, SESSION_TTL};
|
pub use service::{
|
||||||
|
AuthError, AuthService, AuthedUser, SCOPE_FULL, SCOPE_SKILLS_READ, SESSION_TTL,
|
||||||
|
};
|
||||||
pub use token::SessionToken;
|
pub use token::SessionToken;
|
||||||
|
|||||||
@@ -10,6 +10,17 @@ use crate::token::{hash_token, SessionToken};
|
|||||||
/// How long a login session stays valid.
|
/// How long a login session stays valid.
|
||||||
pub const SESSION_TTL: Duration = Duration::days(7);
|
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
|
/// The authenticated caller attached to every API request: everything RBAC
|
||||||
/// decisions need, nothing more.
|
/// decisions need, nothing more.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -263,13 +274,44 @@ impl AuthService {
|
|||||||
/// JWTs (three dot-separated segments) take the hosted-identity path;
|
/// JWTs (three dot-separated segments) take the hosted-identity path;
|
||||||
/// everything else is a local opaque session token.
|
/// everything else is a local opaque session token.
|
||||||
pub async fn authenticate(&self, token_secret: &str) -> Result<AuthedUser, AuthError> {
|
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 let Some(verifier) = self.verifier.clone() {
|
||||||
if token_secret.matches('.').count() == 2 {
|
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;
|
return self.authenticate_external(token_secret, &verifier).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let row = sqlx::query!(
|
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
|
FROM auth_sessions s
|
||||||
JOIN users u ON u.id = s.user_id
|
JOIN users u ON u.id = s.user_id
|
||||||
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
||||||
@@ -278,6 +320,9 @@ impl AuthService {
|
|||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await?
|
.await?
|
||||||
.ok_or(AuthError::Unauthenticated)?;
|
.ok_or(AuthError::Unauthenticated)?;
|
||||||
|
if row.scope != SCOPE_FULL && row.scope != required {
|
||||||
|
return Err(AuthError::Unauthenticated);
|
||||||
|
}
|
||||||
Ok(AuthedUser {
|
Ok(AuthedUser {
|
||||||
user_id: UserId::from(row.id),
|
user_id: UserId::from(row.id),
|
||||||
workspace_id: WorkspaceId::from(row.workspace_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
|
/// Mint a long-lived opaque session for an internal service caller
|
||||||
/// (e.g. the per-team ZeroClaw runtime calling back into the MCP door).
|
/// (e.g. the per-team ZeroClaw runtime calling back into the MCP door).
|
||||||
/// Returns the plaintext token — the caller is responsible for handing
|
/// Returns the plaintext token — the caller is responsible for handing
|
||||||
|
|||||||
@@ -125,3 +125,81 @@ async fn tokens_are_unique_per_login() {
|
|||||||
auth.authenticate(a.secret()).await.unwrap();
|
auth.authenticate(a.secret()).await.unwrap();
|
||||||
auth.authenticate(b.secret()).await.unwrap();
|
auth.authenticate(b.secret()).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A narrow credential must be refused everywhere it was not explicitly
|
||||||
|
/// allowed.
|
||||||
|
///
|
||||||
|
/// This is the whole security property. The skills token lives in a file
|
||||||
|
/// inside a mission container, where an agent running arbitrary `Bash` can
|
||||||
|
/// read it — so what matters is not that `/mcp/skills` accepts it but that
|
||||||
|
/// **nothing else does**.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_scoped_token_is_refused_by_every_unscoped_caller() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let (_ws, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
|
||||||
|
let narrow = auth
|
||||||
|
.mint_scoped(user.id, cm_auth::SCOPE_SKILLS_READ, time::Duration::hours(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// `authenticate` is what every ordinary route calls.
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
auth.authenticate(&narrow).await,
|
||||||
|
Err(AuthError::Unauthenticated)
|
||||||
|
),
|
||||||
|
"a skills token must not authenticate a normal API call — the token is \
|
||||||
|
readable by the agent it is given to"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And it is refused for a DIFFERENT narrow scope, not just for `full`.
|
||||||
|
assert!(matches!(
|
||||||
|
auth.authenticate_scoped(&narrow, "some:other").await,
|
||||||
|
Err(AuthError::Unauthenticated)
|
||||||
|
));
|
||||||
|
|
||||||
|
// It does work for the one thing it is for.
|
||||||
|
let ok = auth
|
||||||
|
.authenticate_scoped(&narrow, cm_auth::SCOPE_SKILLS_READ)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ok.user_id, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A person's session keeps working everywhere, including the scoped route.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_full_session_still_satisfies_a_scoped_route() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let (_ws, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
auth.set_password(user.id, "correct horse battery staple")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let token = auth
|
||||||
|
.login_local("[email protected]", "correct horse battery staple")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(auth
|
||||||
|
.authenticate_scoped(token.secret(), cm_auth::SCOPE_SKILLS_READ)
|
||||||
|
.await
|
||||||
|
.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `mint_scoped` must refuse to mint a full token.
|
||||||
|
///
|
||||||
|
/// A caller reaching for this wants a narrow credential; handing back a full
|
||||||
|
/// one because an argument was wrong is precisely the failure the scope column
|
||||||
|
/// exists to prevent, and it would be invisible — the token would work.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mint_scoped_refuses_to_mint_a_full_token() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let (_ws, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
assert!(auth
|
||||||
|
.mint_scoped(user.id, cm_auth::SCOPE_FULL, time::Duration::hours(1))
|
||||||
|
.await
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Give a session a SCOPE, so a credential can be handed to something that is
|
||||||
|
-- not a person.
|
||||||
|
--
|
||||||
|
-- `AuthService::authenticate` returns a full `AuthedUser` carrying the user's
|
||||||
|
-- role. There is no narrower credential in the system, so any component that
|
||||||
|
-- needs to call the ClawMates API must be given one that can do everything the
|
||||||
|
-- user can.
|
||||||
|
--
|
||||||
|
-- That is the blocker on deploying the MCP door to mission agents
|
||||||
|
-- (`docs/TOOL-CALL-ARCHITECTURE.md` §3, which calls it "config, not code").
|
||||||
|
-- Reaching `/mcp/skills` from a mission container means putting a bearer token
|
||||||
|
-- in a file inside that container — and mission agents run arbitrary `Bash`
|
||||||
|
-- with egress and no read gate, which is the 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 API as the owner".
|
||||||
|
--
|
||||||
|
-- Verified before building this: no such credential is in a mission container
|
||||||
|
-- today. The runtime's config.toml has no `[mcp.servers]` block and no bearer,
|
||||||
|
-- so this would be a NEW exposure rather than an existing one.
|
||||||
|
--
|
||||||
|
-- FAIL CLOSED. The default is 'full', so every existing row and every existing
|
||||||
|
-- caller behaves exactly as before; `authenticate` REJECTS anything else, and a
|
||||||
|
-- route must opt in by asking for the scope it accepts. A scope added later and
|
||||||
|
-- wired nowhere therefore grants nothing, which is the safe direction for the
|
||||||
|
-- mistake most likely to be made here.
|
||||||
|
ALTER TABLE auth_sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS scope TEXT NOT NULL DEFAULT 'full';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN auth_sessions.scope IS
|
||||||
|
'full = a person''s session, accepted everywhere. Anything else is a narrow credential accepted only by routes that name that scope (see AuthService::authenticate_scoped). Never widen a token in place; mint a new one.';
|
||||||
|
|
||||||
|
-- The lookup is by token_hash and already indexed; this supports auditing and
|
||||||
|
-- revoking a whole class of narrow credential at once (e.g. every skills token
|
||||||
|
-- for a workspace after a leak).
|
||||||
|
CREATE INDEX IF NOT EXISTS auth_sessions_scope_idx
|
||||||
|
ON auth_sessions (scope)
|
||||||
|
WHERE scope <> 'full';
|
||||||
Reference in New Issue
Block a user