feat(auth): the door that can delegate no longer needs a person's session
`/mcp` — `email_send`, `slack_post`, `delegate` — authenticated with `authenticate`, which accepts only `full`. Nothing hands it a token today, so this cost nothing yet; the moment something did, the only credential that worked would have been an owner's session, held by an agent runtime. `SCOPE_AGENT_DOOR` is that credential's narrow form. `full` still works, so the UI and every human caller are unaffected, and the route now names what it accepts rather than accepting everything by default. The test that matters is not that each scope opens its own route: it is that holding one grants nothing the other has. Both tokens live where an agent can read them. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f52cff3e04
commit
22eeaa6f15
@@ -237,12 +237,21 @@ async fn mint_grant(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Authenticate the bearer header → workspace/user. `None` if missing/invalid.
|
/// Authenticate the bearer header → workspace/user. `None` if missing/invalid.
|
||||||
|
///
|
||||||
|
/// Accepts [`cm_auth::SCOPE_AGENT_DOOR`] as well as a person's session. This
|
||||||
|
/// route is the one that can `delegate`, and the thing that will eventually
|
||||||
|
/// hold a token for it is an agent runtime — so the narrow credential has to
|
||||||
|
/// exist before something reaches for the only one that does.
|
||||||
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_AGENT_DOOR)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the specific claw making the call. Our ZeroClaw fork stamps the
|
/// Resolve the specific claw making the call. Our ZeroClaw fork stamps the
|
||||||
|
|||||||
@@ -13,6 +13,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::{
|
pub use service::{
|
||||||
AuthError, AuthService, AuthedUser, SCOPE_FULL, SCOPE_SKILLS_READ, SESSION_TTL,
|
AuthError, AuthService, AuthedUser, SCOPE_AGENT_DOOR, SCOPE_FULL, SCOPE_SKILLS_READ,
|
||||||
|
SESSION_TTL,
|
||||||
};
|
};
|
||||||
pub use token::SessionToken;
|
pub use token::SessionToken;
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ pub const SCOPE_FULL: &str = "full";
|
|||||||
/// that authenticates nowhere, which fails safely but silently.
|
/// that authenticates nowhere, which fails safely but silently.
|
||||||
pub const SCOPE_SKILLS_READ: &str = "skills:read";
|
pub const SCOPE_SKILLS_READ: &str = "skills:read";
|
||||||
|
|
||||||
|
/// Act through the §15 MCP door (`/mcp`), and nothing else.
|
||||||
|
///
|
||||||
|
/// The door is the actuator: `email_send`, `slack_post`, `delegate`. Reaching
|
||||||
|
/// it means a token an agent's runtime holds, and the same reasoning as
|
||||||
|
/// [`SCOPE_SKILLS_READ`] applies — a full session there is an owner-privileged
|
||||||
|
/// API key handed to a process whose whole purpose is to act on instructions
|
||||||
|
/// from a model.
|
||||||
|
///
|
||||||
|
/// Nothing mints one yet; the route accepts it so that whatever does will not
|
||||||
|
/// have to reach for a person's session to be understood. `full` still works,
|
||||||
|
/// so the UI and any human caller are unaffected.
|
||||||
|
pub const SCOPE_AGENT_DOOR: &str = "agent:door";
|
||||||
|
|
||||||
/// 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)]
|
||||||
|
|||||||
@@ -168,6 +168,54 @@ async fn a_scoped_token_is_refused_by_every_unscoped_caller() {
|
|||||||
assert_eq!(ok.user_id, user.id);
|
assert_eq!(ok.user_id, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The two narrow scopes must not substitute for each other.
|
||||||
|
///
|
||||||
|
/// They protect different things — one reads the skills catalogue, the other
|
||||||
|
/// operates the §15 door that can `delegate`. Both tokens live where an agent
|
||||||
|
/// can read them, so the whole value of having two constants is that holding
|
||||||
|
/// one grants nothing the other has.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_narrow_scope_does_not_open_the_other() {
|
||||||
|
let pool = cm_testkit::test_pool().await;
|
||||||
|
let (_ws, user) = seeded(&pool).await;
|
||||||
|
let auth = AuthService::new(pool);
|
||||||
|
|
||||||
|
let skills = auth
|
||||||
|
.mint_scoped(user.id, cm_auth::SCOPE_SKILLS_READ, time::Duration::hours(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let door = auth
|
||||||
|
.mint_scoped(user.id, cm_auth::SCOPE_AGENT_DOOR, time::Duration::hours(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
auth.authenticate_scoped(&skills, cm_auth::SCOPE_AGENT_DOOR).await,
|
||||||
|
Err(AuthError::Unauthenticated)
|
||||||
|
),
|
||||||
|
"a skills token must not reach the door — the door can `delegate`"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
auth.authenticate_scoped(&door, cm_auth::SCOPE_SKILLS_READ).await,
|
||||||
|
Err(AuthError::Unauthenticated)
|
||||||
|
),
|
||||||
|
"and a door token must not read the catalogue"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
auth.authenticate(&door).await,
|
||||||
|
Err(AuthError::Unauthenticated)
|
||||||
|
),
|
||||||
|
"nor authenticate an ordinary API call"
|
||||||
|
);
|
||||||
|
assert!(auth
|
||||||
|
.authenticate_scoped(&door, cm_auth::SCOPE_AGENT_DOOR)
|
||||||
|
.await
|
||||||
|
.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
/// A person's session keeps working everywhere, including the scoped route.
|
/// A person's session keeps working everywhere, including the scoped route.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_full_session_still_satisfies_a_scoped_route() {
|
async fn a_full_session_still_satisfies_a_scoped_route() {
|
||||||
|
|||||||
Reference in New Issue
Block a user