refactor: remove server-global runtime trust
This commit is contained in:
@@ -10,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
|
||||
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
|
||||
const TOKEN_PREFIX: &str = "yoi-cap-v1";
|
||||
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
|
||||
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
|
||||
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
|
||||
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
||||
@@ -164,21 +162,6 @@ impl RuntimeIdentityMaterial {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct TrustedServerKey {
|
||||
pub server_id: String,
|
||||
pub public_key: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeHttpAuthConfig {
|
||||
pub runtime_id: String,
|
||||
#[serde(default)]
|
||||
pub trusted_servers: Vec<TrustedServerKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeAuthContext {
|
||||
pub server_id: String,
|
||||
@@ -188,122 +171,6 @@ pub struct RuntimeAuthContext {
|
||||
pub expires_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CapabilityClaims {
|
||||
pub iss: String,
|
||||
pub aud: String,
|
||||
pub workspace_id: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub exp: u64,
|
||||
pub jti: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CapabilityTokenSigner {
|
||||
server_id: String,
|
||||
private_key: String,
|
||||
}
|
||||
|
||||
impl CapabilityTokenSigner {
|
||||
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
server_id: server_id.into(),
|
||||
private_key: private_key.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn server_id(&self) -> &str {
|
||||
&self.server_id
|
||||
}
|
||||
|
||||
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
|
||||
if claims.iss != self.server_id {
|
||||
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
|
||||
}
|
||||
let private = decode_private_key(&self.private_key)?;
|
||||
let pair = Ed25519KeyPair::from_pkcs8(&private)
|
||||
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||
let payload = serde_json::to_vec(claims)?;
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload);
|
||||
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
||||
let signature = pair.sign(signing_input.as_bytes());
|
||||
Ok(format!(
|
||||
"{TOKEN_PREFIX}.{payload}.{}",
|
||||
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capability_claims(
|
||||
server_id: impl Into<String>,
|
||||
runtime_id: impl Into<String>,
|
||||
workspace_id: impl Into<String>,
|
||||
permissions: Vec<String>,
|
||||
ttl_seconds: u64,
|
||||
) -> Result<CapabilityClaims, RuntimeAuthError> {
|
||||
let exp = unix_now_seconds().saturating_add(ttl_seconds);
|
||||
Ok(CapabilityClaims {
|
||||
iss: server_id.into(),
|
||||
aud: runtime_id.into(),
|
||||
workspace_id: workspace_id.into(),
|
||||
permissions,
|
||||
exp,
|
||||
jti: new_token_id()?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn verify_capability_token(
|
||||
config: &RuntimeHttpAuthConfig,
|
||||
token: &str,
|
||||
required_permission: Option<&str>,
|
||||
now_seconds: u64,
|
||||
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
|
||||
let (payload, signature) = split_token(token)?;
|
||||
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
|
||||
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
|
||||
let Some(server) = config
|
||||
.trusted_servers
|
||||
.iter()
|
||||
.find(|server| server.server_id == claims.iss)
|
||||
else {
|
||||
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
|
||||
};
|
||||
let public_key = decode_public_key(&server.public_key)?;
|
||||
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
||||
UnparsedPublicKey::new(&ED25519, public_key)
|
||||
.verify(signing_input.as_bytes(), &signature)
|
||||
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
||||
|
||||
if claims.aud != config.runtime_id {
|
||||
return Err(RuntimeAuthError::WrongAudience {
|
||||
expected: config.runtime_id.clone(),
|
||||
actual: claims.aud,
|
||||
});
|
||||
}
|
||||
if claims.exp < now_seconds {
|
||||
return Err(RuntimeAuthError::Expired);
|
||||
}
|
||||
if claims.workspace_id.trim().is_empty() {
|
||||
return Err(RuntimeAuthError::MissingWorkspaceScope);
|
||||
}
|
||||
if let Some(required) = required_permission {
|
||||
if !claims
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|permission| permission == required)
|
||||
{
|
||||
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(RuntimeAuthContext {
|
||||
server_id: claims.iss,
|
||||
workspace_id: claims.workspace_id,
|
||||
permissions: claims.permissions,
|
||||
token_id: claims.jti,
|
||||
expires_at: claims.exp,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RuntimeRequestSourceClaims {
|
||||
pub iss: String,
|
||||
@@ -638,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
|
||||
}
|
||||
}
|
||||
|
||||
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
|
||||
let mut parts = token.split('.');
|
||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
||||
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
|
||||
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
|
||||
}
|
||||
_ => Err(RuntimeAuthError::InvalidTokenFormat),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_public_key(bytes: &[u8]) -> String {
|
||||
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
@@ -903,46 +760,4 @@ mod tests {
|
||||
Err(RuntimeAuthError::Expired)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
||||
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
||||
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
|
||||
let claims = CapabilityClaims {
|
||||
iss: "server-main".to_string(),
|
||||
aud: "runtime-main".to_string(),
|
||||
workspace_id: "workspace-a".to_string(),
|
||||
permissions: vec!["workers:list".to_string()],
|
||||
exp: 100,
|
||||
jti: "token-1".to_string(),
|
||||
};
|
||||
let token = signer.sign(&claims).unwrap();
|
||||
let auth = RuntimeHttpAuthConfig {
|
||||
runtime_id: "runtime-main".to_string(),
|
||||
trusted_servers: vec![TrustedServerKey {
|
||||
server_id: "server-main".to_string(),
|
||||
public_key: server.public_key.clone(),
|
||||
display_name: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
|
||||
assert_eq!(context.workspace_id, "workspace-a");
|
||||
assert!(matches!(
|
||||
verify_capability_token(&auth, &token, Some("workers:create"), 99),
|
||||
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
|
||||
));
|
||||
assert!(matches!(
|
||||
verify_capability_token(&auth, &token, Some("workers:list"), 101),
|
||||
Err(RuntimeAuthError::Expired)
|
||||
));
|
||||
let wrong_audience = RuntimeHttpAuthConfig {
|
||||
runtime_id: "other-runtime".to_string(),
|
||||
trusted_servers: auth.trusted_servers.clone(),
|
||||
};
|
||||
assert!(matches!(
|
||||
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
|
||||
Err(RuntimeAuthError::WrongAudience { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,7 @@
|
||||
//! Runtime process directly; a backend is expected to own any browser-facing
|
||||
//! credentials, registration, and policy.
|
||||
|
||||
use crate::auth::{
|
||||
RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, new_token_id, unix_now_seconds,
|
||||
verify_capability_token,
|
||||
};
|
||||
use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds};
|
||||
use crate::catalog::{
|
||||
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
|
||||
WorkerLifecycleAck, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
|
||||
@@ -95,11 +92,9 @@ pub struct RuntimeHttpServerConfig {
|
||||
pub display_name: Option<String>,
|
||||
/// v0 store selection for the Runtime process.
|
||||
pub store: RuntimeHttpStoreSelection,
|
||||
/// Minimal local bearer token placeholder for backend-to-Runtime calls.
|
||||
/// Minimal local bearer token for explicitly local Runtime calls.
|
||||
/// This is not a browser-facing credential model.
|
||||
pub local_token: Option<String>,
|
||||
/// Optional signed Server-to-Runtime capability token authority.
|
||||
pub auth: Option<RuntimeHttpAuthConfig>,
|
||||
}
|
||||
|
||||
impl Default for RuntimeHttpServerConfig {
|
||||
@@ -109,7 +104,6 @@ impl Default for RuntimeHttpServerConfig {
|
||||
display_name: None,
|
||||
store: RuntimeHttpStoreSelection::Memory,
|
||||
local_token: None,
|
||||
auth: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,34 +146,15 @@ pub async fn serve_runtime_http(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serve an existing Runtime on a pre-bound listener with signed capability-token auth.
|
||||
pub async fn serve_runtime_http_with_auth(
|
||||
runtime: Runtime,
|
||||
listener: TcpListener,
|
||||
local_token: Option<String>,
|
||||
auth: Option<RuntimeHttpAuthConfig>,
|
||||
) -> Result<(), RuntimeHttpServerError> {
|
||||
if local_token.is_none() && auth.is_none() {
|
||||
return Err(RuntimeHttpServerError::AuthRequired);
|
||||
}
|
||||
axum::serve(
|
||||
listener,
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, auth, None),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn serve_runtime_http_with_workspace_auth(
|
||||
runtime: Runtime,
|
||||
listener: TcpListener,
|
||||
local_token: Option<String>,
|
||||
auth: Option<RuntimeHttpAuthConfig>,
|
||||
workspace_auth: WorkspaceRuntimeHttpAuth,
|
||||
) -> Result<(), RuntimeHttpServerError> {
|
||||
axum::serve(
|
||||
listener,
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, auth, Some(workspace_auth)),
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -191,37 +166,25 @@ pub async fn serve_runtime_http_with_workspace_auth(
|
||||
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
|
||||
/// accepted or forwarded as Runtime authority.
|
||||
pub fn runtime_http_router(runtime: Runtime, local_token: String) -> Router {
|
||||
runtime_http_router_with_optional_auth(runtime, Some(local_token), None, None)
|
||||
}
|
||||
|
||||
/// Build the REST router for an existing Runtime with signed capability-token auth.
|
||||
pub fn runtime_http_router_with_auth(
|
||||
runtime: Runtime,
|
||||
local_token: Option<String>,
|
||||
auth: RuntimeHttpAuthConfig,
|
||||
) -> Router {
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), None)
|
||||
runtime_http_router_with_optional_auth(runtime, Some(local_token), None)
|
||||
}
|
||||
|
||||
pub fn runtime_http_router_with_workspace_auth(
|
||||
runtime: Runtime,
|
||||
local_token: Option<String>,
|
||||
auth: RuntimeHttpAuthConfig,
|
||||
workspace_auth: WorkspaceRuntimeHttpAuth,
|
||||
) -> Router {
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), Some(workspace_auth))
|
||||
runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth))
|
||||
}
|
||||
|
||||
fn runtime_http_router_with_optional_auth(
|
||||
runtime: Runtime,
|
||||
local_token: Option<String>,
|
||||
auth: Option<RuntimeHttpAuthConfig>,
|
||||
workspace_auth: Option<WorkspaceRuntimeHttpAuth>,
|
||||
) -> Router {
|
||||
let state = RuntimeHttpState {
|
||||
runtime,
|
||||
local_token: local_token.map(Arc::<str>::from),
|
||||
auth: auth.map(Arc::new),
|
||||
workspace_auth: workspace_auth.map(Arc::new),
|
||||
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
};
|
||||
@@ -328,7 +291,6 @@ pub const MAX_WORKER_FILE_UPLOAD_BYTES: usize =
|
||||
struct RuntimeHttpState {
|
||||
runtime: Runtime,
|
||||
local_token: Option<Arc<str>>,
|
||||
auth: Option<Arc<RuntimeHttpAuthConfig>>,
|
||||
workspace_auth: Option<Arc<WorkspaceRuntimeHttpAuth>>,
|
||||
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
|
||||
}
|
||||
@@ -709,9 +671,9 @@ async fn get_runtime_ping(
|
||||
));
|
||||
}
|
||||
let runtime_id = state
|
||||
.auth
|
||||
.workspace_auth
|
||||
.as_ref()
|
||||
.map(|config| config.runtime_id.trim())
|
||||
.map(|auth| auth.signer.runtime_id().trim())
|
||||
.filter(|runtime_id| !runtime_id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
RuntimeHttpRestError::new(
|
||||
@@ -2110,56 +2072,6 @@ async fn require_runtime_auth(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(auth) = state.auth.as_deref() {
|
||||
let Some(token) = supplied.as_deref() else {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
"missing Runtime capability bearer token",
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
match verify_capability_token(
|
||||
auth,
|
||||
token,
|
||||
required_runtime_permission(request.method(), request.uri().path()),
|
||||
unix_now_seconds(),
|
||||
) {
|
||||
Ok(context) => {
|
||||
let workspace_verification_exists = match state.workspace_auth.as_deref() {
|
||||
Some(workspace_auth) => match workspace_auth
|
||||
.verifications
|
||||
.get(&context.workspace_id, workspace_auth.signer.runtime_id())
|
||||
{
|
||||
Ok(record) => record.is_some(),
|
||||
Err(error) => {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
"workspace_runtime_verification_unavailable",
|
||||
error.to_string(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
},
|
||||
None => false,
|
||||
};
|
||||
if workspace_verification_exists {
|
||||
return RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_identity_required",
|
||||
"Legacy Server-issued capability is disabled after signed Workspace Runtime verification",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
request.extensions_mut().insert(context);
|
||||
return next.run(request).await;
|
||||
}
|
||||
Err(error) => {
|
||||
return runtime_auth_error_response(error).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(expected) = state.local_token.as_deref() {
|
||||
if supplied.as_deref() != Some(expected) {
|
||||
return RuntimeHttpRestError::new(
|
||||
@@ -2180,32 +2092,12 @@ async fn require_runtime_auth(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn runtime_auth_error_response(error: RuntimeAuthError) -> RuntimeHttpRestError {
|
||||
match error {
|
||||
RuntimeAuthError::MissingPermission(permission) => RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"forbidden",
|
||||
format!("Runtime capability token is missing required permission `{permission}`"),
|
||||
),
|
||||
RuntimeAuthError::MissingWorkspaceScope => RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_scope_required",
|
||||
"Runtime capability token is missing workspace scope",
|
||||
),
|
||||
other => RuntimeHttpRestError::new(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"unauthorized",
|
||||
format!("invalid Runtime capability token: {other}"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_workspace_scope(
|
||||
state: &RuntimeHttpState,
|
||||
auth: Option<&Extension<RuntimeAuthContext>>,
|
||||
) -> Result<Option<RuntimeWorkspaceScope>, RuntimeHttpRestError> {
|
||||
let Some(Extension(context)) = auth else {
|
||||
if state.auth.is_some() || state.local_token.is_some() {
|
||||
if state.workspace_auth.is_some() || state.local_token.is_some() {
|
||||
return Err(RuntimeHttpRestError::new(
|
||||
StatusCode::FORBIDDEN,
|
||||
"workspace_scope_required",
|
||||
@@ -2537,7 +2429,7 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
|
||||
pub enum RuntimeHttpServerError {
|
||||
#[error(transparent)]
|
||||
Runtime(#[from] RuntimeError),
|
||||
#[error("Runtime HTTP server requires capability-token auth or a local bearer token")]
|
||||
#[error("Runtime HTTP server requires Workspace issuer auth or a local bearer token")]
|
||||
AuthRequired,
|
||||
#[error("Runtime HTTP server I/O failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
@@ -2546,10 +2438,7 @@ pub enum RuntimeHttpServerError {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::{
|
||||
CapabilityTokenSigner, RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey,
|
||||
capability_claims,
|
||||
};
|
||||
use crate::auth::RuntimeIdentityMaterial;
|
||||
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef};
|
||||
use crate::config_bundle::{
|
||||
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
|
||||
@@ -2579,7 +2468,6 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
|
||||
let runtime = Runtime::new_memory();
|
||||
let (legacy_auth, _) = auth_config_and_signer();
|
||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
||||
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||
let workspace_public_key =
|
||||
@@ -2614,7 +2502,6 @@ mod tests {
|
||||
let app = runtime_http_router_with_workspace_auth(
|
||||
runtime,
|
||||
None,
|
||||
legacy_auth,
|
||||
WorkspaceRuntimeHttpAuth {
|
||||
verifier,
|
||||
signer: RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(),
|
||||
@@ -2764,82 +2651,6 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_requires_scoped_permission_and_returns_versioned_identity() {
|
||||
let runtime = Runtime::new_memory();
|
||||
let (auth, signer) = auth_config_and_signer();
|
||||
let app = runtime_http_router_with_auth(runtime, None, auth);
|
||||
let token =
|
||||
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v1/ping")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
let response = app.clone().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<RuntimeHttpPingResponse>(&body).unwrap(),
|
||||
RuntimeHttpPingResponse {
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION,
|
||||
}
|
||||
);
|
||||
|
||||
let wrong_scope_token =
|
||||
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
|
||||
let wrong_scope_request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v1/ping")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {wrong_scope_token}"))
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-b")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.oneshot(wrong_scope_request).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ping_rejects_token_without_ping_permission() {
|
||||
let runtime = Runtime::new_memory();
|
||||
let (auth, signer) = auth_config_and_signer();
|
||||
let app = runtime_http_router_with_auth(runtime, None, auth);
|
||||
let missing_credential = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v1/ping")
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
app.clone()
|
||||
.oneshot(missing_credential)
|
||||
.await
|
||||
.unwrap()
|
||||
.status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]);
|
||||
let request = Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/v1/ping")
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
app.oneshot(request).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_protocol_replaces_serialized_tracked_source() {
|
||||
let wire = serde_json::to_string(&protocol::Method::SubmitTracked {
|
||||
@@ -2937,301 +2748,6 @@ mod tests {
|
||||
.with_computed_digest()
|
||||
}
|
||||
|
||||
fn store_coder_test_bundle(runtime: &Runtime) {
|
||||
runtime
|
||||
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
|
||||
"builtin:coder".to_string(),
|
||||
)))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
|
||||
let mut request = task_request(objective);
|
||||
request.workspace_api = Some(WorkspaceApiRef {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
base_url: format!("https://workspace.example/{workspace_id}"),
|
||||
});
|
||||
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
settings_revision: 1,
|
||||
language: "English".to_string(),
|
||||
});
|
||||
request
|
||||
}
|
||||
|
||||
fn auth_config_and_signer() -> (RuntimeHttpAuthConfig, CapabilityTokenSigner) {
|
||||
let identity = RuntimeIdentityMaterial::generate("server-a").unwrap();
|
||||
let signer = CapabilityTokenSigner::new(identity.identity_id.clone(), identity.private_key);
|
||||
let auth = RuntimeHttpAuthConfig {
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
trusted_servers: vec![TrustedServerKey {
|
||||
server_id: identity.identity_id,
|
||||
public_key: identity.public_key,
|
||||
display_name: None,
|
||||
}],
|
||||
};
|
||||
(auth, signer)
|
||||
}
|
||||
|
||||
fn auth_config_and_two_signers() -> (
|
||||
RuntimeHttpAuthConfig,
|
||||
CapabilityTokenSigner,
|
||||
CapabilityTokenSigner,
|
||||
) {
|
||||
let identity_a = RuntimeIdentityMaterial::generate("server-a").unwrap();
|
||||
let identity_b = RuntimeIdentityMaterial::generate("server-b").unwrap();
|
||||
let signer_a =
|
||||
CapabilityTokenSigner::new(identity_a.identity_id.clone(), identity_a.private_key);
|
||||
let signer_b =
|
||||
CapabilityTokenSigner::new(identity_b.identity_id.clone(), identity_b.private_key);
|
||||
let auth = RuntimeHttpAuthConfig {
|
||||
runtime_id: "runtime-test".to_string(),
|
||||
trusted_servers: vec![
|
||||
TrustedServerKey {
|
||||
server_id: identity_a.identity_id,
|
||||
public_key: identity_a.public_key,
|
||||
display_name: None,
|
||||
},
|
||||
TrustedServerKey {
|
||||
server_id: identity_b.identity_id,
|
||||
public_key: identity_b.public_key,
|
||||
display_name: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
(auth, signer_a, signer_b)
|
||||
}
|
||||
|
||||
fn token_for_workspace(signer: &CapabilityTokenSigner, workspace_id: &str) -> String {
|
||||
token_for_workspace_with_permissions(
|
||||
signer,
|
||||
workspace_id,
|
||||
[
|
||||
"workers:list",
|
||||
"workers:create",
|
||||
"workers:read",
|
||||
"workers:input",
|
||||
"workers:stop",
|
||||
"workers:protocol",
|
||||
"workers:delete",
|
||||
"workdirs:operate",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn token_for_workspace_with_permissions<const N: usize>(
|
||||
signer: &CapabilityTokenSigner,
|
||||
workspace_id: &str,
|
||||
permissions: [&str; N],
|
||||
) -> String {
|
||||
let claims = capability_claims(
|
||||
signer.server_id(),
|
||||
"runtime-test",
|
||||
workspace_id,
|
||||
permissions.into_iter().map(str::to_string).collect(),
|
||||
3600,
|
||||
)
|
||||
.unwrap();
|
||||
signer.sign(&claims).unwrap()
|
||||
}
|
||||
|
||||
fn bearer_request(
|
||||
method: Method,
|
||||
uri: impl AsRef<str>,
|
||||
token: &str,
|
||||
body: impl Into<Body>,
|
||||
) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(uri.as_ref())
|
||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.body(body.into())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_workspace_scope_filters_list_and_hides_detail() {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
store_coder_test_bundle(&runtime);
|
||||
let (auth, signer) = auth_config_and_signer();
|
||||
let token_a = token_for_workspace(&signer, "workspace-a");
|
||||
let token_b = token_for_workspace(&signer, "workspace-b");
|
||||
let app = runtime_http_router_with_auth(runtime, None, auth);
|
||||
|
||||
let create_a = scoped_task_request("a", "workspace-a");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::POST,
|
||||
"/v1/workers",
|
||||
&token_a,
|
||||
serde_json::to_vec(&create_a).unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let worker_a: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(worker_a.worker.workspace_id.as_deref(), Some("workspace-a"));
|
||||
|
||||
let create_b = scoped_task_request("b", "workspace-b");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::POST,
|
||||
"/v1/workers",
|
||||
&token_b,
|
||||
serde_json::to_vec(&create_b).unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let worker_b: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(worker_b.worker.workspace_id.as_deref(), Some("workspace-b"));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
"/v1/workers",
|
||||
&token_a,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let workers: RuntimeHttpWorkersResponse = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(workers.workers.len(), 1);
|
||||
assert_eq!(workers.workers[0].worker_ref, worker_a.worker.worker_ref);
|
||||
assert_eq!(
|
||||
workers.workers[0].workspace_id.as_deref(),
|
||||
Some("workspace-a")
|
||||
);
|
||||
|
||||
let response = app
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
format!("/v1/workers/{}", worker_b.worker.worker_id),
|
||||
&token_a,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_workspace_owner_binding_rejects_other_trusted_server() {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
store_coder_test_bundle(&runtime);
|
||||
let (auth, signer_a, signer_b) = auth_config_and_two_signers();
|
||||
let token_a = token_for_workspace(&signer_a, "workspace-a");
|
||||
let token_b = token_for_workspace(&signer_b, "workspace-a");
|
||||
let app = runtime_http_router_with_auth(runtime, None, auth);
|
||||
|
||||
let create_a = scoped_task_request("a", "workspace-a");
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(bearer_request(
|
||||
Method::POST,
|
||||
"/v1/workers",
|
||||
&token_a,
|
||||
serde_json::to_vec(&create_a).unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let create_b = scoped_task_request("b", "workspace-a");
|
||||
let response = app
|
||||
.oneshot(bearer_request(
|
||||
Method::POST,
|
||||
"/v1/workers",
|
||||
&token_b,
|
||||
serde_json::to_vec(&create_b).unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_token_without_workspace_scope_is_forbidden() {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
let (auth, signer) = auth_config_and_signer();
|
||||
let token = token_for_workspace_with_permissions(&signer, "", ["workers:list"]);
|
||||
let app = runtime_http_router_with_auth(runtime, None, auth);
|
||||
|
||||
let response = app
|
||||
.oneshot(bearer_request(
|
||||
Method::GET,
|
||||
"/v1/workers",
|
||||
&token,
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_token_without_worker_permission_is_forbidden() {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
let (auth, signer) = auth_config_and_signer();
|
||||
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:list"]);
|
||||
let app = runtime_http_router_with_auth(runtime, None, auth);
|
||||
let create = scoped_task_request("a", "workspace-a");
|
||||
|
||||
let response = app
|
||||
.oneshot(bearer_request(
|
||||
Method::POST,
|
||||
"/v1/workers",
|
||||
&token,
|
||||
serde_json::to_vec(&create).unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capability_token_without_workdir_permission_is_forbidden() {
|
||||
let runtime =
|
||||
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
|
||||
.unwrap();
|
||||
let (auth, signer) = auth_config_and_signer();
|
||||
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]);
|
||||
let app = runtime_http_router_with_auth(runtime, None, auth);
|
||||
|
||||
let response = app
|
||||
.oneshot(bearer_request(
|
||||
Method::POST,
|
||||
"/v1/working-directories/wd-1/sessions",
|
||||
&token,
|
||||
serde_json::to_vec(&OpenWorkdirSessionRequest::default()).unwrap(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
fn task_request(_objective: &str) -> CreateWorkerRequest {
|
||||
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
|
||||
let bundle = test_bundle(profile.clone());
|
||||
@@ -3331,7 +2847,6 @@ mod tests {
|
||||
)
|
||||
.expect("runtime"),
|
||||
local_token: Some(Arc::from("token")),
|
||||
auth: None,
|
||||
workspace_auth: None,
|
||||
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
|
||||
"session-1".to_string(),
|
||||
@@ -3708,12 +3223,6 @@ mod tests {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let error = serve_runtime_http_with_auth(Runtime::new_memory(), listener, None, None)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,9 +15,9 @@ use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use worker_runtime::auth::{
|
||||
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
|
||||
};
|
||||
use worker_runtime::auth::RuntimeIdentityMaterial;
|
||||
#[cfg(test)]
|
||||
use worker_runtime::auth::decode_public_key;
|
||||
use worker_runtime::error::RuntimeError;
|
||||
use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||
use worker_runtime::http_server::{
|
||||
@@ -81,16 +81,15 @@ fn run() -> Result<(), ProcessError> {
|
||||
}
|
||||
if matches!(
|
||||
args.first().map(String::as_str),
|
||||
Some("identity" | "trust-server" | "trust-workspace")
|
||||
Some("identity" | "trust-workspace")
|
||||
) {
|
||||
return run_auth_command(args);
|
||||
}
|
||||
let Some(mut config) = parse_args(args)? else {
|
||||
let Some(config) = parse_args(args)? else {
|
||||
println!("{}", usage());
|
||||
return Ok(());
|
||||
};
|
||||
init_serve_tracing();
|
||||
config.http.auth = load_runtime_http_auth(&config)?;
|
||||
let workspace_http_auth = load_workspace_runtime_http_auth(&config)?;
|
||||
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
@@ -108,16 +107,19 @@ fn run() -> Result<(), ProcessError> {
|
||||
worker_runtime,
|
||||
listener,
|
||||
config.http.local_token,
|
||||
config.http.auth,
|
||||
workspace_auth,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
worker_runtime::http_server::serve_runtime_http_with_auth(
|
||||
let local_token = config.http.local_token.ok_or_else(|| {
|
||||
ProcessError::auth(
|
||||
"Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(),
|
||||
)
|
||||
})?;
|
||||
worker_runtime::http_server::serve_runtime_http(
|
||||
worker_runtime,
|
||||
listener,
|
||||
config.http.local_token,
|
||||
config.http.auth,
|
||||
Some(local_token),
|
||||
)
|
||||
.await
|
||||
};
|
||||
@@ -207,12 +209,7 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
.with_runtime_store_dir(runtime_store_dir);
|
||||
let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?;
|
||||
if let Some(identity) = runtime_auth.identity.clone() {
|
||||
if let [trusted_server] = runtime_auth.trusted_servers.as_slice() {
|
||||
factory =
|
||||
factory.with_runtime_request_identity(identity, trusted_server.server_id.clone());
|
||||
} else {
|
||||
factory = factory.with_remote_worker_mutation_identity(identity);
|
||||
}
|
||||
factory = factory.with_remote_worker_mutation_identity(identity);
|
||||
}
|
||||
let mut backend_resource_client: Option<
|
||||
Arc<dyn worker_runtime::resource::BackendResourceClient>,
|
||||
@@ -223,9 +220,9 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
"--backend-resource-endpoint requires a configured Runtime identity".to_owned(),
|
||||
)
|
||||
})?;
|
||||
let [trusted_server] = runtime_auth.trusted_servers.as_slice() else {
|
||||
let [workspace_issuer] = runtime_auth.workspace_issuers.as_slice() else {
|
||||
return Err(ProcessError::auth(
|
||||
"--backend-resource-endpoint requires exactly one trusted Server identity"
|
||||
"--backend-resource-endpoint requires exactly one trusted Workspace issuer"
|
||||
.to_owned(),
|
||||
));
|
||||
};
|
||||
@@ -234,7 +231,7 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
endpoint,
|
||||
config.backend_resource_token.clone(),
|
||||
)
|
||||
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
|
||||
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()),
|
||||
);
|
||||
factory = factory.with_resource_client(client.clone());
|
||||
backend_resource_client = Some(client);
|
||||
@@ -254,11 +251,10 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
|
||||
}
|
||||
RuntimeHttpStoreSelection::Fs { root } => {
|
||||
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
|
||||
config
|
||||
.http
|
||||
.auth
|
||||
runtime_auth
|
||||
.identity
|
||||
.as_ref()
|
||||
.map(|auth| auth.runtime_id.as_str())
|
||||
.map(|identity| identity.identity_id.as_str())
|
||||
.unwrap_or("local"),
|
||||
);
|
||||
options.display_name = config.http.display_name.clone();
|
||||
@@ -549,8 +545,6 @@ struct RuntimeAuthFile {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
identity: Option<RuntimeIdentityMaterial>,
|
||||
#[serde(default)]
|
||||
trusted_servers: Vec<TrustedServerKey>,
|
||||
#[serde(default)]
|
||||
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
|
||||
}
|
||||
|
||||
@@ -924,23 +918,6 @@ fn load_workspace_runtime_http_auth(
|
||||
}))
|
||||
}
|
||||
|
||||
fn load_runtime_http_auth(
|
||||
config: &ProcessConfig,
|
||||
) -> Result<Option<RuntimeHttpAuthConfig>, ProcessError> {
|
||||
let path = runtime_auth_path(config);
|
||||
let auth = read_runtime_auth_file(&path)?;
|
||||
let Some(identity) = auth.identity else {
|
||||
return Ok(None);
|
||||
};
|
||||
if auth.trusted_servers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(RuntimeHttpAuthConfig {
|
||||
runtime_id: identity.identity_id,
|
||||
trusted_servers: auth.trusted_servers,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_auth_storage_flags(args: &mut VecDeque<String>) -> Result<ProcessConfig, ProcessError> {
|
||||
let mut config = ProcessConfig::default()?;
|
||||
while let Some(arg) = args.pop_front() {
|
||||
@@ -967,7 +944,6 @@ fn run_auth_command(args: Vec<String>) -> Result<(), ProcessError> {
|
||||
let command = args.pop_front().unwrap_or_default();
|
||||
match command.as_str() {
|
||||
"identity" => run_identity_command(args),
|
||||
"trust-server" => run_trust_server_command(args),
|
||||
"trust-workspace" => run_trust_workspace_command(args),
|
||||
_ => Err(ProcessError::usage(format!(
|
||||
"unknown auth command `{command}`"
|
||||
@@ -1096,187 +1072,6 @@ fn run_identity_command(mut args: VecDeque<String>) -> Result<(), ProcessError>
|
||||
}
|
||||
}
|
||||
|
||||
fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessError> {
|
||||
let subcommand = args.pop_front().ok_or_else(|| {
|
||||
ProcessError::usage(
|
||||
"trust-server requires subcommand `add`, `list`, or `revoke`".to_string(),
|
||||
)
|
||||
})?;
|
||||
match subcommand.as_str() {
|
||||
"add" => {
|
||||
let mut server_id = None;
|
||||
let mut public_key = None;
|
||||
let mut display_name = None;
|
||||
let mut replace = false;
|
||||
let mut rest = VecDeque::new();
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||
"--public-key" => {
|
||||
public_key = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--display-name" => {
|
||||
display_name = Some(take_value(&flag, inline_value, &mut args)?)
|
||||
}
|
||||
"--replace" => {
|
||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||
replace = true;
|
||||
}
|
||||
"--fs-root" | "--fs-runtime-dir" => {
|
||||
rest.push_back(flag);
|
||||
if let Some(value) = inline_value {
|
||||
rest.push_back(value);
|
||||
} else {
|
||||
rest.push_back(args.pop_front().ok_or_else(|| {
|
||||
ProcessError::usage(format!(
|
||||
"{} requires a value",
|
||||
rest.back().unwrap()
|
||||
))
|
||||
})?);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ProcessError::usage(format!(
|
||||
"unknown trust-server add argument `{flag}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = parse_auth_storage_flags(&mut rest)?;
|
||||
let path = runtime_auth_path(&config);
|
||||
let mut auth = read_runtime_auth_file(&path)?;
|
||||
let server_id = server_id.ok_or_else(|| {
|
||||
ProcessError::usage("trust-server add requires --server-id".to_string())
|
||||
})?;
|
||||
let public_key = public_key.ok_or_else(|| {
|
||||
ProcessError::usage("trust-server add requires --public-key".to_string())
|
||||
})?;
|
||||
decode_public_key(&public_key)
|
||||
.map_err(|error| ProcessError::usage(error.to_string()))?;
|
||||
if auth
|
||||
.trusted_servers
|
||||
.iter()
|
||||
.any(|server| server.server_id == server_id)
|
||||
&& !replace
|
||||
{
|
||||
return Err(ProcessError::usage(format!(
|
||||
"trusted server `{server_id}` already exists; pass --replace to update it"
|
||||
)));
|
||||
}
|
||||
auth.trusted_servers
|
||||
.retain(|server| server.server_id != server_id);
|
||||
auth.trusted_servers.push(TrustedServerKey {
|
||||
server_id: server_id.clone(),
|
||||
public_key,
|
||||
display_name,
|
||||
});
|
||||
write_runtime_auth_file(&path, &auth)?;
|
||||
println!("trusted_server_id={server_id}");
|
||||
println!("auth_file={}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
"list" => {
|
||||
let mut json = false;
|
||||
let mut rest = VecDeque::new();
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--json" => {
|
||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
||||
json = true;
|
||||
}
|
||||
"--fs-root" | "--fs-runtime-dir" => {
|
||||
rest.push_back(flag);
|
||||
if let Some(value) = inline_value {
|
||||
rest.push_back(value);
|
||||
} else {
|
||||
rest.push_back(args.pop_front().ok_or_else(|| {
|
||||
ProcessError::usage(format!(
|
||||
"{} requires a value",
|
||||
rest.back().unwrap()
|
||||
))
|
||||
})?);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ProcessError::usage(format!(
|
||||
"unknown trust-server list argument `{flag}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = parse_auth_storage_flags(&mut rest)?;
|
||||
let auth = read_runtime_auth_file(&runtime_auth_path(&config))?;
|
||||
if json {
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&auth.trusted_servers)
|
||||
.map_err(|error| ProcessError::auth(error.to_string()))?
|
||||
);
|
||||
} else {
|
||||
for server in auth.trusted_servers {
|
||||
println!(
|
||||
"server_id={} public_key={} display_name={}",
|
||||
server.server_id,
|
||||
server.public_key,
|
||||
server.display_name.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
"revoke" => {
|
||||
let mut server_id = None;
|
||||
let mut rest = VecDeque::new();
|
||||
while let Some(arg) = args.pop_front() {
|
||||
let (flag, inline_value) = split_flag_value(arg)?;
|
||||
match flag.as_str() {
|
||||
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
|
||||
"--fs-root" | "--fs-runtime-dir" => {
|
||||
rest.push_back(flag);
|
||||
if let Some(value) = inline_value {
|
||||
rest.push_back(value);
|
||||
} else {
|
||||
rest.push_back(args.pop_front().ok_or_else(|| {
|
||||
ProcessError::usage(format!(
|
||||
"{} requires a value",
|
||||
rest.back().unwrap()
|
||||
))
|
||||
})?);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(ProcessError::usage(format!(
|
||||
"unknown trust-server revoke argument `{flag}`"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = parse_auth_storage_flags(&mut rest)?;
|
||||
let path = runtime_auth_path(&config);
|
||||
let mut auth = read_runtime_auth_file(&path)?;
|
||||
let server_id = server_id.ok_or_else(|| {
|
||||
ProcessError::usage("trust-server revoke requires --server-id".to_string())
|
||||
})?;
|
||||
let before = auth.trusted_servers.len();
|
||||
auth.trusted_servers
|
||||
.retain(|server| server.server_id != server_id);
|
||||
if auth.trusted_servers.len() == before {
|
||||
return Err(ProcessError::usage(format!(
|
||||
"trusted server `{server_id}` is not registered"
|
||||
)));
|
||||
}
|
||||
write_runtime_auth_file(&path, &auth)?;
|
||||
println!("revoked_server_id={server_id}");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(ProcessError::usage(format!(
|
||||
"unknown trust-server subcommand `{subcommand}`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn usage() -> &'static str {
|
||||
r#"Usage: yoi-runtime [OPTIONS]
|
||||
yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS]
|
||||
@@ -1302,14 +1097,11 @@ Options:
|
||||
Auth commands:
|
||||
identity init --runtime-id ID [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
identity show [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-server add --server-id ID --public-key KEY [--display-name NAME] [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-server list [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-workspace add --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-workspace list [--offset N] [--limit N] [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-workspace show --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-workspace replace --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]
|
||||
trust-server revoke --server-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"#
|
||||
trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1700,6 +1492,35 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_server_trust_entries_are_dropped_when_auth_store_is_rewritten() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("runtime-auth.toml");
|
||||
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"identity = {{ identity_id = \"{}\", private_key = \"{}\", public_key = \"{}\" }}\n[[trusted_servers]]\nserver_id = \"removed\"\npublic_key = \"removed\"\n",
|
||||
identity.identity_id, identity.private_key, identity.public_key
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let auth = read_runtime_auth_file(&path).unwrap();
|
||||
write_runtime_auth_file(&path, &auth).unwrap();
|
||||
let rewritten = std::fs::read_to_string(path).unwrap();
|
||||
assert!(!rewritten.contains("trusted_servers"));
|
||||
assert!(!rewritten.contains("server_id"));
|
||||
assert!(rewritten.contains("identity_id = \"runtime-a\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_server_trust_command_is_rejected() {
|
||||
let error = run_auth_command(vec!["trust-server".to_owned(), "list".to_owned()])
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert_eq!(error, "unknown auth command `trust-server`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_store_disables_runtime_catalog_persistence() {
|
||||
let config = parse_args(["--no-store"]).unwrap().unwrap();
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use workspace_api::WorkspacePublicIdentityBundle;
|
||||
|
||||
use crate::auth::{RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig};
|
||||
use crate::auth::RuntimeAuthError;
|
||||
|
||||
const WORKSPACE_TOKEN_PREFIX: &str = "yoi-workspace-v1";
|
||||
const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1.";
|
||||
@@ -1408,68 +1408,6 @@ pub(crate) fn hex_lower(bytes: &[u8]) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
pub enum RuntimeCapabilityVerifier {
|
||||
LegacyServer(RuntimeHttpAuthConfig),
|
||||
WorkspaceIssuer(WorkspaceCapabilityVerifier),
|
||||
}
|
||||
|
||||
pub enum RuntimeCapabilityVerification<'a> {
|
||||
LegacyServer {
|
||||
required_permission: Option<&'a str>,
|
||||
now_seconds: u64,
|
||||
},
|
||||
WorkspaceIssuer(WorkspaceCapabilityExpectation<'a>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum VerifiedRuntimeCapability {
|
||||
LegacyServer(RuntimeAuthContext),
|
||||
WorkspaceIssuer(VerifiedWorkspaceCapability),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RuntimeCapabilityVerificationError {
|
||||
#[error("Runtime capability verifier mode does not match the verification request")]
|
||||
ModeMismatch,
|
||||
#[error(transparent)]
|
||||
LegacyServer(#[from] RuntimeAuthError),
|
||||
#[error(transparent)]
|
||||
WorkspaceIssuer(#[from] WorkspaceCapabilityVerificationError),
|
||||
}
|
||||
|
||||
impl RuntimeCapabilityVerifier {
|
||||
pub fn verify(
|
||||
&self,
|
||||
token: &str,
|
||||
verification: RuntimeCapabilityVerification<'_>,
|
||||
) -> Result<VerifiedRuntimeCapability, RuntimeCapabilityVerificationError> {
|
||||
match (self, verification) {
|
||||
(
|
||||
Self::LegacyServer(config),
|
||||
RuntimeCapabilityVerification::LegacyServer {
|
||||
required_permission,
|
||||
now_seconds,
|
||||
},
|
||||
) => crate::auth::verify_capability_token(
|
||||
config,
|
||||
token,
|
||||
required_permission,
|
||||
now_seconds,
|
||||
)
|
||||
.map(VerifiedRuntimeCapability::LegacyServer)
|
||||
.map_err(Into::into),
|
||||
(
|
||||
Self::WorkspaceIssuer(verifier),
|
||||
RuntimeCapabilityVerification::WorkspaceIssuer(expectation),
|
||||
) => verifier
|
||||
.verify(token, &expectation)
|
||||
.map(VerifiedRuntimeCapability::WorkspaceIssuer)
|
||||
.map_err(Into::into),
|
||||
_ => Err(RuntimeCapabilityVerificationError::ModeMismatch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1762,54 +1700,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifier_selection_never_falls_back_between_legacy_and_workspace_authority() {
|
||||
assert_eq!(
|
||||
WorkspaceCapabilityVerifier::new(
|
||||
Vec::new(),
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap_err(),
|
||||
WorkspaceCapabilityVerificationError::TrustAuthorityMissing
|
||||
);
|
||||
let (key, bundle) = identity("workspace-1", "WK-1", 1);
|
||||
let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 1, 1).unwrap();
|
||||
let workspace = RuntimeCapabilityVerifier::WorkspaceIssuer(
|
||||
WorkspaceCapabilityVerifier::new(
|
||||
vec![record.clone()],
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let token = issue_workspace_capability_token(&key, &claims(&record, "typed-mode")).unwrap();
|
||||
assert!(matches!(
|
||||
workspace.verify(
|
||||
&token,
|
||||
RuntimeCapabilityVerification::LegacyServer {
|
||||
required_permission: None,
|
||||
now_seconds: 1_001,
|
||||
},
|
||||
),
|
||||
Err(RuntimeCapabilityVerificationError::ModeMismatch)
|
||||
));
|
||||
assert!(matches!(
|
||||
RuntimeCapabilityVerifier::WorkspaceIssuer(
|
||||
WorkspaceCapabilityVerifier::new(
|
||||
vec![record],
|
||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.verify(
|
||||
&token,
|
||||
RuntimeCapabilityVerification::WorkspaceIssuer(expectation(
|
||||
&workspace_request_body_digest(br#"{"content":"hello"}"#),
|
||||
)),
|
||||
),
|
||||
Ok(VerifiedRuntimeCapability::WorkspaceIssuer(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_replay_protection_survives_reconstruction() {
|
||||
let temporary = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user