feat(workspace-config): project grant-bound logical settings through WIP
This commit is contained in:
@@ -11,6 +11,7 @@ pub use api_macros::axum as server_support;
|
||||
pub use api_macros::reqwest as client_support;
|
||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||
pub mod repository_openapi_typescript;
|
||||
mod workspace_config;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -18,6 +19,7 @@ use webauthn_rs_proto::{
|
||||
CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential,
|
||||
RequestChallengeResponse,
|
||||
};
|
||||
pub use workspace_config::*;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(JsonSchema)]
|
||||
@@ -200,6 +202,22 @@ impl_openapi_schema!(
|
||||
CurrentWorkerWorkdirDetachQuery,
|
||||
CurrentWorkerWorkdirOperationRequest,
|
||||
CurrentWorkerWorkdirOperationResponse,
|
||||
WorkspaceConfigAccess,
|
||||
WorkspaceConfigNodeKind,
|
||||
WorkspaceConfigFailureClassification,
|
||||
WorkspaceConfigAttachRequest,
|
||||
WorkspaceConfigAttachment,
|
||||
WorkspaceConfigCurrentResponse,
|
||||
WorkspaceConfigObserveRequest,
|
||||
WorkspaceConfigNode,
|
||||
WorkspaceConfigObserveResponse,
|
||||
WorkspaceConfigReadRequest,
|
||||
WorkspaceConfigReadResponse,
|
||||
WorkspaceConfigCommitRequest,
|
||||
WorkspaceConfigCommitResponse,
|
||||
WorkspaceConfigApiError,
|
||||
WorkspaceConfigGrantCreateRequest,
|
||||
WorkspaceConfigGrantResponse,
|
||||
ExternalWorkdirGrantCreateRequest,
|
||||
ExternalWorkdirGrantResponse,
|
||||
WorkspaceResponse,
|
||||
@@ -2570,6 +2588,55 @@ pub trait ServerApi {
|
||||
#[path] working_directory_id: String,
|
||||
#[body] request: WorkingDirectoryRemovalRequest,
|
||||
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
||||
#[get("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_get(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
) -> Result<WorkspaceConfigCurrentResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_attach(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigAttachRequest,
|
||||
) -> Result<WorkspaceConfigAttachment, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config/observe", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_observe(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigObserveRequest,
|
||||
) -> Result<WorkspaceConfigObserveResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config/read", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_read(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigReadRequest,
|
||||
) -> Result<WorkspaceConfigReadResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workers/self/workspace-config/commit", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)]
|
||||
async fn current_worker_workspace_config_commit(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigCommitRequest,
|
||||
) -> Result<WorkspaceConfigCommitResponse, WorkspaceConfigApiError>;
|
||||
#[post("/api/w/{workspace_id}/workspace-config-grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)]
|
||||
async fn workspace_config_grant_create(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: WorkspaceConfigGrantCreateRequest,
|
||||
) -> Result<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
|
||||
#[delete("/api/w/{workspace_id}/workspace-config-grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,500], bearer_auth = true, browser_auth = true)]
|
||||
async fn workspace_config_grant_revoke(
|
||||
&self,
|
||||
#[extension] actor: RequestActor,
|
||||
#[path] workspace_id: String,
|
||||
#[path] grant_id: String,
|
||||
) -> Result<WorkspaceConfigGrantResponse, WorkspaceConfigApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/w/{workspace_id}/workers/self/workdir-catalog",
|
||||
status = 200,
|
||||
@@ -6323,6 +6390,7 @@ pub struct Diagnostic {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkingDirectoryMaterializerKind {
|
||||
LogicalWorkspaceConfig,
|
||||
#[default]
|
||||
RuntimeGitClone,
|
||||
ClientHostedExternal,
|
||||
@@ -6462,6 +6530,11 @@ pub struct RuntimeWorkingDirectorySummary {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkingDirectorySource {
|
||||
WorkspaceConfig {
|
||||
access: WorkspaceConfigAccess,
|
||||
content_path: String,
|
||||
purpose: String,
|
||||
},
|
||||
Repository {
|
||||
repository_key: String,
|
||||
},
|
||||
@@ -10723,6 +10796,22 @@ pub fn legacy_catalog_typescript() -> String {
|
||||
ToolchainContract::decl(&config),
|
||||
WorkspaceConfigTreeResponse::decl(&config),
|
||||
ConfigCommitRequest::decl(&config),
|
||||
WorkspaceConfigAccess::decl(&config),
|
||||
WorkspaceConfigNodeKind::decl(&config),
|
||||
WorkspaceConfigFailureClassification::decl(&config),
|
||||
WorkspaceConfigAttachRequest::decl(&config),
|
||||
WorkspaceConfigAttachment::decl(&config),
|
||||
WorkspaceConfigCurrentResponse::decl(&config),
|
||||
WorkspaceConfigObserveRequest::decl(&config),
|
||||
WorkspaceConfigNode::decl(&config),
|
||||
WorkspaceConfigObserveResponse::decl(&config),
|
||||
WorkspaceConfigReadRequest::decl(&config),
|
||||
WorkspaceConfigReadResponse::decl(&config),
|
||||
WorkspaceConfigCommitRequest::decl(&config),
|
||||
WorkspaceConfigCommitResponse::decl(&config),
|
||||
WorkspaceConfigApiError::decl(&config),
|
||||
WorkspaceConfigGrantCreateRequest::decl(&config),
|
||||
WorkspaceConfigGrantResponse::decl(&config),
|
||||
ProfileSettingsResponse::decl(&config),
|
||||
WorkspaceProfileSummary::decl(&config),
|
||||
WorkspaceProfileSourceSummary::decl(&config),
|
||||
@@ -13214,6 +13303,11 @@ mod openapi_artifact_tests {
|
||||
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
|
||||
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
|
||||
const SIGNED_INTERNAL: &[&str] = &[
|
||||
"current_worker_workspace_config_get",
|
||||
"current_worker_workspace_config_attach",
|
||||
"current_worker_workspace_config_observe",
|
||||
"current_worker_workspace_config_read",
|
||||
"current_worker_workspace_config_commit",
|
||||
"current_worker_workdir_catalog",
|
||||
"current_worker_workdir_attachment_list",
|
||||
"current_worker_workdir_attach",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Grant-bound logical Workspace configuration transport. No host filesystem contract.
|
||||
use super::{ConfigCommitRequest, ConfigContentType};
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceConfigAccess {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceConfigNodeKind {
|
||||
Directory,
|
||||
File,
|
||||
Missing,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WorkspaceConfigFailureClassification {
|
||||
NotCommitted,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigAttachRequest {
|
||||
#[serde(default)]
|
||||
pub alias: Option<String>,
|
||||
#[serde(default)]
|
||||
pub access: Option<WorkspaceConfigAccess>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigAttachment {
|
||||
pub workspace_id: String,
|
||||
pub connection_id: String,
|
||||
pub alias: String,
|
||||
pub working_directory_id: String,
|
||||
pub access: WorkspaceConfigAccess,
|
||||
pub name: String,
|
||||
pub purpose: String,
|
||||
pub content_path: String,
|
||||
pub already_attached: bool,
|
||||
}
|
||||
/// Named transport wrapper required by the generated API; JSON remains attachment-or-null.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(transparent)]
|
||||
pub struct WorkspaceConfigCurrentResponse(pub Option<WorkspaceConfigAttachment>);
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigObserveRequest {
|
||||
pub connection_id: String,
|
||||
pub paths: Vec<String>,
|
||||
pub depth: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigNode {
|
||||
pub path: String,
|
||||
pub kind: WorkspaceConfigNodeKind,
|
||||
pub validator: String,
|
||||
pub digest: Option<String>,
|
||||
pub content_type: Option<ConfigContentType>,
|
||||
pub operations: Vec<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigObserveResponse {
|
||||
pub connection_id: String,
|
||||
pub validator: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub revision: u64,
|
||||
pub digest: String,
|
||||
/// Canonical commit entrypoints from this exact metadata snapshot.
|
||||
pub entrypoints: Vec<String>,
|
||||
pub nodes: Vec<WorkspaceConfigNode>,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigReadRequest {
|
||||
pub connection_id: String,
|
||||
pub path: String,
|
||||
pub validator: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigReadResponse {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub content_type: ConfigContentType,
|
||||
pub digest: String,
|
||||
pub validator: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigCommitRequest {
|
||||
pub connection_id: String,
|
||||
pub validator: String,
|
||||
pub request: ConfigCommitRequest,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigCommitResponse {
|
||||
pub validator: String,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub revision: u64,
|
||||
pub digest: String,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigApiError {
|
||||
pub status: u16,
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
pub classification: WorkspaceConfigFailureClassification,
|
||||
}
|
||||
impl api_macros::HttpError for WorkspaceConfigApiError {
|
||||
fn status_code(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
}
|
||||
impl api_macros::HttpRequestError for WorkspaceConfigApiError {
|
||||
fn from_request_rejection(status: u16, _message: String) -> Self {
|
||||
Self {
|
||||
status,
|
||||
code: "invalid_request".into(),
|
||||
message: "Invalid configuration request".into(),
|
||||
classification: WorkspaceConfigFailureClassification::NotCommitted,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigGrantCreateRequest {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub access: WorkspaceConfigAccess,
|
||||
}
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceConfigGrantResponse {
|
||||
pub grant_id: String,
|
||||
pub workspace_id: String,
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub working_directory_id: String,
|
||||
pub access: WorkspaceConfigAccess,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{ApiContract, HttpMethod, ServerApiMetadata};
|
||||
|
||||
#[test]
|
||||
fn workspace_config_observation_is_metadata_only_and_has_same_snapshot_cas_inputs() {
|
||||
let value = serde_json::json!({"connection_id":"c", "validator":"v", "revision":7, "digest":"d", "entrypoints":["main.dcdl"], "nodes":[{"path":"", "kind":"directory", "validator":"v", "digest":null, "content_type":null, "operations":["apply_changes"]}]});
|
||||
let response: WorkspaceConfigObserveResponse =
|
||||
serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
for field in ["revision", "digest", "entrypoints"] {
|
||||
let mut missing = value.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(missing).is_err());
|
||||
}
|
||||
let mut with_body = value;
|
||||
with_body["nodes"][0]["content"] = serde_json::json!("must never appear here");
|
||||
assert!(serde_json::from_value::<WorkspaceConfigObserveResponse>(with_body).is_err());
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceConfigGrantCreateRequest>(
|
||||
serde_json::json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<WorkspaceConfigAttachRequest>(
|
||||
serde_json::json!({"grant_id":"invented"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(WorkspaceConfigCurrentResponse(None)).unwrap(),
|
||||
serde_json::Value::Null
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_failure_classification_roundtrips_and_rejections_are_pre_effects() {
|
||||
for classification in [
|
||||
WorkspaceConfigFailureClassification::NotCommitted,
|
||||
WorkspaceConfigFailureClassification::Unknown,
|
||||
] {
|
||||
let error = WorkspaceConfigApiError {
|
||||
status: 500,
|
||||
code: "unavailable".into(),
|
||||
message: "safe".into(),
|
||||
classification,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkspaceConfigApiError>(
|
||||
serde_json::to_value(&error).unwrap()
|
||||
)
|
||||
.unwrap(),
|
||||
error
|
||||
);
|
||||
assert_eq!(api_macros::HttpError::status_code(&error), 500);
|
||||
}
|
||||
let error =
|
||||
<WorkspaceConfigApiError as api_macros::HttpRequestError>::from_request_rejection(
|
||||
413,
|
||||
"/private/secret".into(),
|
||||
);
|
||||
assert_eq!(
|
||||
error.classification,
|
||||
WorkspaceConfigFailureClassification::NotCommitted
|
||||
);
|
||||
assert!(!error.message.contains("private"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_config_generated_routes_are_exact_and_grants_are_separate() {
|
||||
for (name, method, suffix) in [
|
||||
("get", HttpMethod::Get, ""),
|
||||
("attach", HttpMethod::Post, ""),
|
||||
("observe", HttpMethod::Post, "/observe"),
|
||||
("read", HttpMethod::Post, "/read"),
|
||||
("commit", HttpMethod::Post, "/commit"),
|
||||
] {
|
||||
let name = format!("current_worker_workspace_config_{name}");
|
||||
let operation = ServerApiMetadata::OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == name)
|
||||
.unwrap();
|
||||
assert_eq!(operation.method, method);
|
||||
assert_eq!(
|
||||
operation.path,
|
||||
format!("/api/w/{{workspace_id}}/workers/self/workspace-config{suffix}")
|
||||
);
|
||||
}
|
||||
for (name, method, path) in [
|
||||
(
|
||||
"workspace_config_grant_create",
|
||||
HttpMethod::Post,
|
||||
"/api/w/{workspace_id}/workspace-config-grants",
|
||||
),
|
||||
(
|
||||
"workspace_config_grant_revoke",
|
||||
HttpMethod::Delete,
|
||||
"/api/w/{workspace_id}/workspace-config-grants/{grant_id}",
|
||||
),
|
||||
] {
|
||||
let operation = ServerApiMetadata::OPERATIONS
|
||||
.iter()
|
||||
.find(|operation| operation.operation_id == name)
|
||||
.unwrap();
|
||||
assert_eq!(operation.method, method);
|
||||
assert_eq!(operation.path, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user