diff --git a/crates/server-api/src/authorization.rs b/crates/server-api/src/authorization.rs index ed7fa5fee..453345e46 100644 --- a/crates/server-api/src/authorization.rs +++ b/crates/server-api/src/authorization.rs @@ -59,6 +59,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, @@ -135,6 +139,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, @@ -202,6 +210,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, @@ -311,7 +326,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, .. } @@ -329,6 +345,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, @@ -452,6 +475,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, @@ -864,6 +897,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::(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::(invalid) + .unwrap() + .validate() + .is_err() + ); + } + let mut claims = wire; + claims["subject"] = "owner".into(); + assert!(serde_json::from_value::(claims).is_err()); + } + fn workdir(path: &str, recursive: bool) -> PermissionResource { PermissionResource::Workdir { working_directory_id: "wd".into(), diff --git a/openapi/server-api.json b/openapi/server-api.json index b86d0b4e0..f262e3970 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -7025,6 +7025,27 @@ }, "PermissionResource": { "oneOf": [ + { + "additionalProperties": false, + "properties": { + "installation_id": { + "type": "string" + }, + "kind": { + "const": "plugin_installation", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "installation_id" + ], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -19114,7 +19135,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3" + "x-yoi-source-digest": "sha256:709b2f3a7004d11109fc14a2826d4ffffadfd4818698f550ce9150a36442f494" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index d2c0a9ffc..686cd0a43 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -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:6f738263ef6d2c939d021e719e7d8986edea9389d5ae4805f275b36a9cf722d3) +// Input contract: 0.1.0 (sha256:709b2f3a7004d11109fc14a2826d4ffffadfd4818698f550ce9150a36442f494) // 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 @@ -35,7 +35,7 @@ export type PasteArtifactMediaType = "text_plain_utf8"; export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; }; -export type PermissionResource = { job_id: string; kind: "job"; workspace_id: string; } | { kind: "ticket"; ticket_id: string; workspace_id: string; } | { kind: "objective"; objective_id: string; workspace_id: string; } | { kind: "merge_request"; merge_request_id: string; workspace_id: string; } | { kind: "subject"; subject_id: string; workspace_id: string; } | { kind: "drive_entry"; node_id: string; workspace_id: string; } | { kind: "drive_request"; request_id: string; workspace_id: string; } | { kind: "repository"; repository_key: string; workspace_id: string; } | { connection_id: string; kind: "workdir_attachment"; workspace_id: string; } | { kind: "workspace_config"; path: string; recursive: boolean; workspace_id: string; } | { kind: "network"; url: string; } | { kind: "sub_worker"; name: string; runtime_id: string; worker_id: string; } | { kind: "feature"; } | { kind: "workspace"; workspace_id: string; } | { kind: "workdir"; path: string; recursive: boolean; working_directory_id: string; } | { kind: "worker_operation"; workspace_id: string; } | { kind: "worker"; runtime_id: string; worker_id: string; }; +export type PermissionResource = { installation_id: string; kind: "plugin_installation"; workspace_id: string; } | { job_id: string; kind: "job"; workspace_id: string; } | { kind: "ticket"; ticket_id: string; workspace_id: string; } | { kind: "objective"; objective_id: string; workspace_id: string; } | { kind: "merge_request"; merge_request_id: string; workspace_id: string; } | { kind: "subject"; subject_id: string; workspace_id: string; } | { kind: "drive_entry"; node_id: string; workspace_id: string; } | { kind: "drive_request"; request_id: string; workspace_id: string; } | { kind: "repository"; repository_key: string; workspace_id: string; } | { connection_id: string; kind: "workdir_attachment"; workspace_id: string; } | { kind: "workspace_config"; path: string; recursive: boolean; workspace_id: string; } | { kind: "network"; url: string; } | { kind: "sub_worker"; name: string; runtime_id: string; worker_id: string; } | { kind: "feature"; } | { kind: "workspace"; workspace_id: string; } | { kind: "workdir"; path: string; recursive: boolean; working_directory_id: string; } | { kind: "worker_operation"; workspace_id: string; } | { kind: "worker"; runtime_id: string; worker_id: string; }; export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; };