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:
2026-10-09 00:02:09 +09:00
parent 9da79ab9f0
commit b5ea162f83
25 changed files with 6416 additions and 16 deletions
Generated
+1
View File
@@ -6715,6 +6715,7 @@ name = "yoi-workspace-server"
version = "0.1.0"
dependencies = [
"agen",
"api-macros",
"async-trait",
"axum",
"chrono",
+4
View File
@@ -55,3 +55,7 @@ required-features = ["typescript"]
[[example]]
name = "generate_repository_access_types"
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())
);
}
+902
View File
@@ -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());
}
}
}
}
+112
View File
@@ -10,7 +10,9 @@ use api_macros::api;
pub use api_macros::axum as server_support;
pub use api_macros::reqwest as client_support;
pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation};
mod drive;
pub mod repository_openapi_typescript;
pub use drive::*;
mod workspace_config;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -2603,6 +2605,116 @@ pub trait ServerApi {
#[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest,
) -> 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)]
async fn current_worker_workspace_config_get(
&self,
+45 -3
View File
@@ -29,6 +29,8 @@ pub enum Error {
NotFound,
#[error("Drive revision, name, or request identity conflict")]
Conflict,
#[error("Drive access denied")]
Denied,
#[error("Workspace Drive is fenced for deletion")]
Fenced,
#[error("Drive storage failure: {0}")]
@@ -174,8 +176,11 @@ pub struct CollectionPage {
pub next_after: Option<String>,
}
type Authorizer = dyn Fn(&Connection, bool) -> Result<()> + Send + Sync;
#[derive(Clone)]
pub struct Drive {
authorizer: Option<Arc<Authorizer>>,
database: FeatureDatabase,
blobs: Arc<blob::BlobStore>,
workspace_id: String,
@@ -232,6 +237,7 @@ impl Drive {
Ok(())
})?;
Ok(Self {
authorizer: None,
database,
blobs,
workspace_id,
@@ -278,8 +284,36 @@ impl 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> {
self.database.try_with_connection(|c| {
self.database.try_transaction(|c| {
self.authorize(c, false)?;
let id = c.query_row(
"SELECT id FROM drive_nodes WHERE parent_id IS NULL",
[],
@@ -289,7 +323,10 @@ impl Drive {
})
}
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
@@ -297,6 +334,7 @@ impl Drive {
pub fn list(&self, parent: NodeId, after: Option<NodeId>, limit: usize) -> Result<Page> {
validate_limit(limit, MAX_PAGE)?;
self.database.try_transaction(|tx| {
self.authorize(tx, false)?;
directory(tx, parent)?;
let mut stmt = tx.prepare(
"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> {
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
.query_row(
"SELECT result_json FROM drive_receipts WHERE request_id=?1",
@@ -354,6 +393,7 @@ impl Drive {
.map(|b| format!("{b:02x}"))
.collect();
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",
[request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? {
if old_fingerprint != fingerprint { return Err(Error::Conflict); }
@@ -419,6 +459,7 @@ impl Drive {
) -> Result<ReadChunk> {
validate_limit(length, MAX_READ_BYTES)?;
self.database.try_transaction(|tx| {
self.authorize(tx, false)?;
accepting(tx)?;
let selected = node(tx, id)?;
if selected.revision != revision {
@@ -488,6 +529,7 @@ impl Drive {
validate_identity(query, "query", 256)?;
validate_limit(budget, MAX_SEARCH_NODES)?;
self.database.try_transaction(|tx| {
self.authorize(tx, false)?;
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 ids = stmt.query_map(params![after.map_or(0,|v|v.0),budget+1],|r|r.get::<_,i64>(0))?.collect::<std::result::Result<Vec<_>,_>>()?;
+180
View File
@@ -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,
&registration,
&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);
}
+1
View File
@@ -11,6 +11,7 @@ name = "yoi-server"
path = "src/main.rs"
[dependencies]
api-macros = { workspace = true, features = ["axum"] }
async-trait.workspace = true
axum = { workspace = true, features = ["ws"] }
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
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;
+131
View File
@@ -6,6 +6,10 @@ pub(crate) use worker_operations::{
WorkerOperationContext, WorkspaceWorker, WorkspaceWorkerMethodSender,
};
#[path = "server_drive.rs"]
mod drive;
#[path = "server_drive_grants.rs"]
mod drive_grants;
#[path = "server_workspace_config.rs"]
mod workspace_config;
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 {
if path.starts_with("/api/w/") && path.contains("/drive/") {
return (status, Json(drive::failure(status.as_u16()))).into_response();
}
if path == "/api/repositories"
|| path.starts_with("/api/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 {
let service = Arc::new(service);
Router::new()
.merge(drive::router(service.clone()))
.merge(server_api::server_api_axum::workspace_current(
service.clone(),
))
@@ -11112,6 +11120,128 @@ impl server_api::ServerApi for ServerApiContractService {
.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(
&self,
context: server_api::ServerRequestContext,
@@ -32591,6 +32721,7 @@ impl IntoResponse for ApiError {
#[cfg(test)]
mod tests {
mod auth_logging_tests;
mod drive_api_tests;
mod drive_tests;
mod subject_spawn_tests;
mod subjektiv_jobs_tests;
File diff suppressed because it is too large Load Diff
+736
View File
@@ -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 })
}
+374 -5
View File
@@ -28,7 +28,8 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
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 SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
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",
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)]
@@ -2005,6 +2011,28 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
) -> Result<WorkdirCatalogPage>;
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(
&self,
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(
&self,
grant: &server_api::WorkspaceConfigGrantResponse,
@@ -11906,6 +12009,12 @@ pub(crate) fn commit_worker_catalog_removal(
workspace_id: &str,
worker: &RuntimeWorkerRef,
) -> 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
.query_row(
"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(
row: &rusqlite::Row<'_>,
) -> 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<()> {
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
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)]
mod tests {
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::*;
#[test]
@@ -18340,6 +18702,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
}
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
// unique index, so migration tests exercise real coder rows.
for table in [
@@ -18428,7 +18792,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
.iter()
.map(|s| s.version)
.collect::<Vec<_>>(),
vec![83]
vec![83, 84]
);
let store = SqliteWorkspaceStore::open(&path).unwrap();
let assignment = store
@@ -18454,7 +18818,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
);
}
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 COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?,0);
Ok(())
@@ -18739,6 +19103,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
version: 83,
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()),
(82, "Ticket responsibility and unfinished work separation".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")?);
@@ -19183,7 +19552,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
.collect::<Vec<_>>(),
vec![
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();
@@ -19192,7 +19561,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
current_schema_version(&conn).unwrap(),
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", "capabilities").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
@@ -87,6 +87,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"worker_workdir_links",
"workspace_config_entries",
"workspace_config_grants",
"workspace_drive_grants",
"workspace_config_tree_revisions",
"workspace_config_trees",
"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;
+106 -2
View File
@@ -5,8 +5,9 @@
Drive is a Workspace-owned **latest-version document tree**, not a mounted or
POSIX-compatible filesystem. `workspace-drive` owns storage, hierarchy, revision
conflicts, mutation receipts, bounded reads/search, and garbage collection.
`WorkspaceApi::drive` is the trusted Server adapter. Authorization and HTTP DTOs
belong to T-722; Worker/Web clients and end-to-end routing belong to T-723–725.
`WorkspaceApi::drive` is the trusted Server adapter. The authorized HTTP API and
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, 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
IDs/latest content/receipts and non-reused ID allocation. It does not validate a
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.
+18
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// 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
// Output: web/workspace/src/lib/generated/companion-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/repository-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/runtime-api.ts
// 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.
// 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
// Output: web/workspace/src/lib/generated/ticket-api.ts
// 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.
// 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
// 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