feat(drive): expose authorized typed Workspace Drive API
T-722: reuse T-721 storage/CAS/receipts with clone-bound transaction authorization against attached Server authority. Add migration 84 audited Worker read_only/read_write grants and removal/deletion fencing, member-facing ordinary access, owner-controlled grant settings, Workspace-bound decimal entry references, bounded text/binary chunks, streamed HTTP receive/download with integrity and safe headers, typed errors and generated Rust/OpenAPI/TypeScript contracts. Validation: root cargo check; server-api 83 default / 91 typescript tests; FeatureStorage 16; Drive 31 unit + 12 integration; Workspace Server 691 library + 11 binary tests (--test-threads=4), including 9 real DB/LocalFileSystem HTTP tests for two Workers/non-owner usage, revoke during reception, concurrent CAS, response-loss receipts, chunk expiry and safe binary media. cargo fmt --all -- --check and git diff --check HEAD passed. Existing OpenAPI schemas/routes semantically unchanged; dependent TS digest projections regenerated.
This commit is contained in:
Generated
+1
@@ -6715,6 +6715,7 @@ name = "yoi-workspace-server"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"agen",
|
"agen",
|
||||||
|
"api-macros",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
@@ -55,3 +55,7 @@ required-features = ["typescript"]
|
|||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_repository_access_types"
|
name = "generate_repository_access_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_drive_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
fn main() {
|
||||||
|
print!(
|
||||||
|
"{}",
|
||||||
|
server_api::normalized_typescript(server_api::drive_api_typescript())
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,902 @@
|
|||||||
|
//! Workspace Drive HTTP contracts. Entries are logical, workspace-bound resources,
|
||||||
|
//! never host paths. Worker identity and grants are resolved by the service from
|
||||||
|
//! trusted request context, not supplied by ordinary operation DTOs.
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
|
|
||||||
|
pub const DRIVE_TEXT_MAX_BYTES: usize = 64 * 1024;
|
||||||
|
pub const DRIVE_CHUNK_MAX_BYTES: u32 = 64 * 1024;
|
||||||
|
pub const DRIVE_PAGE_MAX_LIMIT: u32 = 200;
|
||||||
|
pub const DRIVE_SEARCH_MAX_LIMIT: u32 = 128;
|
||||||
|
pub const DRIVE_FILE_MAX_BYTES: u32 = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
fn decimal<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
if !canonical_positive_decimal(&value) {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"expected a canonical positive decimal string",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
fn optional_decimal<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<String>, D::Error> {
|
||||||
|
let value = Option::<String>::deserialize(deserializer)?;
|
||||||
|
if value
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !canonical_positive_decimal(value))
|
||||||
|
{
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"expected a canonical positive decimal string",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
fn canonical_positive_decimal(value: &str) -> bool {
|
||||||
|
value
|
||||||
|
.as_bytes()
|
||||||
|
.first()
|
||||||
|
.is_some_and(|first| matches!(first, b'1'..=b'9'))
|
||||||
|
&& value.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
&& value.parse::<i64>().is_ok_and(|value| value > 0)
|
||||||
|
}
|
||||||
|
fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result<String, D::Error> {
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
if value.len() > DRIVE_TEXT_MAX_BYTES {
|
||||||
|
return Err(serde::de::Error::custom("Drive text exceeds 64 KiB"));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
fn bounded_chunk<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u32, D::Error> {
|
||||||
|
let value = u32::deserialize(deserializer)?;
|
||||||
|
if value == 0 || value > DRIVE_CHUNK_MAX_BYTES {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"Drive read size must be between 1 and 65536 bytes",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
fn optional_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<u32>, D::Error> {
|
||||||
|
let value = Option::<u32>::deserialize(deserializer)?;
|
||||||
|
if value.is_some_and(|value| value == 0 || value > DRIVE_PAGE_MAX_LIMIT) {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"Drive page limit must be between 1 and 200",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
const fn default_read_size() -> u32 {
|
||||||
|
DRIVE_CHUNK_MAX_BYTES
|
||||||
|
}
|
||||||
|
fn optional_search_limit<'de, D: Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<Option<u32>, D::Error> {
|
||||||
|
let value = Option::<u32>::deserialize(deserializer)?;
|
||||||
|
if value.is_some_and(|value| value == 0 || value > DRIVE_SEARCH_MAX_LIMIT) {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"Drive search limit must be between 1 and 128",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Numeric node IDs alone are not public references: the referenced Workspace
|
||||||
|
/// must match the route Workspace before the service resolves the node.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveEntryRef {
|
||||||
|
pub workspace_id: String,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub node_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DriveEntryKind {
|
||||||
|
Folder,
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata only: content is returned through bounded text or binary routes.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveEntry {
|
||||||
|
pub entry: DriveEntryRef,
|
||||||
|
pub parent: Option<DriveEntryRef>,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: DriveEntryKind,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub revision: String,
|
||||||
|
/// File length in bytes; absent for folders.
|
||||||
|
#[schemars(range(min = 0, max = 16_777_216))]
|
||||||
|
pub size: Option<u32>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
pub updated_by: String,
|
||||||
|
/// Timestamp projected from storage's updated_at_ms; not a creation time.
|
||||||
|
pub updated_at: String,
|
||||||
|
/// Stable relative authenticated URL in this same Workspace. No bearer token
|
||||||
|
/// or cross-Workspace link is embedded in this metadata.
|
||||||
|
pub latest_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveEntryQuery {
|
||||||
|
pub entry_workspace_id: String,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveListQuery {
|
||||||
|
pub entry_workspace_id: String,
|
||||||
|
/// Folder to list.
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default, deserialize_with = "optional_limit")]
|
||||||
|
#[schemars(range(min = 1, max = 200))]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
/// Opaque service-issued page cursor, not a bare node ID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub after: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveSearchQuery {
|
||||||
|
pub query: String,
|
||||||
|
/// Search file text as well as names only when explicitly requested.
|
||||||
|
#[serde(default)]
|
||||||
|
pub include_text: bool,
|
||||||
|
#[serde(default, deserialize_with = "optional_search_limit")]
|
||||||
|
#[schemars(range(min = 1, max = 128))]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub after: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveListResponse {
|
||||||
|
pub entries: Vec<DriveEntry>,
|
||||||
|
pub next_after: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveReadTextQuery {
|
||||||
|
pub entry_workspace_id: String,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||||
|
#[schemars(range(min = 1, max = 65536))]
|
||||||
|
pub max_bytes: u32,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveReadTextResponse {
|
||||||
|
pub entry: DriveEntry,
|
||||||
|
#[serde(deserialize_with = "bounded_text")]
|
||||||
|
pub text: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveReadChunkQuery {
|
||||||
|
pub entry_workspace_id: String,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub id: String,
|
||||||
|
/// Bind a multi-chunk read to the same file revision.
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub expected_revision: String,
|
||||||
|
#[schemars(range(min = 0, max = 16_777_216))]
|
||||||
|
pub offset: u32,
|
||||||
|
#[serde(default = "default_read_size", deserialize_with = "bounded_chunk")]
|
||||||
|
#[schemars(range(min = 1, max = 65536))]
|
||||||
|
pub length: u32,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveDownloadQuery {
|
||||||
|
pub entry_workspace_id: String,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default, deserialize_with = "optional_decimal")]
|
||||||
|
pub expected_revision: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveMutationRequest {
|
||||||
|
pub request_id: String,
|
||||||
|
pub mutation: DriveMutation,
|
||||||
|
}
|
||||||
|
/// JSON can mutate bounded UTF-8 TEXT only. Arbitrary file bytes use drive_upload.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)]
|
||||||
|
pub enum DriveMutation {
|
||||||
|
CreateFolder {
|
||||||
|
parent: DriveEntryRef,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
CreateText {
|
||||||
|
parent: DriveEntryRef,
|
||||||
|
name: String,
|
||||||
|
#[serde(deserialize_with = "bounded_text")]
|
||||||
|
text: String,
|
||||||
|
content_type: String,
|
||||||
|
},
|
||||||
|
UpdateText {
|
||||||
|
id: DriveEntryRef,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
expected_revision: String,
|
||||||
|
#[serde(deserialize_with = "bounded_text")]
|
||||||
|
text: String,
|
||||||
|
content_type: String,
|
||||||
|
},
|
||||||
|
Relocate {
|
||||||
|
id: DriveEntryRef,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
expected_revision: String,
|
||||||
|
parent: DriveEntryRef,
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
id: DriveEntryRef,
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
expected_revision: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveMutationResponse {
|
||||||
|
pub request_id: String,
|
||||||
|
/// A deleted entry has no live metadata.
|
||||||
|
pub entry: Option<DriveEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DriveUploadOperation {
|
||||||
|
Create,
|
||||||
|
Update,
|
||||||
|
}
|
||||||
|
/// Flat query metadata accompanies the binary body. The service rejects invalid
|
||||||
|
/// combinations: create requires parent_id/name and forbids id/expected_revision;
|
||||||
|
/// update requires id/expected_revision and forbids parent_id/name. Both targets
|
||||||
|
/// are bound by entry_workspace_id; declared size and SHA256 must match the body.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveUploadQuery {
|
||||||
|
pub operation: DriveUploadOperation,
|
||||||
|
pub request_id: String,
|
||||||
|
pub entry_workspace_id: String,
|
||||||
|
#[serde(default, deserialize_with = "optional_decimal")]
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "optional_decimal")]
|
||||||
|
pub id: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "optional_decimal")]
|
||||||
|
pub expected_revision: Option<String>,
|
||||||
|
pub content_type: String,
|
||||||
|
/// Values above the file limit still decode as u32 so the service returns
|
||||||
|
/// typed Limit rather than a generic request-decoding error.
|
||||||
|
#[schemars(range(min = 0, max = 16_777_216))]
|
||||||
|
pub size: u32,
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DriveRequestState {
|
||||||
|
/// No commit record exists at the observation snapshot. An in-flight request
|
||||||
|
/// can still commit afterward; this is not proof of a final failed outcome.
|
||||||
|
Uncommitted,
|
||||||
|
Committed,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveRequestStatusResponse {
|
||||||
|
pub request_id: String,
|
||||||
|
pub state: DriveRequestState,
|
||||||
|
pub response: Option<DriveMutationResponse>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DriveAccess {
|
||||||
|
ReadOnly,
|
||||||
|
ReadWrite,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveGrantCreateRequest {
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub access: DriveAccess,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveGrantResponse {
|
||||||
|
#[serde(deserialize_with = "decimal")]
|
||||||
|
#[schemars(regex(pattern = "^[1-9][0-9]*$"))]
|
||||||
|
pub grant_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub worker_id: String,
|
||||||
|
pub access: DriveAccess,
|
||||||
|
pub revoked: bool,
|
||||||
|
pub created_by: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub revoked_by: Option<String>,
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveGrantListQuery {
|
||||||
|
#[serde(default, deserialize_with = "optional_limit")]
|
||||||
|
#[schemars(range(min = 1, max = 200))]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
/// Canonical grant ID cursor within this Workspace.
|
||||||
|
#[serde(default, deserialize_with = "optional_decimal")]
|
||||||
|
pub after: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveGrantListResponse {
|
||||||
|
pub grants: Vec<DriveGrantResponse>,
|
||||||
|
pub next_after: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DriveApiErrorCode {
|
||||||
|
Denied,
|
||||||
|
NotFound,
|
||||||
|
Conflict,
|
||||||
|
Invalid,
|
||||||
|
Limit,
|
||||||
|
StorageUnavailable,
|
||||||
|
OutcomeUnknown,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DriveFailureClassification {
|
||||||
|
NotCommitted,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct DriveApiError {
|
||||||
|
pub code: DriveApiErrorCode,
|
||||||
|
pub classification: DriveFailureClassification,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
impl DriveApiError {
|
||||||
|
/// Never include provider errors, paths, SQL, or caller-supplied messages.
|
||||||
|
pub fn new(code: DriveApiErrorCode) -> Self {
|
||||||
|
use DriveApiErrorCode::*;
|
||||||
|
Self {
|
||||||
|
code,
|
||||||
|
classification: if code == OutcomeUnknown {
|
||||||
|
DriveFailureClassification::Unknown
|
||||||
|
} else {
|
||||||
|
DriveFailureClassification::NotCommitted
|
||||||
|
},
|
||||||
|
message: match code {
|
||||||
|
Denied => "Drive access denied",
|
||||||
|
NotFound => "Drive resource not found",
|
||||||
|
Conflict => "Drive revision or name conflict",
|
||||||
|
Invalid => "Invalid Drive request",
|
||||||
|
Limit => "Drive request exceeds a limit",
|
||||||
|
StorageUnavailable => "Drive storage unavailable",
|
||||||
|
OutcomeUnknown => "Drive mutation outcome unknown",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl api_macros::HttpError for DriveApiError {
|
||||||
|
fn status_code(&self) -> u16 {
|
||||||
|
use DriveApiErrorCode::*;
|
||||||
|
match self.code {
|
||||||
|
Denied => 403,
|
||||||
|
NotFound => 404,
|
||||||
|
Conflict => 409,
|
||||||
|
Invalid => 400,
|
||||||
|
Limit => 413,
|
||||||
|
StorageUnavailable | OutcomeUnknown => 503,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl api_macros::HttpRequestError for DriveApiError {
|
||||||
|
fn from_request_rejection(status: u16, _message: String) -> Self {
|
||||||
|
Self::new(if status == 413 {
|
||||||
|
DriveApiErrorCode::Limit
|
||||||
|
} else {
|
||||||
|
DriveApiErrorCode::Invalid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveApiError {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveEntry {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveListQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveListResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveReadTextQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveReadTextResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveReadChunkQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveDownloadQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveMutationRequest {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveMutationResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveUploadQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveRequestStatusResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveGrantCreateRequest {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveGrantResponse {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveGrantListQuery {}
|
||||||
|
impl api_macros::openapi::OpenApiSchema for DriveGrantListResponse {}
|
||||||
|
|
||||||
|
/// Browser wire declarations are derived from the transport DTOs, not copied
|
||||||
|
/// from storage models or maintained independently in the Web client.
|
||||||
|
#[cfg(feature = "typescript")]
|
||||||
|
pub fn drive_api_typescript() -> String {
|
||||||
|
use ts_rs::TS;
|
||||||
|
let config = ts_rs::Config::default();
|
||||||
|
let declarations = [
|
||||||
|
DriveEntryRef::decl(&config),
|
||||||
|
DriveEntryKind::decl(&config),
|
||||||
|
DriveEntry::decl(&config),
|
||||||
|
DriveEntryQuery::decl(&config),
|
||||||
|
DriveListQuery::decl(&config),
|
||||||
|
DriveSearchQuery::decl(&config),
|
||||||
|
DriveListResponse::decl(&config),
|
||||||
|
DriveReadTextQuery::decl(&config),
|
||||||
|
DriveReadTextResponse::decl(&config),
|
||||||
|
DriveReadChunkQuery::decl(&config),
|
||||||
|
DriveDownloadQuery::decl(&config),
|
||||||
|
DriveMutation::decl(&config),
|
||||||
|
DriveMutationRequest::decl(&config),
|
||||||
|
DriveMutationResponse::decl(&config),
|
||||||
|
DriveUploadOperation::decl(&config),
|
||||||
|
DriveUploadQuery::decl(&config),
|
||||||
|
DriveRequestState::decl(&config),
|
||||||
|
DriveRequestStatusResponse::decl(&config),
|
||||||
|
DriveAccess::decl(&config),
|
||||||
|
DriveGrantCreateRequest::decl(&config),
|
||||||
|
DriveGrantResponse::decl(&config),
|
||||||
|
DriveGrantListQuery::decl(&config),
|
||||||
|
DriveGrantListResponse::decl(&config),
|
||||||
|
DriveApiErrorCode::decl(&config),
|
||||||
|
DriveFailureClassification::decl(&config),
|
||||||
|
DriveApiError::decl(&config),
|
||||||
|
];
|
||||||
|
format!(
|
||||||
|
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts\n\n{}\n",
|
||||||
|
declarations
|
||||||
|
.into_iter()
|
||||||
|
.map(|declaration| format!("export {declaration}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
|
mod typescript_tests {
|
||||||
|
#[test]
|
||||||
|
fn generated_drive_api_contract_is_current() {
|
||||||
|
let expected = super::drive_api_typescript();
|
||||||
|
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../web/workspace/src/lib/generated/drive-api.ts");
|
||||||
|
let actual = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()));
|
||||||
|
assert_eq!(
|
||||||
|
normalize(&actual),
|
||||||
|
normalize(&expected),
|
||||||
|
"regenerate Drive API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts` and format the generated file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fn normalize(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter_map(|character| match character {
|
||||||
|
character if character.is_whitespace() => None,
|
||||||
|
',' => Some(';'),
|
||||||
|
character => Some(character),
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.replace("=|", "=")
|
||||||
|
.replace(";}", "}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::{ApiContract, HttpMethod, ServerApiMetadata, canonical_openapi_document};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_entry_references_reject_unbound_or_noncanonical_numeric_ids() {
|
||||||
|
let valid = json!({"workspace_id":"workspace-a", "node_id":"9007199254740993"});
|
||||||
|
let entry: DriveEntryRef = serde_json::from_value(valid.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(entry).unwrap(), valid);
|
||||||
|
for invalid in [
|
||||||
|
json!("7"),
|
||||||
|
json!(7),
|
||||||
|
json!({"node_id":"7"}),
|
||||||
|
json!({"workspace_id":"a", "node_id":7}),
|
||||||
|
] {
|
||||||
|
assert!(serde_json::from_value::<DriveEntryRef>(invalid).is_err());
|
||||||
|
}
|
||||||
|
for id in [
|
||||||
|
"",
|
||||||
|
"0",
|
||||||
|
"01",
|
||||||
|
"-1",
|
||||||
|
"+1",
|
||||||
|
"1.0",
|
||||||
|
"1e3",
|
||||||
|
" 1",
|
||||||
|
"١",
|
||||||
|
"9223372036854775808",
|
||||||
|
"18446744073709551616",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveEntryRef>(json!({"workspace_id":"a", "node_id":id}))
|
||||||
|
.is_err(),
|
||||||
|
"accepted {id:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveEntryQuery>(
|
||||||
|
json!({"entry_workspace_id":"a", "id":id})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveEntryRef>(
|
||||||
|
json!({"workspace_id":"a","node_id":"9223372036854775807"})
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_value::<DriveEntryQuery>(json!({"id":"7"})).is_err());
|
||||||
|
assert!(serde_json::from_value::<DriveListQuery>(json!({"id":"7"})).is_err());
|
||||||
|
assert!(serde_json::from_value::<DriveReadTextQuery>(json!({"id":"7"})).is_err());
|
||||||
|
assert!(serde_json::from_value::<DriveDownloadQuery>(json!({"id":"7"})).is_err());
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveEntryQuery>(
|
||||||
|
json!({"entry_workspace_id":"a", "id":"7", "worker_id":"spoofed"})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_metadata_exposes_only_stored_update_provenance_and_same_workspace_url() {
|
||||||
|
let value = json!({
|
||||||
|
"entry":{"workspace_id":"a","node_id":"7"},
|
||||||
|
"parent":{"workspace_id":"a","node_id":"1"},
|
||||||
|
"name":"file.txt","kind":"file","revision":"2","size":5,
|
||||||
|
"content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z",
|
||||||
|
"latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7"
|
||||||
|
});
|
||||||
|
let entry: DriveEntry = serde_json::from_value(value.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(entry).unwrap(), value);
|
||||||
|
for field in ["sha256", "created_at"] {
|
||||||
|
let mut fabricated = value.clone();
|
||||||
|
fabricated[field] = json!("not stored");
|
||||||
|
assert!(serde_json::from_value::<DriveEntry>(fabricated).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_search_text_is_opt_in_and_work_budget_is_bounded() {
|
||||||
|
let query: DriveSearchQuery = serde_json::from_value(json!({"query":"name"})).unwrap();
|
||||||
|
assert!(!query.include_text);
|
||||||
|
let query: DriveSearchQuery =
|
||||||
|
serde_json::from_value(json!({"query":"text","include_text":true,"limit":128}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(query.include_text);
|
||||||
|
for limit in [0, 129, u32::MAX] {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveSearchQuery>(json!({"query":"name","limit":limit}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_request_status_is_a_commit_snapshot_not_a_fictitious_lifecycle() {
|
||||||
|
for state in ["uncommitted", "committed"] {
|
||||||
|
let value = json!({"request_id":"r","state":state,"response":null});
|
||||||
|
let response: DriveRequestStatusResponse =
|
||||||
|
serde_json::from_value(value.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||||
|
}
|
||||||
|
for state in ["pending", "unknown", "not_committed"] {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveRequestStatusResponse>(
|
||||||
|
json!({"request_id":"r","state":state,"response":null})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() {
|
||||||
|
let valid = json!({"request_id":"request-a", "mutation":{
|
||||||
|
"operation":"update_text", "id":{"workspace_id":"a","node_id":"7"},
|
||||||
|
"expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain"
|
||||||
|
}});
|
||||||
|
let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(request).unwrap(), valid);
|
||||||
|
let mut invalid = valid.clone();
|
||||||
|
invalid["mutation"]["bytes"] = json!([0, 255]);
|
||||||
|
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||||
|
let mut invalid = valid.clone();
|
||||||
|
invalid["mutation"]["id"] = json!("7");
|
||||||
|
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||||
|
let mut invalid = valid.clone();
|
||||||
|
invalid["mutation"]["expected_revision"] = json!("01");
|
||||||
|
assert!(serde_json::from_value::<DriveMutationRequest>(invalid).is_err());
|
||||||
|
// UTF-8 byte count, not character count, determines the text bound.
|
||||||
|
let mut boundary = valid;
|
||||||
|
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2));
|
||||||
|
assert!(serde_json::from_value::<DriveMutationRequest>(boundary.clone()).is_ok());
|
||||||
|
boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2 + 1));
|
||||||
|
assert!(serde_json::from_value::<DriveMutationRequest>(boundary).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_reads_reject_zero_or_over_64kib_requests() {
|
||||||
|
for size in [0, 65537, u32::MAX] {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveReadTextQuery>(
|
||||||
|
json!({"entry_workspace_id":"a","id":"7","max_bytes":size})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":size})).is_err());
|
||||||
|
}
|
||||||
|
let default: DriveReadTextQuery =
|
||||||
|
serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap();
|
||||||
|
assert_eq!(default.max_bytes, 65536);
|
||||||
|
let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":65536})).unwrap();
|
||||||
|
assert_eq!(chunk.length, 65536);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_upload_requires_binding_declared_integrity_and_request_identity() {
|
||||||
|
let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_revision":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)});
|
||||||
|
assert!(serde_json::from_value::<DriveUploadQuery>(valid.clone()).is_ok());
|
||||||
|
for field in ["entry_workspace_id", "size", "sha256", "request_id"] {
|
||||||
|
let mut missing = valid.clone();
|
||||||
|
missing.as_object_mut().unwrap().remove(field);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveUploadQuery>(missing).is_err(),
|
||||||
|
"accepted missing {field}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for size in [DRIVE_FILE_MAX_BYTES, DRIVE_FILE_MAX_BYTES + 1, u32::MAX] {
|
||||||
|
let mut oversized = valid.clone();
|
||||||
|
oversized["size"] = json!(size);
|
||||||
|
let query: DriveUploadQuery = serde_json::from_value(oversized).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
query.size, size,
|
||||||
|
"service must receive oversized declared sizes for typed Limit classification"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(serde_json::from_value::<DriveReadChunkQuery>(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok());
|
||||||
|
let mut invalid = valid;
|
||||||
|
invalid["bytes"] = json!([0, 255]);
|
||||||
|
assert!(serde_json::from_value::<DriveUploadQuery>(invalid).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_grants_reject_command_access_and_unbounded_pages() {
|
||||||
|
let grant = json!({"runtime_id":"r","worker_id":"w","access":"read_write"});
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(
|
||||||
|
serde_json::from_value::<DriveGrantCreateRequest>(grant.clone()).unwrap()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
grant
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveGrantCreateRequest>(
|
||||||
|
json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_value::<DriveGrantCreateRequest>(json!({"runtime_id":"r","worker_id":"w","access":"read_only","workspace_id":"spoofed"})).is_err());
|
||||||
|
for limit in [0, 201, u32::MAX] {
|
||||||
|
assert!(serde_json::from_value::<DriveGrantListQuery>(json!({"limit":limit})).is_err());
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<DriveGrantListQuery>(json!({"limit":200,"after":"7"})).is_ok()
|
||||||
|
);
|
||||||
|
assert!(serde_json::from_value::<DriveGrantListQuery>(json!({"after":"01"})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_errors_have_fixed_safe_messages_and_typed_outcome_classification() {
|
||||||
|
use DriveApiErrorCode::*;
|
||||||
|
for (code, wire, status, message) in [
|
||||||
|
(Denied, "denied", 403, "Drive access denied"),
|
||||||
|
(NotFound, "not_found", 404, "Drive resource not found"),
|
||||||
|
(Conflict, "conflict", 409, "Drive revision or name conflict"),
|
||||||
|
(Invalid, "invalid", 400, "Invalid Drive request"),
|
||||||
|
(Limit, "limit", 413, "Drive request exceeds a limit"),
|
||||||
|
(
|
||||||
|
StorageUnavailable,
|
||||||
|
"storage_unavailable",
|
||||||
|
503,
|
||||||
|
"Drive storage unavailable",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
OutcomeUnknown,
|
||||||
|
"outcome_unknown",
|
||||||
|
503,
|
||||||
|
"Drive mutation outcome unknown",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let error = DriveApiError::new(code);
|
||||||
|
assert_eq!(api_macros::HttpError::status_code(&error), status);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&error).unwrap(),
|
||||||
|
json!({"code":wire,"classification":if code == OutcomeUnknown {"unknown"} else {"not_committed"},"message":message})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for status in [400, 413, 415, 422] {
|
||||||
|
let error = <DriveApiError as api_macros::HttpRequestError>::from_request_rejection(
|
||||||
|
status,
|
||||||
|
"/private/path: SQL failed".into(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
DriveApiError::new(if status == 413 { Limit } else { Invalid })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(serde_json::from_value::<DriveApiError>(json!({"code":"invalid","classification":"not_committed","message":"Invalid Drive request","provider_error":"secret"})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drive_routes_are_workspace_scoped_authenticated_and_binary_is_not_json() {
|
||||||
|
let document = canonical_openapi_document().unwrap();
|
||||||
|
let document: serde_json::Value =
|
||||||
|
serde_json::from_str(&document.to_json().unwrap()).unwrap();
|
||||||
|
let schemas = &document["components"]["schemas"];
|
||||||
|
assert_eq!(
|
||||||
|
schemas["DriveEntry"]["properties"]["size"]["maximum"],
|
||||||
|
DRIVE_FILE_MAX_BYTES
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["DriveReadChunkQuery"]["properties"]["offset"]["maximum"],
|
||||||
|
DRIVE_FILE_MAX_BYTES
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["DriveUploadQuery"]["properties"]["size"]["maximum"],
|
||||||
|
DRIVE_FILE_MAX_BYTES
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
schemas["DriveSearchQuery"]["properties"]["limit"]["maximum"],
|
||||||
|
DRIVE_SEARCH_MAX_LIMIT
|
||||||
|
);
|
||||||
|
for (name, method, suffix, wire_method) in [
|
||||||
|
("drive_root", HttpMethod::Get, "/root", "get"),
|
||||||
|
("drive_metadata", HttpMethod::Get, "/metadata", "get"),
|
||||||
|
("drive_list", HttpMethod::Get, "/list", "get"),
|
||||||
|
("drive_search", HttpMethod::Get, "/search", "get"),
|
||||||
|
("drive_read_text", HttpMethod::Get, "/read-text", "get"),
|
||||||
|
("drive_read_chunk", HttpMethod::Get, "/read-chunk", "get"),
|
||||||
|
("drive_download", HttpMethod::Get, "/download", "get"),
|
||||||
|
("drive_mutate", HttpMethod::Post, "/mutate", "post"),
|
||||||
|
("drive_upload", HttpMethod::Put, "/upload", "put"),
|
||||||
|
(
|
||||||
|
"drive_request_status",
|
||||||
|
HttpMethod::Get,
|
||||||
|
"/requests/{request_id}",
|
||||||
|
"get",
|
||||||
|
),
|
||||||
|
("drive_grant_create", HttpMethod::Post, "/grants", "post"),
|
||||||
|
(
|
||||||
|
"drive_grant_revoke",
|
||||||
|
HttpMethod::Delete,
|
||||||
|
"/grants/{grant_id}",
|
||||||
|
"delete",
|
||||||
|
),
|
||||||
|
("drive_grant_list", HttpMethod::Get, "/grants", "get"),
|
||||||
|
] {
|
||||||
|
let operation = ServerApiMetadata::OPERATIONS
|
||||||
|
.iter()
|
||||||
|
.find(|operation| operation.operation_id == name)
|
||||||
|
.unwrap();
|
||||||
|
let path = format!("/api/w/{{workspace_id}}/drive{suffix}");
|
||||||
|
assert_eq!(operation.method, method);
|
||||||
|
assert_eq!(operation.path, path);
|
||||||
|
let documented = &document["paths"][&path][wire_method];
|
||||||
|
assert_eq!(documented["operationId"], name);
|
||||||
|
assert_eq!(
|
||||||
|
documented["security"],
|
||||||
|
json!([{"bearerAuth":[]},{"browserSession":[]}])
|
||||||
|
);
|
||||||
|
// Server extensions must not become caller-controlled parameters.
|
||||||
|
assert!(
|
||||||
|
documented["parameters"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|parameter| parameter["name"] != "context")
|
||||||
|
);
|
||||||
|
if matches!(name, "drive_read_chunk" | "drive_download") {
|
||||||
|
let content = &documented["responses"]["200"]["content"];
|
||||||
|
assert!(content["application/octet-stream"].is_object());
|
||||||
|
assert!(content["application/json"].is_null());
|
||||||
|
}
|
||||||
|
if name == "drive_download" {
|
||||||
|
let headers = documented["responses"]["200"]["headers"]
|
||||||
|
.as_object()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(headers.len(), 5);
|
||||||
|
for header in [
|
||||||
|
"content-disposition",
|
||||||
|
"cache-control",
|
||||||
|
"etag",
|
||||||
|
"x-content-type-options",
|
||||||
|
"content-security-policy",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
headers[header]["schema"]["$ref"],
|
||||||
|
"#/components/schemas/string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(!headers.contains_key("content-type"));
|
||||||
|
assert!(!headers.contains_key("content-length"));
|
||||||
|
}
|
||||||
|
if name == "drive_upload" {
|
||||||
|
let content = &documented["requestBody"]["content"];
|
||||||
|
assert!(content["application/octet-stream"].is_object());
|
||||||
|
assert!(content["application/json"].is_null());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,9 @@ use api_macros::api;
|
|||||||
pub use api_macros::axum as server_support;
|
pub use api_macros::axum as server_support;
|
||||||
pub use api_macros::reqwest as client_support;
|
pub use api_macros::reqwest as client_support;
|
||||||
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
|
||||||
|
mod drive;
|
||||||
pub mod repository_openapi_typescript;
|
pub mod repository_openapi_typescript;
|
||||||
|
pub use drive::*;
|
||||||
mod workspace_config;
|
mod workspace_config;
|
||||||
use schemars::JsonSchema;
|
use schemars::JsonSchema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -2603,6 +2605,116 @@ pub trait ServerApi {
|
|||||||
#[path] working_directory_id: String,
|
#[path] working_directory_id: String,
|
||||||
#[body] request: WorkingDirectoryRemovalRequest,
|
#[body] request: WorkingDirectoryRemovalRequest,
|
||||||
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>;
|
||||||
|
// Ordinary operations accept member browser sessions or trusted Worker request
|
||||||
|
// context. The service resolves Worker grants; no operation accepts an actor or
|
||||||
|
// grant ID from the caller. Grant management is Workspace-owner-only.
|
||||||
|
#[get("/api/w/{workspace_id}/drive/root", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_root(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
) -> Result<DriveEntry, DriveApiError>;
|
||||||
|
#[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_metadata(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveEntryQuery,
|
||||||
|
) -> Result<DriveEntry, DriveApiError>;
|
||||||
|
#[get("/api/w/{workspace_id}/drive/list", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_list(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveListQuery,
|
||||||
|
) -> Result<DriveListResponse, DriveApiError>;
|
||||||
|
#[get("/api/w/{workspace_id}/drive/search", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_search(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveSearchQuery,
|
||||||
|
) -> Result<DriveListResponse, DriveApiError>;
|
||||||
|
#[get("/api/w/{workspace_id}/drive/read-text", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_read_text(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveReadTextQuery,
|
||||||
|
) -> Result<DriveReadTextResponse, DriveApiError>;
|
||||||
|
#[get("/api/w/{workspace_id}/drive/read-chunk", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_read_chunk(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveReadChunkQuery,
|
||||||
|
) -> Result<BinaryBody, DriveApiError>;
|
||||||
|
// Download safety/cache headers are service-produced typed response metadata;
|
||||||
|
// Content-Type/Content-Length remain controlled by the binary transport.
|
||||||
|
#[get(
|
||||||
|
"/api/w/{workspace_id}/drive/download",
|
||||||
|
responses = [(status = 200, body = BinaryBody, headers = [
|
||||||
|
("content-disposition", String),
|
||||||
|
("cache-control", String),
|
||||||
|
("etag", String),
|
||||||
|
("x-content-type-options", String),
|
||||||
|
("content-security-policy", String)
|
||||||
|
])],
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401,403,404,409,413,503],
|
||||||
|
bearer_auth = true,
|
||||||
|
browser_auth = true
|
||||||
|
)]
|
||||||
|
async fn drive_download(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveDownloadQuery,
|
||||||
|
) -> Result<server_api_responses::DriveDownload, DriveApiError>;
|
||||||
|
#[post("/api/w/{workspace_id}/drive/mutate", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||||
|
async fn drive_mutate(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: DriveMutationRequest,
|
||||||
|
) -> Result<DriveMutationResponse, DriveApiError>;
|
||||||
|
#[put("/api/w/{workspace_id}/drive/upload", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||||
|
async fn drive_upload(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveUploadQuery,
|
||||||
|
#[binary] body: BinaryBody,
|
||||||
|
) -> Result<DriveMutationResponse, DriveApiError>;
|
||||||
|
#[get("/api/w/{workspace_id}/drive/requests/{request_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_request_status(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] request_id: String,
|
||||||
|
) -> Result<DriveRequestStatusResponse, DriveApiError>;
|
||||||
|
#[post("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)]
|
||||||
|
async fn drive_grant_create(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: DriveGrantCreateRequest,
|
||||||
|
) -> Result<DriveGrantResponse, DriveApiError>;
|
||||||
|
#[delete("/api/w/{workspace_id}/drive/grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_grant_revoke(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[path] grant_id: String,
|
||||||
|
) -> Result<DriveGrantResponse, DriveApiError>;
|
||||||
|
#[get("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)]
|
||||||
|
async fn drive_grant_list(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: DriveGrantListQuery,
|
||||||
|
) -> Result<DriveGrantListResponse, DriveApiError>;
|
||||||
|
|
||||||
#[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)]
|
#[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(
|
async fn current_worker_workspace_config_get(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ pub enum Error {
|
|||||||
NotFound,
|
NotFound,
|
||||||
#[error("Drive revision, name, or request identity conflict")]
|
#[error("Drive revision, name, or request identity conflict")]
|
||||||
Conflict,
|
Conflict,
|
||||||
|
#[error("Drive access denied")]
|
||||||
|
Denied,
|
||||||
#[error("Workspace Drive is fenced for deletion")]
|
#[error("Workspace Drive is fenced for deletion")]
|
||||||
Fenced,
|
Fenced,
|
||||||
#[error("Drive storage failure: {0}")]
|
#[error("Drive storage failure: {0}")]
|
||||||
@@ -174,8 +176,11 @@ pub struct CollectionPage {
|
|||||||
pub next_after: Option<String>,
|
pub next_after: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Authorizer = dyn Fn(&Connection, bool) -> Result<()> + Send + Sync;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct Drive {
|
pub struct Drive {
|
||||||
|
authorizer: Option<Arc<Authorizer>>,
|
||||||
database: FeatureDatabase,
|
database: FeatureDatabase,
|
||||||
blobs: Arc<blob::BlobStore>,
|
blobs: Arc<blob::BlobStore>,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
@@ -232,6 +237,7 @@ impl Drive {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
authorizer: None,
|
||||||
database,
|
database,
|
||||||
blobs,
|
blobs,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
@@ -278,8 +284,36 @@ impl Drive {
|
|||||||
})?;
|
})?;
|
||||||
Ok(drive)
|
Ok(drive)
|
||||||
}
|
}
|
||||||
|
/// Bind immutable caller authority to this handle and its clones. The callback
|
||||||
|
/// runs within every client operation's IMMEDIATE transaction, never at bind
|
||||||
|
/// time; attached authority stays locked through blob I/O and publication.
|
||||||
|
/// Host maintenance must use an unbound handle.
|
||||||
|
pub fn with_authorizer<F>(&self, authorizer: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&Connection, bool) -> Result<()> + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
authorizer: Some(Arc::new(authorizer)),
|
||||||
|
..self.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn authorize(&self, connection: &Connection, write: bool) -> Result<()> {
|
||||||
|
match &self.authorizer {
|
||||||
|
Some(authorizer) => authorizer(connection, write),
|
||||||
|
None => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// Early admission check, not a lease: every subsequent operation still
|
||||||
|
/// invokes its authorizer in that operation's own transaction.
|
||||||
|
pub fn check_authorized(&self, write: bool) -> Result<()> {
|
||||||
|
self.database.try_transaction(|tx| {
|
||||||
|
self.authorize(tx, write)?;
|
||||||
|
accepting(tx)
|
||||||
|
})
|
||||||
|
}
|
||||||
pub fn root(&self) -> Result<Node> {
|
pub fn root(&self) -> Result<Node> {
|
||||||
self.database.try_with_connection(|c| {
|
self.database.try_transaction(|c| {
|
||||||
|
self.authorize(c, false)?;
|
||||||
let id = c.query_row(
|
let id = c.query_row(
|
||||||
"SELECT id FROM drive_nodes WHERE parent_id IS NULL",
|
"SELECT id FROM drive_nodes WHERE parent_id IS NULL",
|
||||||
[],
|
[],
|
||||||
@@ -289,7 +323,10 @@ impl Drive {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
pub fn metadata(&self, id: NodeId) -> Result<Node> {
|
pub fn metadata(&self, id: NodeId) -> Result<Node> {
|
||||||
self.database.try_with_connection(|c| node(c, id))
|
self.database.try_transaction(|c| {
|
||||||
|
self.authorize(c, false)?;
|
||||||
|
node(c, id)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keyset pages are ordered by stable ID, not name. Concurrent mutations may
|
/// Keyset pages are ordered by stable ID, not name. Concurrent mutations may
|
||||||
@@ -297,6 +334,7 @@ impl Drive {
|
|||||||
pub fn list(&self, parent: NodeId, after: Option<NodeId>, limit: usize) -> Result<Page> {
|
pub fn list(&self, parent: NodeId, after: Option<NodeId>, limit: usize) -> Result<Page> {
|
||||||
validate_limit(limit, MAX_PAGE)?;
|
validate_limit(limit, MAX_PAGE)?;
|
||||||
self.database.try_transaction(|tx| {
|
self.database.try_transaction(|tx| {
|
||||||
|
self.authorize(tx, false)?;
|
||||||
directory(tx, parent)?;
|
directory(tx, parent)?;
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
"SELECT id FROM drive_nodes WHERE parent_id=?1 AND id>?2 ORDER BY id LIMIT ?3",
|
"SELECT id FROM drive_nodes WHERE parent_id=?1 AND id>?2 ORDER BY id LIMIT ?3",
|
||||||
@@ -320,7 +358,8 @@ impl Drive {
|
|||||||
|
|
||||||
pub fn request_status(&self, request_id: &str) -> Result<RequestStatus> {
|
pub fn request_status(&self, request_id: &str) -> Result<RequestStatus> {
|
||||||
validate_identity(request_id, "request_id", 128)?;
|
validate_identity(request_id, "request_id", 128)?;
|
||||||
self.database.try_with_connection(|c| {
|
self.database.try_transaction(|c| {
|
||||||
|
self.authorize(c, false)?;
|
||||||
let result = c
|
let result = c
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT result_json FROM drive_receipts WHERE request_id=?1",
|
"SELECT result_json FROM drive_receipts WHERE request_id=?1",
|
||||||
@@ -354,6 +393,7 @@ impl Drive {
|
|||||||
.map(|b| format!("{b:02x}"))
|
.map(|b| format!("{b:02x}"))
|
||||||
.collect();
|
.collect();
|
||||||
self.database.try_transaction(|tx| {
|
self.database.try_transaction(|tx| {
|
||||||
|
self.authorize(tx, true)?;
|
||||||
if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1",
|
if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1",
|
||||||
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
|
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
|
||||||
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
|
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
|
||||||
@@ -419,6 +459,7 @@ impl Drive {
|
|||||||
) -> Result<ReadChunk> {
|
) -> Result<ReadChunk> {
|
||||||
validate_limit(length, MAX_READ_BYTES)?;
|
validate_limit(length, MAX_READ_BYTES)?;
|
||||||
self.database.try_transaction(|tx| {
|
self.database.try_transaction(|tx| {
|
||||||
|
self.authorize(tx, false)?;
|
||||||
accepting(tx)?;
|
accepting(tx)?;
|
||||||
let selected = node(tx, id)?;
|
let selected = node(tx, id)?;
|
||||||
if selected.revision != revision {
|
if selected.revision != revision {
|
||||||
@@ -488,6 +529,7 @@ impl Drive {
|
|||||||
validate_identity(query, "query", 256)?;
|
validate_identity(query, "query", 256)?;
|
||||||
validate_limit(budget, MAX_SEARCH_NODES)?;
|
validate_limit(budget, MAX_SEARCH_NODES)?;
|
||||||
self.database.try_transaction(|tx| {
|
self.database.try_transaction(|tx| {
|
||||||
|
self.authorize(tx, false)?;
|
||||||
accepting(tx)?;
|
accepting(tx)?;
|
||||||
let mut stmt = tx.prepare("SELECT id FROM drive_nodes WHERE parent_id IS NOT NULL AND id>?1 ORDER BY id LIMIT ?2")?;
|
let mut stmt = tx.prepare("SELECT id FROM drive_nodes WHERE parent_id IS NOT NULL AND id>?1 ORDER BY id LIMIT ?2")?;
|
||||||
let ids = stmt.query_map(params![after.map_or(0,|v|v.0),budget+1],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
let ids = stmt.query_map(params![after.map_or(0,|v|v.0),budget+1],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
|
||||||
|
|||||||
@@ -299,3 +299,183 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn grant_fixture(t: &tempfile::TempDir) -> (Drive, Connection) {
|
||||||
|
let authority = t.path().join("server.db");
|
||||||
|
let server = Connection::open(&authority).unwrap();
|
||||||
|
feature_storage::configure_connection(&server).unwrap();
|
||||||
|
server
|
||||||
|
.execute_batch(
|
||||||
|
"CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY,state TEXT NOT NULL);
|
||||||
|
INSERT INTO workspaces VALUES ('ws','active');
|
||||||
|
CREATE TABLE grants(write_access INTEGER NOT NULL,revoked INTEGER NOT NULL);
|
||||||
|
INSERT INTO grants VALUES (1,0);",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let storage = feature_storage::FeatureStorage::new(t.path().join("metadata"))
|
||||||
|
.workspace("ws")
|
||||||
|
.unwrap();
|
||||||
|
let registration = Drive::register(&storage).unwrap();
|
||||||
|
(
|
||||||
|
Drive::open_with_workspace_authority(
|
||||||
|
&storage,
|
||||||
|
®istration,
|
||||||
|
&t.path().join("blobs"),
|
||||||
|
&authority,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
server,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
fn grant_authorizer(conn: &Connection, write: bool) -> Result<()> {
|
||||||
|
assert!(
|
||||||
|
!conn.is_autocommit(),
|
||||||
|
"authority must be checked in the operation transaction"
|
||||||
|
);
|
||||||
|
let allowed: bool = conn.query_row(
|
||||||
|
"SELECT revoked=0 AND (?1=0 OR write_access=1) FROM yoi_workspace_authority.grants",
|
||||||
|
[write],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
if allowed { Ok(()) } else { Err(Error::Denied) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
let (raw, server) = grant_fixture(&t);
|
||||||
|
let root = raw.root().unwrap();
|
||||||
|
let bound = raw.with_authorizer(grant_authorizer);
|
||||||
|
let mutation = create(root.id, "file");
|
||||||
|
let result = bound.mutate("request", "worker", mutation.clone()).unwrap();
|
||||||
|
bound.check_authorized(true).unwrap();
|
||||||
|
server
|
||||||
|
.execute("UPDATE grants SET write_access=0", [])
|
||||||
|
.unwrap();
|
||||||
|
assert!(bound.check_authorized(false).is_ok());
|
||||||
|
assert!(matches!(bound.check_authorized(true), Err(Error::Denied)));
|
||||||
|
assert!(bound.root().is_ok());
|
||||||
|
assert!(bound.metadata(result.node.id).is_ok());
|
||||||
|
assert!(bound.list(root.id, None, 10).is_ok());
|
||||||
|
assert!(bound.search("file", true, None, 10).is_ok());
|
||||||
|
assert!(
|
||||||
|
bound
|
||||||
|
.read(result.node.id, result.node.revision, 0, 100)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
bound
|
||||||
|
.read_text(result.node.id, result.node.revision, 0, 100)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
assert!(bound.request_status("request").is_ok());
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
bound.mutate("request", "worker", mutation.clone()),
|
||||||
|
Err(Error::Denied)
|
||||||
|
),
|
||||||
|
"read-only receipt replay is a write request"
|
||||||
|
);
|
||||||
|
let clone = bound.clone();
|
||||||
|
server.execute("UPDATE grants SET revoked=1", []).unwrap();
|
||||||
|
assert!(matches!(clone.check_authorized(false), Err(Error::Denied)));
|
||||||
|
assert!(matches!(clone.check_authorized(true), Err(Error::Denied)));
|
||||||
|
assert!(matches!(clone.root(), Err(Error::Denied)));
|
||||||
|
assert!(matches!(clone.metadata(result.node.id), Err(Error::Denied)));
|
||||||
|
assert!(matches!(clone.list(root.id, None, 10), Err(Error::Denied)));
|
||||||
|
assert!(matches!(
|
||||||
|
clone.search("file", true, None, 10),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
clone.read(result.node.id, result.node.revision, 0, 100),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
clone.read_text(result.node.id, result.node.revision, 0, 100),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
clone.request_status("request"),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
clone.mutate("request", "worker", mutation),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
clone.mutate("denied", "worker", create(root.id, "denied")),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
raw.blobs.list(None, 10).unwrap().len(),
|
||||||
|
1,
|
||||||
|
"denied write must not publish a blob"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
raw.request_status("denied").unwrap(),
|
||||||
|
RequestStatus::Uncommitted
|
||||||
|
);
|
||||||
|
assert_eq!(raw.list(root.id, None, 10).unwrap().nodes.len(), 1);
|
||||||
|
raw.collect(None, 10).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoke_serializes_with_publication_and_cached_handle_denies_after_revoke_commit() {
|
||||||
|
use std::sync::{Mutex, mpsc};
|
||||||
|
use std::time::Duration;
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
let (raw, mut server) = grant_fixture(&t);
|
||||||
|
let root = raw.root().unwrap();
|
||||||
|
let (entered_tx, entered_rx) = mpsc::channel();
|
||||||
|
let (release_tx, release_rx) = mpsc::channel();
|
||||||
|
let release_rx = Mutex::new(release_rx);
|
||||||
|
let publishing = raw.with_authorizer(move |conn, write| {
|
||||||
|
grant_authorizer(conn, write)?;
|
||||||
|
entered_tx.send(()).unwrap();
|
||||||
|
release_rx
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.unwrap();
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
let cached = raw.with_authorizer(grant_authorizer);
|
||||||
|
let writer = std::thread::spawn(move || {
|
||||||
|
publishing.mutate("publishing", "worker", create(root.id, "published"))
|
||||||
|
});
|
||||||
|
entered_rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||||
|
server.busy_timeout(Duration::ZERO).unwrap();
|
||||||
|
assert!(
|
||||||
|
matches!(server.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate),
|
||||||
|
Err(rusqlite::Error::SqliteFailure(e,_)) if e.code==rusqlite::ErrorCode::DatabaseBusy),
|
||||||
|
"revoke cannot acquire authority while mutation publishes"
|
||||||
|
);
|
||||||
|
release_tx.send(()).unwrap();
|
||||||
|
let published = writer.join().unwrap().unwrap();
|
||||||
|
let revoke = server
|
||||||
|
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
|
||||||
|
.unwrap();
|
||||||
|
revoke.execute("UPDATE grants SET revoked=1", []).unwrap();
|
||||||
|
revoke.commit().unwrap();
|
||||||
|
assert_eq!(raw.metadata(published.node.id).unwrap(), published.node);
|
||||||
|
assert_eq!(
|
||||||
|
raw.request_status("publishing").unwrap(),
|
||||||
|
RequestStatus::Committed {
|
||||||
|
result: published.clone()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
cached.request_status("publishing"),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
cached.mutate("publishing", "worker", create(root.id, "published")),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
cached.mutate("after-revoke", "worker", create(root.id, "denied")),
|
||||||
|
Err(Error::Denied)
|
||||||
|
));
|
||||||
|
assert_eq!(raw.blobs.list(None, 10).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ name = "yoi-server"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
api-macros = { workspace = true, features = ["axum"] }
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
axum = { workspace = true, features = ["ws"] }
|
axum = { workspace = true, features = ["ws"] }
|
||||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||||
|
|||||||
@@ -1629,3 +1629,28 @@ CREATE TABLE workspace_config_grants (
|
|||||||
);
|
);
|
||||||
CREATE UNIQUE INDEX workspace_config_grants_active_worker
|
CREATE UNIQUE INDEX workspace_config_grants_active_worker
|
||||||
ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0;
|
ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0;
|
||||||
|
|
||||||
|
CREATE TABLE workspace_drive_grants (
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)),
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
revoked_by TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL)
|
||||||
|
OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX workspace_drive_grants_active_worker
|
||||||
|
ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0;
|
||||||
|
-- Retain grant audit across Worker deletion, but never carry its authority into
|
||||||
|
-- a later lifetime reusing the same Runtime/Worker identity.
|
||||||
|
CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN
|
||||||
|
UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
|
||||||
|
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
|
||||||
|
AND worker_id=OLD.worker_id AND revoked=0;
|
||||||
|
END;
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ pub(crate) use worker_operations::{
|
|||||||
WorkerOperationContext, WorkspaceWorker, WorkspaceWorkerMethodSender,
|
WorkerOperationContext, WorkspaceWorker, WorkspaceWorkerMethodSender,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[path = "server_drive.rs"]
|
||||||
|
mod drive;
|
||||||
|
#[path = "server_drive_grants.rs"]
|
||||||
|
mod drive_grants;
|
||||||
#[path = "server_workspace_config.rs"]
|
#[path = "server_workspace_config.rs"]
|
||||||
mod workspace_config;
|
mod workspace_config;
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
@@ -2601,6 +2605,9 @@ fn signed_request_target(uri: &Uri) -> &str {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response {
|
fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response {
|
||||||
|
if path.starts_with("/api/w/") && path.contains("/drive/") {
|
||||||
|
return (status, Json(drive::failure(status.as_u16()))).into_response();
|
||||||
|
}
|
||||||
if path == "/api/repositories"
|
if path == "/api/repositories"
|
||||||
|| path.starts_with("/api/repositories/")
|
|| path.starts_with("/api/repositories/")
|
||||||
|| (path.starts_with("/api/w/") && path.contains("/repositories"))
|
|| (path.starts_with("/api/w/") && path.contains("/repositories"))
|
||||||
@@ -6303,6 +6310,7 @@ fn generated_workspace_catalog_contract_router(service: ServerApiContractService
|
|||||||
fn generated_workspace_contract_router(service: ServerApiContractService) -> Router {
|
fn generated_workspace_contract_router(service: ServerApiContractService) -> Router {
|
||||||
let service = Arc::new(service);
|
let service = Arc::new(service);
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.merge(drive::router(service.clone()))
|
||||||
.merge(server_api::server_api_axum::workspace_current(
|
.merge(server_api::server_api_axum::workspace_current(
|
||||||
service.clone(),
|
service.clone(),
|
||||||
))
|
))
|
||||||
@@ -11112,6 +11120,128 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.map_err(ApiError::into_repository_api_error)
|
.map_err(ApiError::into_repository_api_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn drive_root(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
) -> std::result::Result<server_api::DriveEntry, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::root(api, &context, &workspace_id).await
|
||||||
|
}
|
||||||
|
async fn drive_metadata(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveEntryQuery,
|
||||||
|
) -> std::result::Result<server_api::DriveEntry, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::metadata(api, &context, &workspace_id, query).await
|
||||||
|
}
|
||||||
|
async fn drive_list(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveListQuery,
|
||||||
|
) -> std::result::Result<server_api::DriveListResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::list(api, &context, &workspace_id, query).await
|
||||||
|
}
|
||||||
|
async fn drive_search(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveSearchQuery,
|
||||||
|
) -> std::result::Result<server_api::DriveListResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::search(api, &context, &workspace_id, query).await
|
||||||
|
}
|
||||||
|
async fn drive_read_text(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveReadTextQuery,
|
||||||
|
) -> std::result::Result<server_api::DriveReadTextResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::read_text(api, &context, &workspace_id, query).await
|
||||||
|
}
|
||||||
|
async fn drive_read_chunk(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveReadChunkQuery,
|
||||||
|
) -> std::result::Result<server_api::BinaryBody, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::read_chunk(api, &context, &workspace_id, query).await
|
||||||
|
}
|
||||||
|
async fn drive_download(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveDownloadQuery,
|
||||||
|
) -> std::result::Result<
|
||||||
|
server_api::server_api_responses::DriveDownload,
|
||||||
|
server_api::DriveApiError,
|
||||||
|
> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::download(api, &context, &workspace_id, query).await
|
||||||
|
}
|
||||||
|
async fn drive_mutate(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
request: server_api::DriveMutationRequest,
|
||||||
|
) -> std::result::Result<server_api::DriveMutationResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::mutate(api, &context, &workspace_id, request).await
|
||||||
|
}
|
||||||
|
async fn drive_upload(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveUploadQuery,
|
||||||
|
body: server_api::BinaryBody,
|
||||||
|
) -> std::result::Result<server_api::DriveMutationResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::upload(api, &context, &workspace_id, query, body).await
|
||||||
|
}
|
||||||
|
async fn drive_request_status(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
request_id: String,
|
||||||
|
) -> std::result::Result<server_api::DriveRequestStatusResponse, server_api::DriveApiError>
|
||||||
|
{
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive::request_status(api, &context, &workspace_id, request_id).await
|
||||||
|
}
|
||||||
|
async fn drive_grant_create(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
request: server_api::DriveGrantCreateRequest,
|
||||||
|
) -> std::result::Result<server_api::DriveGrantResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive_grants::create_grant(api, &context, &workspace_id, request).await
|
||||||
|
}
|
||||||
|
async fn drive_grant_revoke(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
grant_id: String,
|
||||||
|
) -> std::result::Result<server_api::DriveGrantResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive_grants::revoke_grant(api, &context, &workspace_id, &grant_id).await
|
||||||
|
}
|
||||||
|
async fn drive_grant_list(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::DriveGrantListQuery,
|
||||||
|
) -> std::result::Result<server_api::DriveGrantListResponse, server_api::DriveApiError> {
|
||||||
|
let api = self.workspace_api().map_err(|_| drive::failure(404))?;
|
||||||
|
drive_grants::list_grants(api, &context, &workspace_id, query).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn current_worker_workspace_config_get(
|
async fn current_worker_workspace_config_get(
|
||||||
&self,
|
&self,
|
||||||
context: server_api::ServerRequestContext,
|
context: server_api::ServerRequestContext,
|
||||||
@@ -32591,6 +32721,7 @@ impl IntoResponse for ApiError {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
mod auth_logging_tests;
|
mod auth_logging_tests;
|
||||||
|
mod drive_api_tests;
|
||||||
mod drive_tests;
|
mod drive_tests;
|
||||||
mod subject_spawn_tests;
|
mod subject_spawn_tests;
|
||||||
mod subjektiv_jobs_tests;
|
mod subjektiv_jobs_tests;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,736 @@
|
|||||||
|
//! Authorized Workspace document API. Storage, CAS and receipts stay in workspace-drive.
|
||||||
|
//! HTTP upload consumes frames with a hard bound; DB publication occurs only after validation.
|
||||||
|
use super::*;
|
||||||
|
use futures::StreamExt;
|
||||||
|
use server_api::*;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use workspace_drive::{Drive, Mutation, NodeId, Revision};
|
||||||
|
|
||||||
|
fn mutation_actor(context: &ServerRequestContext) -> DriveResult<String> {
|
||||||
|
if let Some(source) = &context.runtime_source {
|
||||||
|
let worker = source.worker_id.as_ref().ok_or_else(|| failure(403))?;
|
||||||
|
Ok(format!(
|
||||||
|
"worker:{}",
|
||||||
|
serde_json::to_string(&(&source.runtime_id, worker)).map_err(|_| failure(400))?
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Ok(format!(
|
||||||
|
"user:{}",
|
||||||
|
context.actor.as_ref().ok_or_else(|| failure(403))?.user_id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriveResult<T> = std::result::Result<T, DriveApiError>;
|
||||||
|
const TRANSFER_CHUNK: usize = 64 * 1024;
|
||||||
|
|
||||||
|
pub(super) fn failure(status: u16) -> DriveApiError {
|
||||||
|
let code = match status {
|
||||||
|
401 | 403 => DriveApiErrorCode::Denied,
|
||||||
|
404 => DriveApiErrorCode::NotFound,
|
||||||
|
409 => DriveApiErrorCode::Conflict,
|
||||||
|
413 => DriveApiErrorCode::Limit,
|
||||||
|
500..=599 => DriveApiErrorCode::StorageUnavailable,
|
||||||
|
_ => DriveApiErrorCode::Invalid,
|
||||||
|
};
|
||||||
|
DriveApiError::new(code)
|
||||||
|
}
|
||||||
|
fn domain_error(error: workspace_drive::Error, mutation: bool) -> DriveApiError {
|
||||||
|
use workspace_drive::Error;
|
||||||
|
match error {
|
||||||
|
Error::Denied => failure(403),
|
||||||
|
Error::NotFound => failure(404),
|
||||||
|
Error::Conflict | Error::Fenced => failure(409),
|
||||||
|
Error::Invalid(_) => failure(400),
|
||||||
|
Error::Blob(_) => failure(503),
|
||||||
|
_ if mutation => DriveApiError::new(DriveApiErrorCode::OutcomeUnknown),
|
||||||
|
_ => failure(503),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn blocking<T: Send + 'static>(
|
||||||
|
mutation: bool,
|
||||||
|
action: impl FnOnce() -> workspace_drive::Result<T> + Send + 'static,
|
||||||
|
) -> DriveResult<T> {
|
||||||
|
tokio::task::spawn_blocking(action)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
if mutation {
|
||||||
|
DriveApiError::new(DriveApiErrorCode::OutcomeUnknown)
|
||||||
|
} else {
|
||||||
|
failure(503)
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
.map_err(|error| domain_error(error, mutation))
|
||||||
|
}
|
||||||
|
fn node_id(value: &str) -> DriveResult<NodeId> {
|
||||||
|
value.to_owned().try_into().map_err(|_| failure(400))
|
||||||
|
}
|
||||||
|
fn revision(value: &str) -> DriveResult<Revision> {
|
||||||
|
value.to_owned().try_into().map_err(|_| failure(400))
|
||||||
|
}
|
||||||
|
fn entry(workspace: &str, reference: &DriveEntryRef) -> DriveResult<NodeId> {
|
||||||
|
if reference.workspace_id != workspace {
|
||||||
|
return Err(failure(403));
|
||||||
|
}
|
||||||
|
node_id(&reference.node_id)
|
||||||
|
}
|
||||||
|
fn scoped_id(workspace: &str, entry_workspace: &str, id: &str) -> DriveResult<NodeId> {
|
||||||
|
if workspace != entry_workspace {
|
||||||
|
return Err(failure(403));
|
||||||
|
}
|
||||||
|
node_id(id)
|
||||||
|
}
|
||||||
|
fn safe_content_type(value: &str) -> DriveResult<String> {
|
||||||
|
// Deliberately accept a bare MIME type only, not attacker-controlled header parameters.
|
||||||
|
if value.len() > 128
|
||||||
|
|| !value.contains('/')
|
||||||
|
|| value.matches('/').count() != 1
|
||||||
|
|| value.split('/').any(str::is_empty)
|
||||||
|
|| !value
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_alphanumeric() || b"!#$&^_.+-/".contains(&b))
|
||||||
|
{
|
||||||
|
return Err(failure(400));
|
||||||
|
}
|
||||||
|
Ok(value.to_ascii_lowercase())
|
||||||
|
}
|
||||||
|
fn disposition(name: &str) -> String {
|
||||||
|
// ASCII fallback plus RFC 5987 UTF-8 filename. Never place raw names in headers.
|
||||||
|
let encoded: String = name
|
||||||
|
.as_bytes()
|
||||||
|
.iter()
|
||||||
|
.map(|b| {
|
||||||
|
if b.is_ascii_alphanumeric() || b"-._".contains(b) {
|
||||||
|
(*b as char).to_string()
|
||||||
|
} else {
|
||||||
|
format!("%{b:02X}")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format!("attachment; filename=\"download\"; filename*=UTF-8''{encoded}")
|
||||||
|
}
|
||||||
|
fn error_response(error: DriveApiError) -> Response {
|
||||||
|
let status = match error.code {
|
||||||
|
DriveApiErrorCode::Denied => 403,
|
||||||
|
DriveApiErrorCode::NotFound => 404,
|
||||||
|
DriveApiErrorCode::Conflict => 409,
|
||||||
|
DriveApiErrorCode::Limit => 413,
|
||||||
|
DriveApiErrorCode::Invalid => 400,
|
||||||
|
DriveApiErrorCode::StorageUnavailable | DriveApiErrorCode::OutcomeUnknown => 503,
|
||||||
|
};
|
||||||
|
(StatusCode::from_u16(status).unwrap(), Json(error)).into_response()
|
||||||
|
}
|
||||||
|
// No raw storage diagnostic is ever placed in a public error or stream failure.
|
||||||
|
fn stream_error() -> std::io::Error {
|
||||||
|
std::io::Error::other("Drive transfer unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream HTTP frames while enforcing both announced and observed limits. Storage currently
|
||||||
|
/// accepts a bounded whole-file Vec; this is binary buffering, never JSON/base64 expansion.
|
||||||
|
async fn receive(body: axum::body::Body, declared: u64, digest: &str) -> DriveResult<Vec<u8>> {
|
||||||
|
if declared > workspace_drive::MAX_FILE_BYTES as u64 {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
if digest.len() != 64
|
||||||
|
|| !digest
|
||||||
|
.bytes()
|
||||||
|
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||||
|
{
|
||||||
|
return Err(failure(400));
|
||||||
|
}
|
||||||
|
let mut stream = body.into_data_stream();
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|_| failure(400))?;
|
||||||
|
let size = bytes
|
||||||
|
.len()
|
||||||
|
.checked_add(chunk.len())
|
||||||
|
.ok_or_else(|| failure(413))?;
|
||||||
|
if size > workspace_drive::MAX_FILE_BYTES {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
if size > declared as usize {
|
||||||
|
return Err(failure(400));
|
||||||
|
}
|
||||||
|
hasher.update(&chunk);
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
if bytes.len() != declared as usize
|
||||||
|
|| hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>()
|
||||||
|
!= digest
|
||||||
|
{
|
||||||
|
return Err(failure(400));
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_node(node: workspace_drive::Node) -> DriveEntry {
|
||||||
|
let workspace_id = node.workspace_id.clone();
|
||||||
|
let time = chrono::DateTime::from_timestamp_millis(node.updated_at_ms)
|
||||||
|
.map(|t| t.to_rfc3339())
|
||||||
|
.unwrap_or_default();
|
||||||
|
DriveEntry {
|
||||||
|
entry: DriveEntryRef {
|
||||||
|
workspace_id: workspace_id.clone(),
|
||||||
|
node_id: node.id.get().to_string(),
|
||||||
|
},
|
||||||
|
parent: node.parent_id.map(|id| DriveEntryRef {
|
||||||
|
workspace_id,
|
||||||
|
node_id: id.get().to_string(),
|
||||||
|
}),
|
||||||
|
name: node.name,
|
||||||
|
kind: match node.kind {
|
||||||
|
workspace_drive::Kind::File => DriveEntryKind::File,
|
||||||
|
workspace_drive::Kind::Directory => DriveEntryKind::Folder,
|
||||||
|
},
|
||||||
|
revision: node.revision.get().to_string(),
|
||||||
|
size: (node.kind == workspace_drive::Kind::File).then_some(node.size as u32),
|
||||||
|
content_type: node.content_type,
|
||||||
|
updated_by: node.updated_by,
|
||||||
|
updated_at: time,
|
||||||
|
latest_url: format!(
|
||||||
|
"/api/w/{}/drive/{}?entry_workspace_id={}&id={}",
|
||||||
|
node.workspace_id,
|
||||||
|
if node.kind == workspace_drive::Kind::File {
|
||||||
|
"download"
|
||||||
|
} else {
|
||||||
|
"metadata"
|
||||||
|
},
|
||||||
|
node.workspace_id,
|
||||||
|
node.id.get()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn cursor(workspace: &str, id: Option<NodeId>) -> Option<String> {
|
||||||
|
id.map(|id| format!("{workspace}:{}", id.get()))
|
||||||
|
}
|
||||||
|
fn after(workspace: &str, value: Option<String>) -> DriveResult<Option<NodeId>> {
|
||||||
|
value
|
||||||
|
.map(|value| {
|
||||||
|
let (scope, id) = value.rsplit_once(':').ok_or_else(|| failure(400))?;
|
||||||
|
scoped_id(workspace, scope, id)
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
fn limit(value: Option<u32>, maximum: usize) -> DriveResult<usize> {
|
||||||
|
let limit = value.unwrap_or(maximum as u32) as usize;
|
||||||
|
if limit == 0 || limit > maximum {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
Ok(limit)
|
||||||
|
}
|
||||||
|
pub(super) async fn root(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
) -> DriveResult<DriveEntry> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
blocking(false, move || drive.root()).await.map(public_node)
|
||||||
|
}
|
||||||
|
pub(super) async fn metadata(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveEntryQuery,
|
||||||
|
) -> DriveResult<DriveEntry> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||||
|
blocking(false, move || drive.metadata(id))
|
||||||
|
.await
|
||||||
|
.map(public_node)
|
||||||
|
}
|
||||||
|
pub(super) async fn list(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveListQuery,
|
||||||
|
) -> DriveResult<DriveListResponse> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||||
|
let after = after(workspace, query.after)?;
|
||||||
|
let limit = limit(query.limit, workspace_drive::MAX_PAGE)?;
|
||||||
|
let page = blocking(false, move || drive.list(id, after, limit)).await?;
|
||||||
|
Ok(DriveListResponse {
|
||||||
|
entries: page.nodes.into_iter().map(public_node).collect(),
|
||||||
|
next_after: cursor(workspace, page.next_after),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(super) async fn search(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveSearchQuery,
|
||||||
|
) -> DriveResult<DriveListResponse> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
let after = after(workspace, query.after)?;
|
||||||
|
let limit = limit(query.limit, workspace_drive::MAX_SEARCH_NODES)?;
|
||||||
|
let page = blocking(false, move || {
|
||||||
|
drive.search(&query.query, query.include_text, after, limit)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(DriveListResponse {
|
||||||
|
entries: page.nodes.into_iter().map(public_node).collect(),
|
||||||
|
next_after: cursor(workspace, page.next_after),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(super) async fn read_text(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveReadTextQuery,
|
||||||
|
) -> DriveResult<DriveReadTextResponse> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||||
|
let max = query.max_bytes as usize;
|
||||||
|
if max == 0 || max > DRIVE_TEXT_MAX_BYTES {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
let (node, chunk) = blocking(false, move || {
|
||||||
|
let node = drive.metadata(id)?;
|
||||||
|
let chunk = drive.read(id, node.revision, 0, max)?;
|
||||||
|
Ok((node, chunk))
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let text = match std::str::from_utf8(&chunk.bytes) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err(error) if !chunk.eof && error.error_len().is_none() => {
|
||||||
|
std::str::from_utf8(&chunk.bytes[..error.valid_up_to()]).map_err(|_| failure(400))?
|
||||||
|
}
|
||||||
|
Err(_) => return Err(failure(400)),
|
||||||
|
};
|
||||||
|
Ok(DriveReadTextResponse {
|
||||||
|
entry: public_node(node),
|
||||||
|
text: text.into(),
|
||||||
|
truncated: !chunk.eof,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub(super) async fn read_chunk(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveReadChunkQuery,
|
||||||
|
) -> DriveResult<BinaryBody> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||||
|
let revision = revision(&query.expected_revision)?;
|
||||||
|
if query.length == 0 || query.length > DRIVE_CHUNK_MAX_BYTES {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
let chunk = blocking(false, move || {
|
||||||
|
drive.read(id, revision, query.offset.into(), query.length as usize)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(chunk.bytes.into())
|
||||||
|
}
|
||||||
|
fn text_bytes(text: String) -> DriveResult<Vec<u8>> {
|
||||||
|
if text.len() > DRIVE_TEXT_MAX_BYTES {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
Ok(text.into_bytes())
|
||||||
|
}
|
||||||
|
fn mutation(workspace: &str, mutation: DriveMutation) -> DriveResult<Mutation> {
|
||||||
|
Ok(match mutation {
|
||||||
|
DriveMutation::CreateFolder { parent, name } => Mutation::CreateFolder {
|
||||||
|
parent: entry(workspace, &parent)?,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
DriveMutation::CreateText {
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
text,
|
||||||
|
content_type,
|
||||||
|
} => Mutation::CreateFile {
|
||||||
|
parent: entry(workspace, &parent)?,
|
||||||
|
name,
|
||||||
|
content_type: safe_content_type(&content_type)?,
|
||||||
|
bytes: text_bytes(text)?,
|
||||||
|
},
|
||||||
|
DriveMutation::UpdateText {
|
||||||
|
id,
|
||||||
|
expected_revision,
|
||||||
|
text,
|
||||||
|
content_type,
|
||||||
|
} => Mutation::Update {
|
||||||
|
id: entry(workspace, &id)?,
|
||||||
|
expected_revision: revision(&expected_revision)?,
|
||||||
|
content_type: safe_content_type(&content_type)?,
|
||||||
|
bytes: text_bytes(text)?,
|
||||||
|
},
|
||||||
|
DriveMutation::Relocate {
|
||||||
|
id,
|
||||||
|
expected_revision,
|
||||||
|
parent,
|
||||||
|
name,
|
||||||
|
} => Mutation::Relocate {
|
||||||
|
id: entry(workspace, &id)?,
|
||||||
|
expected_revision: revision(&expected_revision)?,
|
||||||
|
parent: entry(workspace, &parent)?,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
DriveMutation::Delete {
|
||||||
|
id,
|
||||||
|
expected_revision,
|
||||||
|
} => Mutation::Delete {
|
||||||
|
id: entry(workspace, &id)?,
|
||||||
|
expected_revision: revision(&expected_revision)?,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn result(request_id: String, result: workspace_drive::MutationResult) -> DriveMutationResponse {
|
||||||
|
DriveMutationResponse {
|
||||||
|
request_id,
|
||||||
|
entry: (!result.deleted).then(|| public_node(result.node)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) async fn mutate(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
request: DriveMutationRequest,
|
||||||
|
) -> DriveResult<DriveMutationResponse> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, true).await?;
|
||||||
|
let actor = mutation_actor(context)?;
|
||||||
|
let operation = mutation(workspace, request.mutation)?;
|
||||||
|
let request_id = request.request_id.clone();
|
||||||
|
let mutated = blocking(true, move || {
|
||||||
|
drive.mutate(&request.request_id, &actor, operation)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(result(request_id, mutated))
|
||||||
|
}
|
||||||
|
fn upload_mutation(
|
||||||
|
workspace: &str,
|
||||||
|
query: &DriveUploadQuery,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) -> DriveResult<Mutation> {
|
||||||
|
if query.entry_workspace_id != workspace {
|
||||||
|
return Err(failure(403));
|
||||||
|
}
|
||||||
|
let content_type = safe_content_type(&query.content_type)?;
|
||||||
|
match query.operation {
|
||||||
|
DriveUploadOperation::Create if query.id.is_none() && query.expected_revision.is_none() => {
|
||||||
|
Ok(Mutation::CreateFile {
|
||||||
|
parent: node_id(query.parent_id.as_deref().ok_or_else(|| failure(400))?)?,
|
||||||
|
name: query.name.clone().ok_or_else(|| failure(400))?,
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
DriveUploadOperation::Update if query.parent_id.is_none() && query.name.is_none() => {
|
||||||
|
Ok(Mutation::Update {
|
||||||
|
id: node_id(query.id.as_deref().ok_or_else(|| failure(400))?)?,
|
||||||
|
expected_revision: revision(
|
||||||
|
query
|
||||||
|
.expected_revision
|
||||||
|
.as_deref()
|
||||||
|
.ok_or_else(|| failure(400))?,
|
||||||
|
)?,
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(failure(400)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) async fn upload(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveUploadQuery,
|
||||||
|
body: BinaryBody,
|
||||||
|
) -> DriveResult<DriveMutationResponse> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, true).await?;
|
||||||
|
let actor = mutation_actor(context)?;
|
||||||
|
let bytes = receive(
|
||||||
|
axum::body::Body::from(body.into_bytes()),
|
||||||
|
query.size.into(),
|
||||||
|
&query.sha256,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
publish_upload(drive, actor, workspace, query, bytes).await
|
||||||
|
}
|
||||||
|
async fn publish_upload(
|
||||||
|
drive: Drive,
|
||||||
|
actor: String,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveUploadQuery,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) -> DriveResult<DriveMutationResponse> {
|
||||||
|
let mutation = upload_mutation(workspace, &query, bytes)?;
|
||||||
|
let request_id = query.request_id.clone();
|
||||||
|
let mutated = blocking(true, move || {
|
||||||
|
drive.mutate(&query.request_id, &actor, mutation)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(result(request_id, mutated))
|
||||||
|
}
|
||||||
|
pub(super) async fn request_status(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
request_id: String,
|
||||||
|
) -> DriveResult<DriveRequestStatusResponse> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
let id = request_id.clone();
|
||||||
|
let status = blocking(false, move || drive.request_status(&id)).await?;
|
||||||
|
Ok(match status {
|
||||||
|
workspace_drive::RequestStatus::Uncommitted => DriveRequestStatusResponse {
|
||||||
|
request_id,
|
||||||
|
state: DriveRequestState::Uncommitted,
|
||||||
|
response: None,
|
||||||
|
},
|
||||||
|
workspace_drive::RequestStatus::Committed { result: mutation } => {
|
||||||
|
DriveRequestStatusResponse {
|
||||||
|
request_id: request_id.clone(),
|
||||||
|
state: DriveRequestState::Committed,
|
||||||
|
response: Some(result(request_id, mutation)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn select_download(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveDownloadQuery,
|
||||||
|
) -> DriveResult<(Drive, workspace_drive::Node)> {
|
||||||
|
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||||
|
let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?;
|
||||||
|
let for_node = drive.clone();
|
||||||
|
let node = blocking(false, move || for_node.metadata(id)).await?;
|
||||||
|
if node.kind != workspace_drive::Kind::File {
|
||||||
|
return Err(failure(400));
|
||||||
|
}
|
||||||
|
if query
|
||||||
|
.expected_revision
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|v| v != node.revision.get().to_string())
|
||||||
|
{
|
||||||
|
return Err(failure(409));
|
||||||
|
}
|
||||||
|
safe_content_type(node.content_type.as_deref().unwrap_or("")).map_err(|_| failure(503))?;
|
||||||
|
Ok((drive, node))
|
||||||
|
}
|
||||||
|
pub(super) async fn download(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace: &str,
|
||||||
|
query: DriveDownloadQuery,
|
||||||
|
) -> DriveResult<server_api::server_api_responses::DriveDownload> {
|
||||||
|
let (drive, node) = select_download(api, context, workspace, query).await?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
let mut offset = 0;
|
||||||
|
loop {
|
||||||
|
let copy = drive.clone();
|
||||||
|
let chunk = blocking(false, move || {
|
||||||
|
copy.read(node.id, node.revision, offset, TRANSFER_CHUNK)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
offset += chunk.bytes.len() as u64;
|
||||||
|
bytes.extend_from_slice(&chunk.bytes);
|
||||||
|
if chunk.eof {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(server_api::server_api_responses::DriveDownload::Status200 {
|
||||||
|
body: bytes.into(),
|
||||||
|
header_content_disposition: disposition(&node.name),
|
||||||
|
header_cache_control: "private, no-store".into(),
|
||||||
|
header_etag: validator(&node),
|
||||||
|
header_x_content_type_options: "nosniff".into(),
|
||||||
|
header_content_security_policy: "sandbox; default-src 'none'".into(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn validator(node: &workspace_drive::Node) -> String {
|
||||||
|
format!(
|
||||||
|
"\"drive-{}-{}-{}\"",
|
||||||
|
node.workspace_id,
|
||||||
|
node.id.get(),
|
||||||
|
node.revision.get()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.merge(streaming_router(service.clone()))
|
||||||
|
.merge(server_api::server_api_axum::drive_root(service.clone()))
|
||||||
|
.merge(server_api::server_api_axum::drive_metadata(service.clone()))
|
||||||
|
.merge(server_api::server_api_axum::drive_list(service.clone()))
|
||||||
|
.merge(server_api::server_api_axum::drive_search(service.clone()))
|
||||||
|
.merge(server_api::server_api_axum::drive_read_text(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::drive_read_chunk(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::drive_mutate(service.clone()))
|
||||||
|
.merge(server_api::server_api_axum::drive_request_status(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::drive_grant_create(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::drive_grant_revoke(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.merge(server_api::server_api_axum::drive_grant_list(
|
||||||
|
service.clone(),
|
||||||
|
))
|
||||||
|
.layer(DefaultBodyLimit::max(512 * 1024))
|
||||||
|
.layer(middleware::from_fn(normalize_rejections))
|
||||||
|
}
|
||||||
|
async fn normalize_rejections(request: Request, next: Next) -> Response {
|
||||||
|
let response = next.run(request).await;
|
||||||
|
if matches!(response.status().as_u16(), 415 | 422) {
|
||||||
|
error_response(failure(400))
|
||||||
|
} else if matches!(response.status().as_u16(), 400 | 413)
|
||||||
|
&& response
|
||||||
|
.headers()
|
||||||
|
.get(CONTENT_TYPE)
|
||||||
|
.is_none_or(|value| value != "application/json")
|
||||||
|
{
|
||||||
|
error_response(failure(response.status().as_u16()))
|
||||||
|
} else {
|
||||||
|
response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn streaming_router(service: Arc<ServerApiContractService>) -> Router {
|
||||||
|
// The operation inventory is the route authority, not duplicated string literals.
|
||||||
|
use api_macros::Operation;
|
||||||
|
use axum::routing::put;
|
||||||
|
use server_api::server_api_operations::{DriveDownload, DriveUpload};
|
||||||
|
Router::new()
|
||||||
|
.route(DriveUpload::METADATA.path, put(streaming_upload))
|
||||||
|
.route(DriveDownload::METADATA.path, get(streaming_download))
|
||||||
|
.with_state(service)
|
||||||
|
}
|
||||||
|
async fn streaming_upload(
|
||||||
|
State(service): State<Arc<ServerApiContractService>>,
|
||||||
|
AxumPath(workspace): AxumPath<String>,
|
||||||
|
Extension(context): Extension<ServerRequestContext>,
|
||||||
|
query: std::result::Result<Query<DriveUploadQuery>, axum::extract::rejection::QueryRejection>,
|
||||||
|
request: Request,
|
||||||
|
) -> Response {
|
||||||
|
let result = async {
|
||||||
|
let api = service.workspace_api().map_err(|_| failure(404))?;
|
||||||
|
let drive = drive_grants::authorized_drive(api, &context, &workspace, true).await?;
|
||||||
|
let actor = mutation_actor(&context)?;
|
||||||
|
let Query(query) = query.map_err(|_| failure(400))?;
|
||||||
|
// Reject malformed metadata, foreign refs and over-limit declarations before reading.
|
||||||
|
upload_mutation(&workspace, &query, Vec::new())?;
|
||||||
|
if query.size as usize > workspace_drive::MAX_FILE_BYTES {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
if let Some(length) = request.headers().get(axum::http::header::CONTENT_LENGTH) {
|
||||||
|
let length: u64 = length
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.ok_or_else(|| failure(400))?;
|
||||||
|
if length > workspace_drive::MAX_FILE_BYTES as u64 {
|
||||||
|
return Err(failure(413));
|
||||||
|
}
|
||||||
|
if length != u64::from(query.size) {
|
||||||
|
return Err(failure(400));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = receive(request.into_body(), query.size.into(), &query.sha256).await?;
|
||||||
|
// The bound Drive authorizer runs again under attached-authority IMMEDIATE,
|
||||||
|
// so a grant revoked during reception cannot publish or replay a receipt.
|
||||||
|
publish_upload(drive, actor, &workspace, query, bytes).await
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(result) => Json(result).into_response(),
|
||||||
|
Err(error) => error_response(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn streaming_download(
|
||||||
|
State(service): State<Arc<ServerApiContractService>>,
|
||||||
|
AxumPath(workspace): AxumPath<String>,
|
||||||
|
Extension(context): Extension<ServerRequestContext>,
|
||||||
|
query: std::result::Result<Query<DriveDownloadQuery>, axum::extract::rejection::QueryRejection>,
|
||||||
|
) -> Response {
|
||||||
|
let result = async {
|
||||||
|
let api = service.workspace_api().map_err(|_| failure(404))?;
|
||||||
|
let Query(query) = query.map_err(|_| failure(400))?;
|
||||||
|
let (drive, node) = select_download(api, &context, &workspace, query).await?;
|
||||||
|
// Validate the referenced blob before HTTP success, including a zero-byte file.
|
||||||
|
let first_drive = drive.clone();
|
||||||
|
let first = blocking(false, move || {
|
||||||
|
first_drive.read(node.id, node.revision, 0, TRANSFER_CHUNK)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
let id = node.id;
|
||||||
|
let revision = node.revision;
|
||||||
|
let state = (drive, id, revision, Some(first), 0_u64, false);
|
||||||
|
let stream = futures::stream::try_unfold(
|
||||||
|
state,
|
||||||
|
|(drive, id, revision, first, offset, done)| async move {
|
||||||
|
if done {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let chunk = match first {
|
||||||
|
Some(chunk) => chunk,
|
||||||
|
None => {
|
||||||
|
let copy = drive.clone();
|
||||||
|
blocking(false, move || {
|
||||||
|
copy.read(id, revision, offset, TRANSFER_CHUNK)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| stream_error())?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let next = offset + chunk.bytes.len() as u64;
|
||||||
|
let eof = chunk.eof;
|
||||||
|
Ok::<_, std::io::Error>(Some((
|
||||||
|
axum::body::Bytes::from(chunk.bytes),
|
||||||
|
(drive, id, revision, None, next, eof),
|
||||||
|
)))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut response = axum::body::Body::from_stream(stream).into_response();
|
||||||
|
let headers = response.headers_mut();
|
||||||
|
headers.insert(
|
||||||
|
CONTENT_TYPE,
|
||||||
|
HeaderValue::from_str(&safe_content_type(
|
||||||
|
node.content_type.as_deref().unwrap_or(""),
|
||||||
|
)?)
|
||||||
|
.map_err(|_| failure(503))?,
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_LENGTH,
|
||||||
|
HeaderValue::from_str(&node.size.to_string()).map_err(|_| failure(503))?,
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_DISPOSITION,
|
||||||
|
HeaderValue::from_str(&disposition(&node.name)).map_err(|_| failure(503))?,
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::ETAG,
|
||||||
|
HeaderValue::from_str(&validator(&node)).map_err(|_| failure(503))?,
|
||||||
|
);
|
||||||
|
headers.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store"));
|
||||||
|
headers.insert(
|
||||||
|
"x-content-type-options",
|
||||||
|
HeaderValue::from_static("nosniff"),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
"content-security-policy",
|
||||||
|
HeaderValue::from_static("sandbox; default-src 'none'"),
|
||||||
|
);
|
||||||
|
Ok::<_, DriveApiError>(response)
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => error_response(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
//! Drive authority is rechecked inside the metadata transaction against the
|
||||||
|
//! attached Server DB. A bound handle is not an authorization cache or lease.
|
||||||
|
use super::*;
|
||||||
|
use rusqlite::params;
|
||||||
|
use server_api::{
|
||||||
|
DriveApiError, DriveApiErrorCode, DriveGrantCreateRequest, DriveGrantListQuery,
|
||||||
|
DriveGrantListResponse, DriveGrantResponse, ServerRequestContext,
|
||||||
|
};
|
||||||
|
|
||||||
|
type DriveResult<T> = std::result::Result<T, DriveApiError>;
|
||||||
|
fn fail(code: DriveApiErrorCode) -> DriveApiError {
|
||||||
|
DriveApiError::new(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn pre_error(error: impl Into<ApiError>) -> DriveApiError {
|
||||||
|
let error = error.into();
|
||||||
|
fail(match api_error_status(&error.error).as_u16() {
|
||||||
|
401 | 403 => DriveApiErrorCode::Denied,
|
||||||
|
404 => DriveApiErrorCode::NotFound,
|
||||||
|
409 => DriveApiErrorCode::Conflict,
|
||||||
|
413 => DriveApiErrorCode::Limit,
|
||||||
|
500..=599 => DriveApiErrorCode::StorageUnavailable,
|
||||||
|
_ => DriveApiErrorCode::Invalid,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn mutation_error(error: Error) -> DriveApiError {
|
||||||
|
if matches!(error, Error::Sqlite(_) | Error::Store(_)) {
|
||||||
|
fail(DriveApiErrorCode::OutcomeUnknown)
|
||||||
|
} else {
|
||||||
|
pre_error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn worker_request(context: &ServerRequestContext) -> bool {
|
||||||
|
context.runtime_source.is_some()
|
||||||
|
|| context.worker_source.is_some()
|
||||||
|
|| context.transport_headers.iter().any(|(name, _)| {
|
||||||
|
name.eq_ignore_ascii_case("x-yoi-runtime-id")
|
||||||
|
|| name.eq_ignore_ascii_case("x-yoi-worker-id")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn browser_actor(context: &ServerRequestContext) -> DriveResult<&RequestActor> {
|
||||||
|
if worker_request(context) {
|
||||||
|
return Err(fail(DriveApiErrorCode::Denied));
|
||||||
|
}
|
||||||
|
context
|
||||||
|
.actor
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| fail(DriveApiErrorCode::Denied))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn authorized_drive(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
write: bool,
|
||||||
|
) -> DriveResult<workspace_drive::Drive> {
|
||||||
|
validate_workspace_scope(api, workspace_id).map_err(pre_error)?;
|
||||||
|
let workspace = workspace_id.to_owned();
|
||||||
|
let drive = if worker_request(context) {
|
||||||
|
// Never fall back to an actor when a Worker/Runtime source is present,
|
||||||
|
// incomplete, or inconsistent with its transport identity.
|
||||||
|
let headers = current_worker_contract_headers(context)
|
||||||
|
.map_err(|_| fail(DriveApiErrorCode::Denied))?;
|
||||||
|
let worker = current_worker_identity(api, workspace_id, &headers)
|
||||||
|
.map_err(|_| fail(DriveApiErrorCode::Denied))?;
|
||||||
|
if context.worker_source.as_ref().is_some_and(|source| {
|
||||||
|
source.runtime_id != worker.runtime_id || source.worker_id != worker.worker_id
|
||||||
|
}) {
|
||||||
|
return Err(fail(DriveApiErrorCode::Denied));
|
||||||
|
}
|
||||||
|
let drive = api.drive().await.map_err(pre_error)?;
|
||||||
|
drive.with_authorizer(move |conn, write| {
|
||||||
|
if !crate::store::drive_worker_is_live(conn, true, &workspace, &worker)? {
|
||||||
|
return Err(workspace_drive::Error::Denied);
|
||||||
|
}
|
||||||
|
let granted: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspace_drive_grants
|
||||||
|
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0 AND (?4=0 OR access='read_write'))",
|
||||||
|
params![workspace,worker.runtime_id,worker.worker_id,write],|row|row.get(0))?;
|
||||||
|
if !granted { return Err(workspace_drive::Error::Denied); }
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let actor = browser_actor(context)?;
|
||||||
|
let account = actor.account_id.clone();
|
||||||
|
let user = actor.user_id.clone();
|
||||||
|
let drive = api.drive().await.map_err(pre_error)?;
|
||||||
|
// Ordinary Workspace use is member-facing: the current Server policy
|
||||||
|
// admits persisted authenticated users, not only the Workspace owner.
|
||||||
|
drive.with_authorizer(move |conn, _write| {
|
||||||
|
let member: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspaces ws
|
||||||
|
CROSS JOIN yoi_workspace_authority.users u
|
||||||
|
WHERE ws.workspace_id=?1 AND ws.state='active' AND u.account_id=?2 AND u.user_id=?3)",
|
||||||
|
params![workspace,account,user],|row|row.get(0))?;
|
||||||
|
if !member { return Err(workspace_drive::Error::Denied); }
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
// Reject missing/read-only grants before upload body reception. This check
|
||||||
|
// is admission only: every later operation rechecks under its own lock.
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
drive.check_authorized(write)?;
|
||||||
|
Ok::<_, workspace_drive::Error>(drive)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))?
|
||||||
|
.map_err(|error| {
|
||||||
|
fail(match error {
|
||||||
|
workspace_drive::Error::Denied => DriveApiErrorCode::Denied,
|
||||||
|
workspace_drive::Error::Fenced | workspace_drive::Error::Conflict => {
|
||||||
|
DriveApiErrorCode::Conflict
|
||||||
|
}
|
||||||
|
workspace_drive::Error::NotFound => DriveApiErrorCode::NotFound,
|
||||||
|
workspace_drive::Error::Invalid(_) => DriveApiErrorCode::Invalid,
|
||||||
|
_ => DriveApiErrorCode::StorageUnavailable,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn create_grant(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
request: DriveGrantCreateRequest,
|
||||||
|
) -> DriveResult<DriveGrantResponse> {
|
||||||
|
let actor = browser_actor(context)?;
|
||||||
|
require_workspace_owner(api, workspace_id, actor, "Drive grants")
|
||||||
|
.await
|
||||||
|
.map_err(pre_error)?;
|
||||||
|
for identity in [&request.runtime_id, &request.worker_id] {
|
||||||
|
if identity.is_empty() || identity.len() > 256 || identity.chars().any(char::is_control) {
|
||||||
|
return Err(fail(DriveApiErrorCode::Invalid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id);
|
||||||
|
let summary = api
|
||||||
|
.runtime
|
||||||
|
.worker(&worker)
|
||||||
|
.map_err(|_| fail(DriveApiErrorCode::NotFound))?;
|
||||||
|
if summary.workspace.workspace_id.as_deref() != Some(workspace_id) {
|
||||||
|
return Err(fail(DriveApiErrorCode::Denied));
|
||||||
|
}
|
||||||
|
let grant = DriveGrantResponse {
|
||||||
|
grant_id: String::new(),
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
runtime_id: request.runtime_id,
|
||||||
|
worker_id: request.worker_id,
|
||||||
|
access: request.access,
|
||||||
|
revoked: false,
|
||||||
|
created_by: actor.account_id.clone(),
|
||||||
|
created_at: String::new(),
|
||||||
|
revoked_by: None,
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
let store = api.store.clone();
|
||||||
|
tokio::task::spawn_blocking(move || store.create_workspace_drive_grant(&grant))
|
||||||
|
.await
|
||||||
|
.map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))?
|
||||||
|
.map_err(mutation_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) async fn revoke_grant(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
) -> DriveResult<DriveGrantResponse> {
|
||||||
|
let actor = browser_actor(context)?;
|
||||||
|
require_workspace_owner(api, workspace_id, actor, "Drive grants")
|
||||||
|
.await
|
||||||
|
.map_err(pre_error)?;
|
||||||
|
validate_grant_id(grant_id)?;
|
||||||
|
let (workspace, grant, actor) = (
|
||||||
|
workspace_id.to_owned(),
|
||||||
|
grant_id.to_owned(),
|
||||||
|
actor.account_id.clone(),
|
||||||
|
);
|
||||||
|
let store = api.store.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
store.revoke_workspace_drive_grant(&workspace, &grant, &actor)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))?
|
||||||
|
.map_err(mutation_error)?
|
||||||
|
.ok_or_else(|| fail(DriveApiErrorCode::NotFound))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_grant_id(id: &str) -> DriveResult<()> {
|
||||||
|
if !id
|
||||||
|
.parse::<i64>()
|
||||||
|
.is_ok_and(|n| n > 0 && n.to_string() == id)
|
||||||
|
{
|
||||||
|
return Err(fail(DriveApiErrorCode::Invalid));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub(super) async fn list_grants(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
context: &ServerRequestContext,
|
||||||
|
workspace_id: &str,
|
||||||
|
query: DriveGrantListQuery,
|
||||||
|
) -> DriveResult<DriveGrantListResponse> {
|
||||||
|
let actor = browser_actor(context)?;
|
||||||
|
require_workspace_owner(api, workspace_id, actor, "Drive grants")
|
||||||
|
.await
|
||||||
|
.map_err(pre_error)?;
|
||||||
|
let limit = query.limit.unwrap_or(100) as usize;
|
||||||
|
if limit == 0 || limit > 200 {
|
||||||
|
return Err(fail(DriveApiErrorCode::Invalid));
|
||||||
|
}
|
||||||
|
if let Some(after) = &query.after {
|
||||||
|
validate_grant_id(after)?;
|
||||||
|
}
|
||||||
|
let workspace = workspace_id.to_owned();
|
||||||
|
let store = api.store.clone();
|
||||||
|
let mut grants = tokio::task::spawn_blocking(move || {
|
||||||
|
store.list_workspace_drive_grants(&workspace, limit + 1, query.after.as_deref())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))?
|
||||||
|
.map_err(pre_error)?;
|
||||||
|
let more = grants.len() > limit;
|
||||||
|
grants.truncate(limit);
|
||||||
|
let next_after = if more {
|
||||||
|
grants.last().map(|g| g.grant_id.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
Ok(DriveGrantListResponse { grants, next_after })
|
||||||
|
}
|
||||||
@@ -28,7 +28,8 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
|||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||||
const LATEST_SCHEMA_VERSION: i64 = 83;
|
const LATEST_SCHEMA_VERSION: i64 = 84;
|
||||||
|
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
@@ -293,6 +294,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: "Generic Ticket Worker roles and durable claims",
|
name: "Generic Ticket Worker roles and durable claims",
|
||||||
apply: migrate_ticket_worker_v82_to_v83,
|
apply: migrate_ticket_worker_v82_to_v83,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 84,
|
||||||
|
name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME,
|
||||||
|
apply: migrate_workspace_drive_grants_v83_to_v84,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -2005,6 +2011,28 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
) -> Result<WorkdirCatalogPage>;
|
) -> Result<WorkdirCatalogPage>;
|
||||||
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
|
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
|
||||||
|
|
||||||
|
fn create_workspace_drive_grant(
|
||||||
|
&self,
|
||||||
|
grant: &server_api::DriveGrantResponse,
|
||||||
|
) -> Result<server_api::DriveGrantResponse>;
|
||||||
|
fn get_workspace_drive_grant(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
) -> Result<Option<server_api::DriveGrantResponse>>;
|
||||||
|
fn list_workspace_drive_grants(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
after: Option<&str>,
|
||||||
|
) -> Result<Vec<server_api::DriveGrantResponse>>;
|
||||||
|
fn revoke_workspace_drive_grant(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
actor: &str,
|
||||||
|
) -> Result<Option<server_api::DriveGrantResponse>>;
|
||||||
|
|
||||||
fn create_workspace_config_grant(
|
fn create_workspace_config_grant(
|
||||||
&self,
|
&self,
|
||||||
grant: &server_api::WorkspaceConfigGrantResponse,
|
grant: &server_api::WorkspaceConfigGrantResponse,
|
||||||
@@ -9505,6 +9533,81 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_workspace_drive_grant(
|
||||||
|
&self,
|
||||||
|
grant: &server_api::DriveGrantResponse,
|
||||||
|
) -> Result<server_api::DriveGrantResponse> {
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
require_drive_grant_owner(&tx, &grant.workspace_id, &grant.created_by)?;
|
||||||
|
let worker = RuntimeWorkerRef::new(&grant.runtime_id, &grant.worker_id);
|
||||||
|
if !drive_worker_is_live(&tx, false, &grant.workspace_id, &worker)? {
|
||||||
|
return Err(Error::WorkspacePermissionDenied("Worker is not live in this Workspace".into()));
|
||||||
|
}
|
||||||
|
let existing = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0"),
|
||||||
|
params![grant.workspace_id, grant.runtime_id, grant.worker_id], read_drive_grant).optional()?;
|
||||||
|
if let Some(existing) = existing {
|
||||||
|
if existing.access != grant.access {
|
||||||
|
return Err(Error::WorkspaceConfigConflict("Drive grant exists; revoke it first".into()));
|
||||||
|
}
|
||||||
|
return Ok(existing);
|
||||||
|
}
|
||||||
|
let access = match grant.access { server_api::DriveAccess::ReadOnly => "read_only", server_api::DriveAccess::ReadWrite => "read_write" };
|
||||||
|
tx.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,revoked,created_by,created_at) VALUES(?1,?2,?3,?4,0,?5,?6)",
|
||||||
|
params![grant.workspace_id,grant.runtime_id,grant.worker_id,access,grant.created_by,chrono::Utc::now().to_rfc3339()])?;
|
||||||
|
let stored = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),params![grant.workspace_id,tx.last_insert_rowid()],read_drive_grant)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(stored)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn get_workspace_drive_grant(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
) -> Result<Option<server_api::DriveGrantResponse>> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
Ok(conn
|
||||||
|
.query_row(
|
||||||
|
&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),
|
||||||
|
params![workspace_id, grant_id],
|
||||||
|
read_drive_grant,
|
||||||
|
)
|
||||||
|
.optional()?)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn list_workspace_drive_grants(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
limit: usize,
|
||||||
|
after: Option<&str>,
|
||||||
|
) -> Result<Vec<server_api::DriveGrantResponse>> {
|
||||||
|
if limit == 0 || limit > 201 {
|
||||||
|
return Err(Error::WorkspaceConfigConflict(
|
||||||
|
"Invalid Drive grant page limit".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let mut stmt = conn.prepare(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id>?2 ORDER BY grant_id LIMIT ?3"))?;
|
||||||
|
Ok(stmt.query_map(params![workspace_id,after.unwrap_or("0").parse::<i64>().map_err(|_| Error::WorkspaceConfigConflict("Invalid Drive grant cursor".into()))?,limit],read_drive_grant)?.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn revoke_workspace_drive_grant(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
grant_id: &str,
|
||||||
|
actor: &str,
|
||||||
|
) -> Result<Option<server_api::DriveGrantResponse>> {
|
||||||
|
self.with_conn_mut(|conn| {
|
||||||
|
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
|
||||||
|
require_drive_grant_owner(&tx, workspace_id, actor)?;
|
||||||
|
tx.execute("UPDATE workspace_drive_grants SET revoked=1,revoked_by=?3,revoked_at=?4 WHERE workspace_id=?1 AND grant_id=?2 AND revoked=0",
|
||||||
|
params![workspace_id,grant_id,actor,chrono::Utc::now().to_rfc3339()])?;
|
||||||
|
let stored = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),params![workspace_id,grant_id],read_drive_grant).optional()?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(stored)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn create_workspace_config_grant(
|
fn create_workspace_config_grant(
|
||||||
&self,
|
&self,
|
||||||
grant: &server_api::WorkspaceConfigGrantResponse,
|
grant: &server_api::WorkspaceConfigGrantResponse,
|
||||||
@@ -11906,6 +12009,12 @@ pub(crate) fn commit_worker_catalog_removal(
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
) -> Result<WorkerRegistryProjectionCommit> {
|
) -> Result<WorkerRegistryProjectionCommit> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
|
||||||
|
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0",
|
||||||
|
params![workspace_id, worker.runtime_id, worker.worker_id],
|
||||||
|
)?;
|
||||||
let existing = conn
|
let existing = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT 1 FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3",
|
"SELECT 1 FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3",
|
||||||
@@ -11925,6 +12034,56 @@ pub(crate) fn commit_worker_catalog_removal(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DRIVE_GRANT_SELECT: &str = "SELECT grant_id,workspace_id,runtime_id,worker_id,access,revoked,created_by,created_at,revoked_by,revoked_at FROM workspace_drive_grants";
|
||||||
|
fn read_drive_grant(row: &rusqlite::Row<'_>) -> rusqlite::Result<server_api::DriveGrantResponse> {
|
||||||
|
Ok(server_api::DriveGrantResponse {
|
||||||
|
grant_id: row.get::<_, i64>(0)?.to_string(),
|
||||||
|
workspace_id: row.get(1)?,
|
||||||
|
runtime_id: row.get(2)?,
|
||||||
|
worker_id: row.get(3)?,
|
||||||
|
access: match row.get::<_, String>(4)?.as_str() {
|
||||||
|
"read_only" => server_api::DriveAccess::ReadOnly,
|
||||||
|
"read_write" => server_api::DriveAccess::ReadWrite,
|
||||||
|
_ => return Err(rusqlite::Error::InvalidQuery),
|
||||||
|
},
|
||||||
|
revoked: row.get(5)?,
|
||||||
|
created_by: row.get(6)?,
|
||||||
|
created_at: row.get(7)?,
|
||||||
|
revoked_by: row.get(8)?,
|
||||||
|
revoked_at: row.get(9)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn require_drive_grant_owner(conn: &Connection, workspace: &str, actor: &str) -> Result<()> {
|
||||||
|
let allowed: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')",params![workspace,actor],|r|r.get(0))?;
|
||||||
|
if !allowed {
|
||||||
|
return Err(Error::WorkspacePermissionDenied(
|
||||||
|
"Drive grants require the active Workspace owner".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
/// The prefix is selected only by trusted Host code, never request data. Uses
|
||||||
|
/// the durable Runtime/Worker identity and removal authority, not observations.
|
||||||
|
pub(crate) fn drive_worker_is_live(
|
||||||
|
conn: &Connection,
|
||||||
|
attached: bool,
|
||||||
|
workspace: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
) -> rusqlite::Result<bool> {
|
||||||
|
let a = if attached {
|
||||||
|
"yoi_workspace_authority."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
conn.query_row(&format!("SELECT EXISTS(SELECT 1 FROM {a}worker_registry wr JOIN {a}workspaces ws ON ws.workspace_id=wr.workspace_id
|
||||||
|
WHERE wr.workspace_id=?1 AND wr.runtime_id=?2 AND wr.worker_id=?3 AND ws.state='active'
|
||||||
|
AND (wr.runtime_id=?4 OR EXISTS(SELECT 1 FROM {a}workspace_runtime_bindings rb WHERE rb.workspace_id=?1 AND rb.runtime_id=?2 AND rb.state='verified' AND rb.revoked_at IS NULL))
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM {a}worker_registry_projection_removals rm WHERE rm.workspace_id=?1 AND rm.runtime_id=?2 AND rm.worker_id=?3)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM {a}worker_tombstones wt WHERE wt.workspace_id=?1 AND wt.runtime_id=?2 AND wt.worker_id=?3)
|
||||||
|
AND NOT EXISTS(SELECT 1 FROM {a}worker_removal_operations ro WHERE ro.workspace_id=?1 AND ro.runtime_id=?2 AND ro.worker_id=?3 AND ro.state IN ('executing','failed','succeeded')))") ,
|
||||||
|
params![workspace,worker.runtime_id,worker.worker_id,crate::hosts::EMBEDDED_RUNTIME_ID],|r|r.get(0))
|
||||||
|
}
|
||||||
|
|
||||||
fn read_worker_control_grant_record(
|
fn read_worker_control_grant_record(
|
||||||
row: &rusqlite::Row<'_>,
|
row: &rusqlite::Row<'_>,
|
||||||
) -> rusqlite::Result<WorkerControlGrantRecord> {
|
) -> rusqlite::Result<WorkerControlGrantRecord> {
|
||||||
@@ -12595,6 +12754,22 @@ fn read_workdir_registry_record(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_workspace_drive_grants_v83_to_v84(conn: &Connection) -> Result<()> {
|
||||||
|
if current_schema_version(conn)? != 83 {
|
||||||
|
return Err(Error::Store(
|
||||||
|
"Drive grants migration requires schema 83".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||||
|
tx.execute_batch(include_str!("workspace_drive_grants.sql"))?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)",
|
||||||
|
params![84_i64, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn migrate_worker_restore_intents_v79_to_v80(conn: &Connection) -> Result<()> {
|
fn migrate_worker_restore_intents_v79_to_v80(conn: &Connection) -> Result<()> {
|
||||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||||
tx.execute_batch("CREATE TABLE worker_restore_intents (
|
tx.execute_batch("CREATE TABLE worker_restore_intents (
|
||||||
@@ -16816,6 +16991,193 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection)
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
include!("store_workspace_config_tests.rs");
|
include!("store_workspace_config_tests.rs");
|
||||||
|
|
||||||
|
fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse {
|
||||||
|
store.with_conn(|c| {
|
||||||
|
c.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('drive-owner','user','drive-owner','Owner','1','1');
|
||||||
|
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('drive-ws','drive-owner','Workspace','active','1','1'),('drive-other','drive-owner','Other','active','1','1');")?;
|
||||||
|
for workspace in ["drive-ws", "drive-other"] {
|
||||||
|
c.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,private_material_ref,revision,state,created_at,updated_at) VALUES(?1,?1,'ed25519',?1,1,'pending_provisioning','1','1')", [workspace])?;
|
||||||
|
c.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES(?1,?2,'worker','Worker','normal','1','1')", params![workspace, crate::hosts::EMBEDDED_RUNTIME_ID])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
server_api::DriveGrantResponse {
|
||||||
|
grant_id: String::new(),
|
||||||
|
workspace_id: "drive-ws".into(),
|
||||||
|
runtime_id: crate::hosts::EMBEDDED_RUNTIME_ID.into(),
|
||||||
|
worker_id: "worker".into(),
|
||||||
|
access: server_api::DriveAccess::ReadOnly,
|
||||||
|
revoked: false,
|
||||||
|
created_by: "drive-owner".into(),
|
||||||
|
created_at: String::new(),
|
||||||
|
revoked_by: None,
|
||||||
|
revoked_at: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart() {
|
||||||
|
let t = tempfile::tempdir().unwrap();
|
||||||
|
let path = t.path().join("server.db");
|
||||||
|
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
|
let mut proposed = drive_grant_fixture(&store);
|
||||||
|
store.with_conn(|c| {
|
||||||
|
c.execute_batch("DROP TRIGGER workspace_drive_grants_worker_deleted; DROP TABLE workspace_drive_grants; UPDATE __yoi_schema_migrations SET version=83 WHERE version=84;")?;
|
||||||
|
assert_eq!(current_schema_version(c)?, 83);
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
drop(store);
|
||||||
|
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
|
store
|
||||||
|
.with_conn(|c| {
|
||||||
|
assert_eq!(current_schema_version(c)?, 84);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let first = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||||
|
assert_eq!(first.grant_id, "1");
|
||||||
|
assert_eq!(first.created_by, "drive-owner");
|
||||||
|
assert!(!first.created_at.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
store.create_workspace_drive_grant(&proposed).unwrap(),
|
||||||
|
first
|
||||||
|
);
|
||||||
|
proposed.access = server_api::DriveAccess::ReadWrite;
|
||||||
|
assert!(store.create_workspace_drive_grant(&proposed).is_err());
|
||||||
|
proposed.workspace_id = "drive-other".into();
|
||||||
|
let other = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_workspace_drive_grant("drive-other", &first.grant_id)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.revoke_workspace_drive_grant("drive-other", &first.grant_id, "drive-owner")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.revoke_workspace_drive_grant("drive-ws", &first.grant_id, "stranger")
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let revoked = store
|
||||||
|
.revoke_workspace_drive_grant("drive-ws", &first.grant_id, "drive-owner")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(revoked.revoked);
|
||||||
|
assert_eq!(revoked.revoked_by.as_deref(), Some("drive-owner"));
|
||||||
|
assert!(revoked.revoked_at.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.revoke_workspace_drive_grant("drive-ws", &first.grant_id, "drive-owner")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap(),
|
||||||
|
revoked
|
||||||
|
);
|
||||||
|
proposed.workspace_id = "drive-ws".into();
|
||||||
|
let second = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||||
|
assert_ne!(second.grant_id, first.grant_id);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_workspace_drive_grants("drive-ws", 1, None)
|
||||||
|
.unwrap(),
|
||||||
|
vec![revoked.clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_workspace_drive_grants("drive-ws", 1, Some(&first.grant_id))
|
||||||
|
.unwrap(),
|
||||||
|
vec![second.clone()]
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.delete_worker_registry(
|
||||||
|
"drive-ws",
|
||||||
|
&RuntimeWorkerRef::new(crate::hosts::EMBEDDED_RUNTIME_ID, "worker"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let retired = store
|
||||||
|
.get_workspace_drive_grant("drive-ws", &second.grant_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(retired.revoked);
|
||||||
|
assert_eq!(retired.revoked_by.as_deref(), Some("server:worker-removal"));
|
||||||
|
assert_eq!(retired.created_at, second.created_at);
|
||||||
|
assert!(store.create_workspace_drive_grant(&proposed).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_workspace_drive_grant("drive-other", &other.grant_id)
|
||||||
|
.unwrap(),
|
||||||
|
Some(other)
|
||||||
|
);
|
||||||
|
drop(store);
|
||||||
|
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_workspace_drive_grant("drive-ws", &first.grant_id)
|
||||||
|
.unwrap(),
|
||||||
|
Some(revoked)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.get_workspace_drive_grant("drive-ws", &second.grant_id)
|
||||||
|
.unwrap(),
|
||||||
|
Some(retired)
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.with_conn(|c| {
|
||||||
|
c.execute("DELETE FROM workspaces WHERE workspace_id='drive-ws'", [])?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_workspace_drive_grants("drive-ws", 10, None)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_workspace_drive_grants("drive-other", 10, None)
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn drive_grants_baseline_constraints_and_worker_lifetime_fail_closed() {
|
||||||
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
|
let proposed = drive_grant_fixture(&store);
|
||||||
|
let first = store.create_workspace_drive_grant(&proposed).unwrap();
|
||||||
|
store.with_conn(|c| {
|
||||||
|
assert_eq!(current_schema_version(c)?, 84);
|
||||||
|
assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws',?1,'worker','read_write','drive-owner','now')", [crate::hosts::EMBEDDED_RUNTIME_ID]).is_err());
|
||||||
|
assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws','r','w','command','drive-owner','now')", []).is_err());
|
||||||
|
assert!(c.execute("UPDATE workspace_drive_grants SET revoked=1 WHERE grant_id=?1", [&first.grant_id]).is_err());
|
||||||
|
c.execute("INSERT INTO worker_registry_projection_removals(workspace_id,runtime_id,worker_id) VALUES('drive-ws',?1,'worker')", [crate::hosts::EMBEDDED_RUNTIME_ID])?;
|
||||||
|
assert!(!drive_worker_is_live(c, false, "drive-ws", &RuntimeWorkerRef::new(crate::hosts::EMBEDDED_RUNTIME_ID, "worker"))?);
|
||||||
|
Ok(())
|
||||||
|
}).unwrap();
|
||||||
|
assert!(store.create_workspace_drive_grant(&proposed).is_err());
|
||||||
|
store
|
||||||
|
.with_conn(|c| {
|
||||||
|
c.execute(
|
||||||
|
"DELETE FROM worker_registry WHERE workspace_id='drive-ws'",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.get_workspace_drive_grant("drive-ws", &first.grant_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.revoked
|
||||||
|
);
|
||||||
|
}
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -18340,6 +18702,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn downgrade_ticket_worker_schema(conn: &Connection, restore_worker_fk: bool) {
|
fn downgrade_ticket_worker_schema(conn: &Connection, restore_worker_fk: bool) {
|
||||||
|
// Older-generation fixtures must not retain the new Drive authority.
|
||||||
|
conn.execute_batch("DROP TRIGGER IF EXISTS workspace_drive_grants_worker_deleted; DROP TABLE IF EXISTS workspace_drive_grants;").unwrap();
|
||||||
// Restore the actual old role and FK contracts, not just the old
|
// Restore the actual old role and FK contracts, not just the old
|
||||||
// unique index, so migration tests exercise real coder rows.
|
// unique index, so migration tests exercise real coder rows.
|
||||||
for table in [
|
for table in [
|
||||||
@@ -18428,7 +18792,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.version)
|
.map(|s| s.version)
|
||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![83]
|
vec![83, 84]
|
||||||
);
|
);
|
||||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
||||||
let assignment = store
|
let assignment = store
|
||||||
@@ -18454,7 +18818,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
store.with_conn(|conn| {
|
store.with_conn(|conn| {
|
||||||
assert_eq!(current_schema_version(conn)?,83);
|
assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION);
|
||||||
assert_eq!(conn.query_row("SELECT role FROM ticket_worker_assignments WHERE assignment_id='assignment'", [], |r| r.get::<_,String>(0))?,"worker");
|
assert_eq!(conn.query_row("SELECT role FROM ticket_worker_assignments WHERE assignment_id='assignment'", [], |r| r.get::<_,String>(0))?,"worker");
|
||||||
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?,0);
|
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?,0);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -18739,6 +19103,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
version: 83,
|
version: 83,
|
||||||
name: "Generic Ticket Worker roles and durable claims".to_string()
|
name: "Generic Ticket Worker roles and durable claims".to_string()
|
||||||
},
|
},
|
||||||
|
WorkspaceSchemaMigrationStep {
|
||||||
|
version: 84,
|
||||||
|
name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string()
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -18832,6 +19200,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
(81, "Backend Job immutable resource serialization".to_string()),
|
(81, "Backend Job immutable resource serialization".to_string()),
|
||||||
(82, "Ticket responsibility and unfinished work separation".to_string()),
|
(82, "Ticket responsibility and unfinished work separation".to_string()),
|
||||||
(83, "Generic Ticket Worker roles and durable claims".to_string()),
|
(83, "Generic Ticket Worker roles and durable claims".to_string()),
|
||||||
|
(84, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -19183,7 +19552,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||||
73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83
|
73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
@@ -19192,7 +19561,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
LATEST_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 34);
|
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 35);
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"worker_workdir_links",
|
"worker_workdir_links",
|
||||||
"workspace_config_entries",
|
"workspace_config_entries",
|
||||||
"workspace_config_grants",
|
"workspace_config_grants",
|
||||||
|
"workspace_drive_grants",
|
||||||
"workspace_config_tree_revisions",
|
"workspace_config_tree_revisions",
|
||||||
"workspace_config_trees",
|
"workspace_config_trees",
|
||||||
"workspace_create_operations",
|
"workspace_create_operations",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE workspace_drive_grants (
|
||||||
|
workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
grant_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
worker_id TEXT NOT NULL,
|
||||||
|
access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')),
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)),
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
revoked_by TEXT,
|
||||||
|
revoked_at TEXT,
|
||||||
|
CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL)
|
||||||
|
OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL))
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX workspace_drive_grants_active_worker
|
||||||
|
ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0;
|
||||||
|
-- Retain grant audit across Worker deletion, but never carry its authority into
|
||||||
|
-- a later lifetime reusing the same Runtime/Worker identity.
|
||||||
|
CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN
|
||||||
|
UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal',
|
||||||
|
revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
||||||
|
WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id
|
||||||
|
AND worker_id=OLD.worker_id AND revoked=0;
|
||||||
|
END;
|
||||||
@@ -5,8 +5,9 @@
|
|||||||
Drive is a Workspace-owned **latest-version document tree**, not a mounted or
|
Drive is a Workspace-owned **latest-version document tree**, not a mounted or
|
||||||
POSIX-compatible filesystem. `workspace-drive` owns storage, hierarchy, revision
|
POSIX-compatible filesystem. `workspace-drive` owns storage, hierarchy, revision
|
||||||
conflicts, mutation receipts, bounded reads/search, and garbage collection.
|
conflicts, mutation receipts, bounded reads/search, and garbage collection.
|
||||||
`WorkspaceApi::drive` is the trusted Server adapter. Authorization and HTTP DTOs
|
`WorkspaceApi::drive` is the trusted Server adapter. The authorized HTTP API and
|
||||||
belong to T-722; Worker/Web clients and end-to-end routing belong to T-723–725.
|
Backend-managed grants are documented below. Worker Tools/WIP, Web UI, and full
|
||||||
|
end-to-end routing belong to T-723–725.
|
||||||
Session attachments remain Session-owned and are not Drive authority. Worker,
|
Session attachments remain Session-owned and are not Drive authority. Worker,
|
||||||
Session, and Workdir cleanup do not delete Drive.
|
Session, and Workdir cleanup do not delete Drive.
|
||||||
|
|
||||||
@@ -262,3 +263,106 @@ The hermetic backup test stops writers, snapshots actual FeatureStorage SQLite,
|
|||||||
copies actual LocalFileSystem blobs, restores into absent roots, and checks stable
|
copies actual LocalFileSystem blobs, restores into absent roots, and checks stable
|
||||||
IDs/latest content/receipts and non-reused ID allocation. It does not validate a
|
IDs/latest content/receipts and non-reused ID allocation. It does not validate a
|
||||||
production backup scheduler, volume snapshot, or physical power cut.
|
production backup scheduler, volume snapshot, or physical power cut.
|
||||||
|
|
||||||
|
## Authorized typed HTTP API
|
||||||
|
|
||||||
|
The Rust `server-api` contract owns `/api/w/{workspace_id}/drive/` operations:
|
||||||
|
|
||||||
|
- `GET root`, `metadata`, `list`, `search`, `read-text`, `read-chunk`, `download`;
|
||||||
|
- `POST mutate` (create folder/bounded UTF-8 text, replace text, relocate, delete);
|
||||||
|
- `PUT upload` (binary body with flat query metadata);
|
||||||
|
- `GET requests/{request_id}` for the committed receipt snapshot;
|
||||||
|
- owner-controlled `POST/GET grants` and `DELETE grants/{grant_id}`.
|
||||||
|
|
||||||
|
Ordinary usage follows the existing member-facing authenticated Server-user
|
||||||
|
policy. Non-ownership alone does not deny use. Grant management follows the
|
||||||
|
existing Workspace-owner account boundary. No new general membership ACL is
|
||||||
|
introduced. Browser/API-token identity is supplied by authentication middleware;
|
||||||
|
user/account existence and active Workspace state are checked again in the
|
||||||
|
operation transaction. Cookie mutations require the configured application
|
||||||
|
Origin through the existing middleware. No ordinary DTO accepts an actor.
|
||||||
|
|
||||||
|
Runtime-forwarded requests require the existing signed source proof and live
|
||||||
|
Runtime/Worker catalog membership. Workers receive an explicit whole-Drive
|
||||||
|
`read_only` or `read_write` grant, independently of Profile, Subject, Ticket,
|
||||||
|
Workdir or commands. Incomplete/mismatched Runtime or Worker identity cannot fall
|
||||||
|
back to a browser actor. Grant creation rechecks owner, active Workspace, and
|
||||||
|
existing live Worker in the Server DB transaction; active access changes require
|
||||||
|
revoking the previous grant first (no silent downgrade). Migration 84 stores
|
||||||
|
creation/revocation actor and time. Removal fences and catalog deletion revoke
|
||||||
|
access and retain audit; grant rows cascade with Workspace deletion. No alternate
|
||||||
|
Worker registry or Drive Workdir attachment is created.
|
||||||
|
|
||||||
|
A clone-bound Drive authorizer queries the attached Server authority under every
|
||||||
|
client-operation `BEGIN IMMEDIATE`, including receipt replay/result queries.
|
||||||
|
The lock spans validation, blob persistence and DB publication. Grant revocation
|
||||||
|
and Worker removal use the same Server DB write authority: whichever transaction
|
||||||
|
wins first is ordered first. A mutation committing before revocation is valid;
|
||||||
|
a revoked cached handle cannot commit later. Upload admission also checks access
|
||||||
|
before consuming the body, but that admission is not a lease: publication always
|
||||||
|
rechecks current access. Download checks authorization separately for every
|
||||||
|
chunk, and stops on revocation, deletion, revision expiry or storage failure.
|
||||||
|
|
||||||
|
References contain **both** Workspace ID and stable decimal node ID. JSON uses
|
||||||
|
`DriveEntryRef`; flat GET/binary queries require `entry_workspace_id` matching the
|
||||||
|
route Workspace before resolving `id`/`parent_id`. IDs/revisions are canonical
|
||||||
|
positive decimal strings within signed 64-bit range; file sizes/offsets are
|
||||||
|
bounded `u32`. List/search cursors carry Workspace identity and the last node ID;
|
||||||
|
they are continuation hints, not capabilities or snapshots. Returned `latest_url`
|
||||||
|
is a relative authenticated URL bound to Workspace and ID, independent of logical
|
||||||
|
name/parent. It confers neither public sharing nor approval. File URLs download;
|
||||||
|
folder URLs return metadata. Deletion/recreation never reuses the old reference.
|
||||||
|
|
||||||
|
Web upload consumes binary HTTP frames with an observed **16 MiB** ceiling and
|
||||||
|
checks declared size and lowercase SHA-256 before publication. Oversized announced
|
||||||
|
or received data, mismatches, aborted bodies and I/O errors never return success.
|
||||||
|
The storage boundary currently requires a whole-file Vec, so uploads retain at
|
||||||
|
most one bounded binary file in memory before calling T-721; they are not
|
||||||
|
constant-memory object-store multipart uploads and are never expanded to
|
||||||
|
JSON/base64. Runtime source-proof verification separately buffers a bounded body
|
||||||
|
before granting trusted ingress, as in other signed Server operations. Web
|
||||||
|
downloads stream **64 KiB** revision-fixed chunks; they do not materialize the
|
||||||
|
whole file. Generated Rust clients retain their explicit bounded-response policy
|
||||||
|
and return exact `BinaryBody` bytes. Worker JSON-facing adapters can request
|
||||||
|
bounded text or binary chunks through this same contract, but Tools/WIP and
|
||||||
|
model-context/image presentation belong to T-723; this API does not emit large
|
||||||
|
base64 JSON blobs.
|
||||||
|
|
||||||
|
Downloads validate bare MIME types and always use `Content-Disposition:
|
||||||
|
attachment` with percent-encoded UTF-8 names, `nosniff`, a sandbox/default-none
|
||||||
|
CSP, `private, no-store`, and a Workspace/node/revision ETag. Arbitrary HTML/SVG
|
||||||
|
bytes are storable, not safe app-origin inline previews. Content length is exact;
|
||||||
|
if a later chunk loses authority or expires, the response stream fails rather
|
||||||
|
than returning another generation or a successful truncated file. Even an empty
|
||||||
|
file validates its referenced blob before HTTP success. Conditional 304/range
|
||||||
|
HTTP semantics are not added; revision-selected chunk reads are explicit.
|
||||||
|
|
||||||
|
Errors use fixed, path-free typed codes: `denied`, `not_found`, `conflict`,
|
||||||
|
`invalid`, `limit`, `storage_unavailable`, `outcome_unknown`. SQL/commit/task
|
||||||
|
uncertainty at a mutation boundary is `outcome_unknown`/`unknown`; known rejection
|
||||||
|
or blob persistence failure is `not_committed`. A successful DB commit alone
|
||||||
|
returns a mutation response. After a lost response, query the same request ID;
|
||||||
|
`uncommitted` only means no committed receipt in that snapshot and must not be
|
||||||
|
interpreted as proof that an in-flight request can never commit. Exact replay uses
|
||||||
|
the same identity/payload/transport-bound actor and current permissions. Never
|
||||||
|
blindly create a fresh request ID to resolve an unknown outcome. Physical paths,
|
||||||
|
blob keys and raw provider errors are not public.
|
||||||
|
|
||||||
|
Contract regeneration/freshness:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -q -p server-api --example export_openapi -- openapi/server-api.json
|
||||||
|
cargo run -q -p server-api --example export_openapi -- --check openapi/server-api.json
|
||||||
|
cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts
|
||||||
|
deno fmt web/workspace/src/lib/generated/drive-api.ts
|
||||||
|
cargo test -p server-api --features typescript generated_drive_api_contract_is_current
|
||||||
|
# Existing OpenAPI-derived projections embed the whole-contract digest:
|
||||||
|
for example in generate_repository_openapi_types generate_runtime_api_types generate_ticket_api_types generate_companion_api_types generate_worker_launch_api_types; do
|
||||||
|
cargo run -q -p server-api --example "$example"
|
||||||
|
cargo run -q -p server-api --example "$example" -- --check
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing BinaryBody contract also supports binary **success responses**,
|
||||||
|
including declared response headers, without changing binary request bytes or
|
||||||
|
JSON/error behavior. It exports inline OpenAPI binary bodies, not JSON byte arrays.
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# T-722: SubWorker cwd and logical path friction
|
||||||
|
|
||||||
|
During delegation from the assigned repository Workdir, a child started with
|
||||||
|
`cwd: crates/server-api` and readable policy paths under repository-root
|
||||||
|
`docs/development`. Its Read calls using repository-relative logical paths could
|
||||||
|
not read the required testing strategy; absolute paths were rejected as invalid
|
||||||
|
logical paths too. The child correctly stopped without edits. The parent
|
||||||
|
inspected the committed child session, stopped that idle child, and recreated it
|
||||||
|
with repository-root cwd and explicit read-only repository scope plus write scope
|
||||||
|
limited to server-api. Policy reads and implementation then succeeded.
|
||||||
|
|
||||||
|
The current observable behavior appears to root child filesystem tools at the
|
||||||
|
selected cwd, rather than making cwd only the command default as the spawn-tool
|
||||||
|
description states. This makes repository-root logical paths surprising. Consider
|
||||||
|
keeping the attachment root invariant and applying cwd only to command execution,
|
||||||
|
or documenting/returning a distinct filesystem base explicitly. Do not encourage
|
||||||
|
absolute paths, parent traversal, or broader write grants as a workaround. This
|
||||||
|
report records a tool usability issue, not additional Ticket requirements.
|
||||||
+2307
-1
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Generated from server-api. Do not edit by hand.
|
||||||
|
// Regenerate: cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts
|
||||||
|
|
||||||
|
export type DriveEntryRef = { workspace_id: string; node_id: string };
|
||||||
|
|
||||||
|
export type DriveEntryKind = "folder" | "file";
|
||||||
|
|
||||||
|
export type DriveEntry = {
|
||||||
|
entry: DriveEntryRef;
|
||||||
|
parent: DriveEntryRef | null;
|
||||||
|
name: string;
|
||||||
|
kind: DriveEntryKind;
|
||||||
|
revision: string;
|
||||||
|
/**
|
||||||
|
* File length in bytes; absent for folders.
|
||||||
|
*/
|
||||||
|
size: number | null;
|
||||||
|
content_type: string | null;
|
||||||
|
updated_by: string;
|
||||||
|
/**
|
||||||
|
* Timestamp projected from storage's updated_at_ms; not a creation time.
|
||||||
|
*/
|
||||||
|
updated_at: string;
|
||||||
|
/**
|
||||||
|
* Stable relative authenticated URL in this same Workspace. No bearer token
|
||||||
|
* or cross-Workspace link is embedded in this metadata.
|
||||||
|
*/
|
||||||
|
latest_url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveEntryQuery = { entry_workspace_id: string; id: string };
|
||||||
|
|
||||||
|
export type DriveListQuery = {
|
||||||
|
entry_workspace_id: string;
|
||||||
|
/**
|
||||||
|
* Folder to list.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
|
limit: number | null;
|
||||||
|
/**
|
||||||
|
* Opaque service-issued page cursor, not a bare node ID.
|
||||||
|
*/
|
||||||
|
after: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveSearchQuery = {
|
||||||
|
query: string;
|
||||||
|
/**
|
||||||
|
* Search file text as well as names only when explicitly requested.
|
||||||
|
*/
|
||||||
|
include_text: boolean;
|
||||||
|
limit: number | null;
|
||||||
|
after: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveListResponse = {
|
||||||
|
entries: Array<DriveEntry>;
|
||||||
|
next_after: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveReadTextQuery = {
|
||||||
|
entry_workspace_id: string;
|
||||||
|
id: string;
|
||||||
|
max_bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveReadTextResponse = {
|
||||||
|
entry: DriveEntry;
|
||||||
|
text: string;
|
||||||
|
truncated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveReadChunkQuery = {
|
||||||
|
entry_workspace_id: string;
|
||||||
|
id: string;
|
||||||
|
/**
|
||||||
|
* Bind a multi-chunk read to the same file revision.
|
||||||
|
*/
|
||||||
|
expected_revision: string;
|
||||||
|
offset: number;
|
||||||
|
length: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveDownloadQuery = {
|
||||||
|
entry_workspace_id: string;
|
||||||
|
id: string;
|
||||||
|
expected_revision: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveMutation = {
|
||||||
|
"operation": "create_folder";
|
||||||
|
parent: DriveEntryRef;
|
||||||
|
name: string;
|
||||||
|
} | {
|
||||||
|
"operation": "create_text";
|
||||||
|
parent: DriveEntryRef;
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
content_type: string;
|
||||||
|
} | {
|
||||||
|
"operation": "update_text";
|
||||||
|
id: DriveEntryRef;
|
||||||
|
expected_revision: string;
|
||||||
|
text: string;
|
||||||
|
content_type: string;
|
||||||
|
} | {
|
||||||
|
"operation": "relocate";
|
||||||
|
id: DriveEntryRef;
|
||||||
|
expected_revision: string;
|
||||||
|
parent: DriveEntryRef;
|
||||||
|
name: string;
|
||||||
|
} | { "operation": "delete"; id: DriveEntryRef; expected_revision: string };
|
||||||
|
|
||||||
|
export type DriveMutationRequest = {
|
||||||
|
request_id: string;
|
||||||
|
mutation: DriveMutation;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveMutationResponse = {
|
||||||
|
request_id: string;
|
||||||
|
/**
|
||||||
|
* A deleted entry has no live metadata.
|
||||||
|
*/
|
||||||
|
entry: DriveEntry | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveUploadOperation = "create" | "update";
|
||||||
|
|
||||||
|
export type DriveUploadQuery = {
|
||||||
|
operation: DriveUploadOperation;
|
||||||
|
request_id: string;
|
||||||
|
entry_workspace_id: string;
|
||||||
|
parent_id: string | null;
|
||||||
|
name: string | null;
|
||||||
|
id: string | null;
|
||||||
|
expected_revision: string | null;
|
||||||
|
content_type: string;
|
||||||
|
/**
|
||||||
|
* Values above the file limit still decode as u32 so the service returns
|
||||||
|
* typed Limit rather than a generic request-decoding error.
|
||||||
|
*/
|
||||||
|
size: number;
|
||||||
|
sha256: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveRequestState = "uncommitted" | "committed";
|
||||||
|
|
||||||
|
export type DriveRequestStatusResponse = {
|
||||||
|
request_id: string;
|
||||||
|
state: DriveRequestState;
|
||||||
|
response: DriveMutationResponse | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveAccess = "read_only" | "read_write";
|
||||||
|
|
||||||
|
export type DriveGrantCreateRequest = {
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
access: DriveAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveGrantResponse = {
|
||||||
|
grant_id: string;
|
||||||
|
workspace_id: string;
|
||||||
|
runtime_id: string;
|
||||||
|
worker_id: string;
|
||||||
|
access: DriveAccess;
|
||||||
|
revoked: boolean;
|
||||||
|
created_by: string;
|
||||||
|
created_at: string;
|
||||||
|
revoked_by: string | null;
|
||||||
|
revoked_at: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveGrantListQuery = {
|
||||||
|
limit: number | null;
|
||||||
|
/**
|
||||||
|
* Canonical grant ID cursor within this Workspace.
|
||||||
|
*/
|
||||||
|
after: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveGrantListResponse = {
|
||||||
|
grants: Array<DriveGrantResponse>;
|
||||||
|
next_after: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DriveApiErrorCode =
|
||||||
|
| "denied"
|
||||||
|
| "not_found"
|
||||||
|
| "conflict"
|
||||||
|
| "invalid"
|
||||||
|
| "limit"
|
||||||
|
| "storage_unavailable"
|
||||||
|
| "outcome_unknown";
|
||||||
|
|
||||||
|
export type DriveFailureClassification = "not_committed" | "unknown";
|
||||||
|
|
||||||
|
export type DriveApiError = {
|
||||||
|
code: DriveApiErrorCode;
|
||||||
|
classification: DriveFailureClassification;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933)
|
// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
Reference in New Issue
Block a user