feat: deliver exact Plugin artifacts through current Host authority
Bind remote and in-process delivery to authenticated Worker lineage, live installation permission and persisted exact pins. Verify canonical bytes and complete descriptors in a bounded Runtime-only atomic cache; cache hits reauthorize and process-crash staging never becomes visible. Shares the exact PluginInstallation permission shape published for T-793; no HTTPS or launch/restore integration is included. Depends on the T-465 source ancestry and the shared Host execution identity helper.
This commit is contained in:
@@ -57,6 +57,10 @@ pub const MAX_SCOPED_PERMISSIONS: usize = 256;
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PermissionResource {
|
||||
PluginInstallation {
|
||||
workspace_id: String,
|
||||
installation_id: String,
|
||||
},
|
||||
Job {
|
||||
workspace_id: String,
|
||||
job_id: String,
|
||||
@@ -128,6 +132,10 @@ impl<'de> Deserialize<'de> for PermissionResource {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
enum Wire {
|
||||
PluginInstallation {
|
||||
workspace_id: String,
|
||||
installation_id: String,
|
||||
},
|
||||
Job {
|
||||
workspace_id: String,
|
||||
job_id: String,
|
||||
@@ -192,6 +200,13 @@ impl<'de> Deserialize<'de> for PermissionResource {
|
||||
},
|
||||
}
|
||||
Ok(match Wire::deserialize(deserializer)? {
|
||||
Wire::PluginInstallation {
|
||||
workspace_id,
|
||||
installation_id,
|
||||
} => Self::PluginInstallation {
|
||||
workspace_id,
|
||||
installation_id,
|
||||
},
|
||||
Wire::Job {
|
||||
workspace_id,
|
||||
job_id,
|
||||
@@ -300,7 +315,8 @@ impl PermissionResource {
|
||||
/// Checks Workspace membership without a target existence or data lookup.
|
||||
pub fn workspace_id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Job { workspace_id, .. }
|
||||
Self::PluginInstallation { workspace_id, .. }
|
||||
| Self::Job { workspace_id, .. }
|
||||
| Self::Ticket { workspace_id, .. }
|
||||
| Self::Objective { workspace_id, .. }
|
||||
| Self::MergeRequest { workspace_id, .. }
|
||||
@@ -317,6 +333,13 @@ impl PermissionResource {
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match self {
|
||||
Self::PluginInstallation {
|
||||
workspace_id,
|
||||
installation_id,
|
||||
} => {
|
||||
validate_authorization_identifier(workspace_id)?;
|
||||
validate_authorization_identifier(installation_id)
|
||||
}
|
||||
Self::Job {
|
||||
workspace_id,
|
||||
job_id,
|
||||
@@ -438,6 +461,16 @@ impl PermissionResource {
|
||||
return false;
|
||||
}
|
||||
match (self, other) {
|
||||
(
|
||||
Self::PluginInstallation {
|
||||
workspace_id: a,
|
||||
installation_id: ai,
|
||||
},
|
||||
Self::PluginInstallation {
|
||||
workspace_id: b,
|
||||
installation_id: bi,
|
||||
},
|
||||
) => a == b && ai == bi,
|
||||
(
|
||||
Self::Job {
|
||||
workspace_id: a,
|
||||
@@ -798,6 +831,56 @@ pub fn validate_relative_permission_path(path: &str) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugin_installation_permissions_require_exact_workspace_and_installation() {
|
||||
let resource = PermissionResource::PluginInstallation {
|
||||
workspace_id: "ws".into(),
|
||||
installation_id: "install".into(),
|
||||
};
|
||||
assert_eq!(resource.workspace_id(), Some("ws"));
|
||||
let wire = serde_json::json!({"kind":"plugin_installation","workspace_id":"ws","installation_id":"install"});
|
||||
assert_eq!(serde_json::to_value(&resource).unwrap(), wire);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PermissionResource>(wire.clone()).unwrap(),
|
||||
resource
|
||||
);
|
||||
assert!(resource.covers(&resource));
|
||||
for other in [
|
||||
PermissionResource::PluginInstallation {
|
||||
workspace_id: "other".into(),
|
||||
installation_id: "install".into(),
|
||||
},
|
||||
PermissionResource::PluginInstallation {
|
||||
workspace_id: "ws".into(),
|
||||
installation_id: "other".into(),
|
||||
},
|
||||
PermissionResource::Workspace {
|
||||
workspace_id: "ws".into(),
|
||||
},
|
||||
PermissionResource::Feature,
|
||||
] {
|
||||
assert!(!resource.covers(&other));
|
||||
assert!(!other.covers(&resource));
|
||||
}
|
||||
for (field, value) in [
|
||||
("workspace_id", ""),
|
||||
("installation_id", "*"),
|
||||
("installation_id", " install"),
|
||||
] {
|
||||
let mut invalid = wire.clone();
|
||||
invalid[field] = value.into();
|
||||
assert!(
|
||||
serde_json::from_value::<PermissionResource>(invalid)
|
||||
.unwrap()
|
||||
.validate()
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let mut claims = wire;
|
||||
claims["subject"] = "owner".into();
|
||||
assert!(serde_json::from_value::<PermissionResource>(claims).is_err());
|
||||
}
|
||||
|
||||
fn workdir(path: &str, recursive: bool) -> PermissionResource {
|
||||
PermissionResource::Workdir {
|
||||
working_directory_id: "wd".into(),
|
||||
|
||||
@@ -219,6 +219,8 @@ impl_openapi_schema!(
|
||||
PluginInstallation,
|
||||
PluginListResponse,
|
||||
PluginOperationResponse,
|
||||
PluginArtifactDeliveryRequest,
|
||||
PluginArtifactDeliveryMetadata,
|
||||
SubjectPermissionsResponse,
|
||||
SubjectPermissionsMutationResponse,
|
||||
SubjectPermissionInterruption,
|
||||
@@ -2985,6 +2987,22 @@ pub trait ServerApi {
|
||||
#[path] operation_id: String,
|
||||
) -> Result<PluginOperationResponse, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/workers/self/plugin-artifacts/authorize", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], openapi = false, normalize_body_errors = true)]
|
||||
async fn plugin_artifact_authorize(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: PluginArtifactDeliveryRequest,
|
||||
) -> Result<PluginArtifactDeliveryMetadata, RepositoryApiError>;
|
||||
|
||||
#[post("/api/w/{workspace_id}/workers/self/plugin-artifacts/pull", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], openapi = false, normalize_body_errors = true)]
|
||||
async fn plugin_artifact_pull(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[path] workspace_id: String,
|
||||
#[body] request: PluginArtifactDeliveryRequest,
|
||||
) -> Result<BinaryBody, 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.
|
||||
@@ -14911,6 +14929,8 @@ mod openapi_artifact_tests {
|
||||
// identity, or a one-use Reviewer capability. They remain generated Rust client/Axum
|
||||
// operations, but must not appear as unauthenticated operations in the public OpenAPI.
|
||||
const SIGNED_INTERNAL: &[&str] = &[
|
||||
"plugin_artifact_authorize",
|
||||
"plugin_artifact_pull",
|
||||
"operation_authorize",
|
||||
"bound_operation_authorize",
|
||||
"current_worker_workspace_config_get",
|
||||
|
||||
@@ -182,3 +182,37 @@ pub struct PluginOperationResponse {
|
||||
pub kind: PluginOperationKind,
|
||||
pub installation: PluginInstallation,
|
||||
}
|
||||
|
||||
/// Namespace a byte cache by the authenticated Server origin and signing key.
|
||||
/// This is not a package hash or an authorization decision. Hosts derive the
|
||||
/// bundle from their current registered authority, never from a delivery reply.
|
||||
pub fn plugin_server_authority_identity(bundle: &crate::WorkspacePublicIdentityBundle) -> String {
|
||||
serde_json::to_string(&(
|
||||
bundle.backend_url.trim_end_matches('/'),
|
||||
&bundle.public_key_fingerprint,
|
||||
))
|
||||
.expect("string tuple is serializable")
|
||||
}
|
||||
|
||||
/// A selection within a persisted plan, never digest-only retrieval authority.
|
||||
/// Workspace and Worker identities come from authenticated Host context.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginArtifactDeliveryRequest {
|
||||
pub plan_id: String,
|
||||
pub plan_fingerprint: String,
|
||||
pub installation_id: String,
|
||||
pub artifact_digest: String,
|
||||
}
|
||||
|
||||
/// Fresh owner observation for one exact selection. Cache only bytes, not this
|
||||
/// observation or the Workspace's configuration, grants or installation state.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginArtifactDeliveryMetadata {
|
||||
pub workspace_id: String,
|
||||
pub request: PluginArtifactDeliveryRequest,
|
||||
pub artifact: PluginArtifact,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ pub fn plugin_api_typescript() -> String {
|
||||
PluginListResponse::decl(&config),
|
||||
PluginOperationKind::decl(&config),
|
||||
PluginOperationResponse::decl(&config),
|
||||
PluginArtifactDeliveryRequest::decl(&config),
|
||||
PluginArtifactDeliveryMetadata::decl(&config),
|
||||
];
|
||||
format!(
|
||||
"// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_plugin_api_types > web/workspace/src/lib/generated/plugin-api.ts\n\n{}\n",
|
||||
@@ -31,6 +33,7 @@ pub fn plugin_api_typescript() -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use api_macros::ApiContract;
|
||||
#[test]
|
||||
fn plugin_routes_use_binary_upload_and_closed_management_contracts() {
|
||||
let document = crate::canonical_openapi_document().unwrap();
|
||||
@@ -55,6 +58,56 @@ mod tests {
|
||||
assert!(value["paths"]["/api/w/{workspace_id}/plugins/{installation_id}/data"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_delivery_is_runtime_signed_and_has_no_caller_authority_or_transport_inputs() {
|
||||
for operation in ["plugin_artifact_authorize", "plugin_artifact_pull"] {
|
||||
assert!(
|
||||
crate::ServerApiMetadata::OPERATIONS
|
||||
.iter()
|
||||
.any(|entry| entry.operation_id == operation)
|
||||
);
|
||||
}
|
||||
let document = crate::canonical_openapi_document().unwrap();
|
||||
let value: serde_json::Value = serde_json::from_str(&document.to_json().unwrap()).unwrap();
|
||||
for suffix in ["authorize", "pull"] {
|
||||
assert!(
|
||||
value["paths"]
|
||||
[format!("/api/w/{{workspace_id}}/workers/self/plugin-artifacts/{suffix}")]
|
||||
.is_null()
|
||||
);
|
||||
}
|
||||
let selection = serde_json::json!({
|
||||
"plan_id":"plan", "plan_fingerprint":"fingerprint",
|
||||
"installation_id":"installation", "artifact_digest":"digest"
|
||||
});
|
||||
assert!(
|
||||
serde_json::from_value::<crate::PluginArtifactDeliveryRequest>(selection.clone())
|
||||
.is_ok()
|
||||
);
|
||||
for field in [
|
||||
"workspace_id",
|
||||
"worker_id",
|
||||
"principal_id",
|
||||
"delegation_id",
|
||||
"url",
|
||||
"token",
|
||||
"cache_path",
|
||||
"bytes",
|
||||
] {
|
||||
let mut invalid = selection.clone();
|
||||
invalid[field] = "caller-selected".into();
|
||||
assert!(
|
||||
serde_json::from_value::<crate::PluginArtifactDeliveryRequest>(invalid).is_err()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<crate::PluginArtifactDeliveryRequest>(
|
||||
serde_json::json!({"artifact_digest":"digest"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "typescript")]
|
||||
#[test]
|
||||
fn generated_plugin_types_are_current() {
|
||||
|
||||
Reference in New Issue
Block a user