feat: add Workspace Repository SSH secret authority
This commit is contained in:
@@ -373,6 +373,116 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
|
||||
pub language: String,
|
||||
}
|
||||
|
||||
/// Public metadata for one Workspace-scoped Repository SSH credential.
|
||||
///
|
||||
/// Secret references and secret material are deliberately not part of this DTO.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshCredential {
|
||||
pub credential_id: String,
|
||||
pub workspace_id: String,
|
||||
pub name: String,
|
||||
pub public_key_algorithm: String,
|
||||
pub public_key_fingerprint: String,
|
||||
pub current_revision: u64,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub rotated_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub referenced_repositories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRepositorySshCredentialRequest {
|
||||
pub operation_id: String,
|
||||
pub credential_id: String,
|
||||
pub name: String,
|
||||
pub private_key: String,
|
||||
#[serde(default)]
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RotateRepositorySshCredentialRequest {
|
||||
pub operation_id: String,
|
||||
pub expected_revision: u64,
|
||||
pub private_key: String,
|
||||
#[serde(default)]
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRepositorySshCredentialRequest {
|
||||
pub operation_id: String,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
/// Public metadata for an explicitly pinned SSH host key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshHostTrust {
|
||||
pub host_trust_id: String,
|
||||
pub workspace_id: String,
|
||||
pub hostname: String,
|
||||
pub port: u16,
|
||||
pub key_algorithm: String,
|
||||
pub host_key: String,
|
||||
pub fingerprint: String,
|
||||
pub current_revision: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub referenced_repositories: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PutRepositorySshHostTrustRequest {
|
||||
pub operation_id: String,
|
||||
pub host_trust_id: String,
|
||||
pub hostname: String,
|
||||
pub port: u16,
|
||||
pub host_key: String,
|
||||
#[serde(default)]
|
||||
pub expected_revision: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct DeleteRepositorySshHostTrustRequest {
|
||||
pub operation_id: String,
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RepositoryAccessMode {
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositorySshAccessBinding {
|
||||
pub repository_id: String,
|
||||
pub credential_id: String,
|
||||
pub host_trust_id: String,
|
||||
pub access: RepositoryAccessMode,
|
||||
}
|
||||
|
||||
/// Secret-free active Repository access projection consumed by later Runtime work.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RepositoryAccessProjection {
|
||||
pub workspace_id: String,
|
||||
pub config_revision: u64,
|
||||
pub projection_digest: String,
|
||||
pub bindings: Vec<RepositorySshAccessBinding>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -25,11 +25,13 @@ manifest.workspace = true
|
||||
protocol = { workspace = true }
|
||||
project-record.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
|
||||
ring.workspace = true
|
||||
rusqlite.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
sha2.workspace = true
|
||||
ssh-key.workspace = true
|
||||
thiserror.workspace = true
|
||||
ticket.workspace = true
|
||||
memory.workspace = true
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod records;
|
||||
#[cfg(feature = "typescript")]
|
||||
pub use records::ticket_api_typescript;
|
||||
pub mod repositories;
|
||||
pub mod repository_access;
|
||||
pub mod repository_source;
|
||||
pub mod resource_broker;
|
||||
pub mod retention;
|
||||
@@ -116,6 +117,8 @@ pub enum Error {
|
||||
TicketAssignmentConflict(String),
|
||||
#[error("Workdir attachment conflict: {0}")]
|
||||
WorkdirAttachmentConflict(String),
|
||||
#[error("Workspace permission denied: {0}")]
|
||||
WorkspacePermissionDenied(String),
|
||||
#[error("Workspace config update conflict: {0}")]
|
||||
WorkspaceConfigConflict(String),
|
||||
#[error("Registry inconsistency: {0}")]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path as AxumPath, Query, Request, State};
|
||||
use axum::extract::{Extension, Path as AxumPath, Query, Request, State};
|
||||
use axum::http::header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH, LOCATION, ORIGIN, SET_COOKIE};
|
||||
use axum::http::{HeaderMap, Method, StatusCode, Uri};
|
||||
use axum::middleware::{self, Next};
|
||||
@@ -58,8 +58,12 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec
|
||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||
use workspace_api::{
|
||||
ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
||||
DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest,
|
||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest,
|
||||
RepositoryAccessProjection, RepositorySshCredential, RepositorySshHostTrust,
|
||||
RotateRepositorySshCredentialRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -109,6 +113,10 @@ use crate::repositories::{
|
||||
ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError,
|
||||
RepositoryRegistryReader, RepositorySummary,
|
||||
};
|
||||
use crate::repository_access::{
|
||||
RepositoryAccessConfigSchemaProvider, RepositorySecretService,
|
||||
project_repository_access_candidate, project_repository_access_state,
|
||||
};
|
||||
use crate::resource_broker::BackendResourceBroker;
|
||||
use crate::runtime_settings::RuntimeConfigSchemaProvider;
|
||||
use crate::runtime_subscription::RuntimeSubscriptionBroker;
|
||||
@@ -342,6 +350,7 @@ pub struct WorkspaceApi {
|
||||
pub(crate) config: ServerConfig,
|
||||
pub(crate) store: Arc<dyn ControlPlaneStore>,
|
||||
config_store: Arc<crate::SqliteWorkspaceStore>,
|
||||
repository_secrets: Arc<RepositorySecretService>,
|
||||
config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry,
|
||||
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||
authority: SqliteWorkspaceAuthority,
|
||||
@@ -1053,6 +1062,7 @@ async fn authorize_workspace_api_request(
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
}
|
||||
request.extensions_mut().insert(actor);
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
@@ -1343,12 +1353,17 @@ impl WorkspaceApi {
|
||||
let config_store = Arc::new(crate::SqliteWorkspaceStore::open(
|
||||
config.database_path.clone(),
|
||||
)?);
|
||||
let repository_secrets = Arc::new(RepositorySecretService::open(
|
||||
config_store.clone(),
|
||||
&config.database_path,
|
||||
)?);
|
||||
let config_schema_registry = crate::config_source::WorkspaceConfigSchemaRegistry::default()
|
||||
.with_provider(Arc::new(
|
||||
crate::profile_settings::ProfileConfigSchemaProvider,
|
||||
))
|
||||
.with_provider(Arc::new(crate::prompt_settings::PromptConfigSchemaProvider))
|
||||
.with_provider(Arc::new(RuntimeConfigSchemaProvider))
|
||||
.with_provider(Arc::new(RepositoryAccessConfigSchemaProvider))
|
||||
.with_provider(Arc::new(skills::SkillConfigSchemaProvider));
|
||||
config_store.ensure_workspace_config_materialized_with_schema(
|
||||
&config.workspace_id,
|
||||
@@ -1357,6 +1372,7 @@ impl WorkspaceApi {
|
||||
)?;
|
||||
let api = Self {
|
||||
config_store,
|
||||
repository_secrets,
|
||||
config_schema_registry,
|
||||
prompt_projection_cache:
|
||||
crate::prompt_settings::WorkspacePromptProjectionCache::default(),
|
||||
@@ -1863,6 +1879,32 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
get(scoped_get_workspace_memory_settings)
|
||||
.put(scoped_update_workspace_memory_settings),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/repository-access",
|
||||
get(scoped_get_repository_access_projection),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/repository-access/credentials",
|
||||
get(scoped_list_repository_ssh_credentials)
|
||||
.post(scoped_create_repository_ssh_credential),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}",
|
||||
delete(scoped_delete_repository_ssh_credential),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/repository-access/credentials/{credential_id}/rotate",
|
||||
post(scoped_rotate_repository_ssh_credential),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/repository-access/host-trusts",
|
||||
get(scoped_list_repository_ssh_host_trusts)
|
||||
.post(scoped_put_repository_ssh_host_trust),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/settings/repository-access/host-trusts/{host_trust_id}",
|
||||
delete(scoped_delete_repository_ssh_host_trust),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/config/source-tree",
|
||||
get(scoped_get_workspace_config_tree),
|
||||
@@ -2986,6 +3028,18 @@ struct ScopedRepositoryPath {
|
||||
repository_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedRepositoryCredentialPath {
|
||||
workspace_id: String,
|
||||
credential_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedRepositoryHostTrustPath {
|
||||
workspace_id: String,
|
||||
host_trust_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScopedProfileArchivePath {
|
||||
workspace_id: String,
|
||||
@@ -3274,6 +3328,162 @@ async fn scoped_update_workspace_memory_settings(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn require_manage_repository_secrets(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
actor: &RequestActor,
|
||||
) -> ApiResult<()> {
|
||||
validate_workspace_scope(api, workspace_id)?;
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(workspace_id)
|
||||
.await?
|
||||
.ok_or(Error::WorkspaceIdMismatch)?;
|
||||
if workspace.owner_account_id.as_deref() != Some(actor.account_id.as_str()) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"ManageSecrets requires the Workspace owner account".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn active_repository_access_projection(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
) -> ApiResult<RepositoryAccessProjection> {
|
||||
let state = api
|
||||
.config_store
|
||||
.load_workspace_config(workspace_id)?
|
||||
.ok_or_else(|| Error::InvalidRecordId("virtual config source tree".into()))?;
|
||||
Ok(project_repository_access_state(
|
||||
&*api.store,
|
||||
&api.repository_secrets,
|
||||
workspace_id,
|
||||
&state,
|
||||
)?)
|
||||
}
|
||||
|
||||
async fn scoped_get_repository_access_projection(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<Json<RepositoryAccessProjection>> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
Ok(Json(active_repository_access_projection(
|
||||
&api,
|
||||
&path.workspace_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_list_repository_ssh_credentials(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<Json<Vec<RepositorySshCredential>>> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
let projection = active_repository_access_projection(&api, &path.workspace_id)?;
|
||||
Ok(Json(
|
||||
api.repository_secrets
|
||||
.list_credentials(&path.workspace_id, &projection)?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_create_repository_ssh_credential(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<CreateRepositorySshCredentialRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<RepositorySshCredential>)> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
let credential =
|
||||
api.repository_secrets
|
||||
.create_credential(&path.workspace_id, request, &actor.account_id)?;
|
||||
Ok((StatusCode::CREATED, Json(credential)))
|
||||
}
|
||||
|
||||
async fn scoped_rotate_repository_ssh_credential(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRepositoryCredentialPath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<RotateRepositorySshCredentialRequest>,
|
||||
) -> ApiResult<Json<RepositorySshCredential>> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
Ok(Json(api.repository_secrets.rotate_credential(
|
||||
&path.workspace_id,
|
||||
&path.credential_id,
|
||||
request,
|
||||
&actor.account_id,
|
||||
)?))
|
||||
}
|
||||
|
||||
async fn scoped_delete_repository_ssh_credential(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRepositoryCredentialPath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<DeleteRepositorySshCredentialRequest>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
let projection = active_repository_access_projection(&api, &path.workspace_id)?;
|
||||
api.repository_secrets.delete_credential(
|
||||
&path.workspace_id,
|
||||
&path.credential_id,
|
||||
request,
|
||||
&actor.account_id,
|
||||
&projection,
|
||||
)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn scoped_list_repository_ssh_host_trusts(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<Json<Vec<RepositorySshHostTrust>>> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
let projection = active_repository_access_projection(&api, &path.workspace_id)?;
|
||||
Ok(Json(
|
||||
api.repository_secrets
|
||||
.list_host_trusts(&path.workspace_id, &projection)?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn scoped_put_repository_ssh_host_trust(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<PutRepositorySshHostTrustRequest>,
|
||||
) -> ApiResult<(StatusCode, Json<RepositorySshHostTrust>)> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
let status = if request.expected_revision.is_some() {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::CREATED
|
||||
};
|
||||
let host_trust =
|
||||
api.repository_secrets
|
||||
.put_host_trust(&path.workspace_id, request, &actor.account_id)?;
|
||||
Ok((status, Json(host_trust)))
|
||||
}
|
||||
|
||||
async fn scoped_delete_repository_ssh_host_trust(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRepositoryHostTrustPath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<DeleteRepositorySshHostTrustRequest>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?;
|
||||
let projection = active_repository_access_projection(&api, &path.workspace_id)?;
|
||||
api.repository_secrets.delete_host_trust(
|
||||
&path.workspace_id,
|
||||
&path.host_trust_id,
|
||||
request,
|
||||
&actor.account_id,
|
||||
&projection,
|
||||
)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn scoped_get_workspace_config_tree(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -3333,6 +3543,12 @@ async fn scoped_commit_workspace_config_tree(
|
||||
api.config_schema_registry.compose()?,
|
||||
)?;
|
||||
crate::prompt_settings::validate_evaluated_prompt_catalog(&candidate.evaluation)?;
|
||||
project_repository_access_candidate(
|
||||
&*api.store,
|
||||
&api.repository_secrets,
|
||||
&path.workspace_id,
|
||||
&candidate,
|
||||
)?;
|
||||
let state = api
|
||||
.config_store
|
||||
.commit_evaluated_workspace_config(&path.workspace_id, &candidate)?;
|
||||
@@ -14070,7 +14286,9 @@ impl ApiError {
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match &self.error {
|
||||
Error::BrowserReopenConfirmationRequired => StatusCode::FORBIDDEN,
|
||||
Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
Error::TicketAssignmentConflict(_)
|
||||
| Error::WorkdirAttachmentConflict(_)
|
||||
| Error::WorkspaceConfigConflict(_) => StatusCode::CONFLICT,
|
||||
@@ -19157,6 +19375,54 @@ mod tests {
|
||||
assert_eq!(detail.provenance.id, "workspace:triage-errors");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_secret_management_is_owner_only() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
let timestamp = Utc::now().to_rfc3339();
|
||||
api.store
|
||||
.upsert_account(&crate::store::AccountRecord {
|
||||
account_id: "owner-account".to_string(),
|
||||
kind: "user".to_string(),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
})
|
||||
.unwrap();
|
||||
let mut workspace = api
|
||||
.store
|
||||
.get_workspace(TEST_WORKSPACE_ID)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
workspace.owner_account_id = Some("owner-account".to_string());
|
||||
api.store.upsert_workspace(&workspace).await.unwrap();
|
||||
|
||||
let owner = RequestActor {
|
||||
user_id: "owner-user".to_string(),
|
||||
account_id: "owner-account".to_string(),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
auth_method: ActorAuthMethod::BrowserSession,
|
||||
};
|
||||
require_manage_repository_secrets(&api, TEST_WORKSPACE_ID, &owner)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let non_owner = RequestActor {
|
||||
user_id: "other-user".to_string(),
|
||||
account_id: "other-account".to_string(),
|
||||
handle: "other".to_string(),
|
||||
display_name: "Other".to_string(),
|
||||
auth_method: ActorAuthMethod::ApiToken,
|
||||
};
|
||||
let error = require_manage_repository_secrets(&api, TEST_WORKSPACE_ID, &non_owner)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
async fn test_api_with_recording_backend(
|
||||
workspace_root: impl Into<PathBuf>,
|
||||
) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) {
|
||||
|
||||
@@ -252,6 +252,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "create Workdir create operations",
|
||||
apply: create_workdir_create_operations,
|
||||
},
|
||||
Migration {
|
||||
version: 46,
|
||||
name: "create Workspace Repository SSH secret authority",
|
||||
apply: create_repository_ssh_secret_authority,
|
||||
},
|
||||
];
|
||||
|
||||
struct Migration {
|
||||
@@ -6703,6 +6708,110 @@ fn create_workdir_create_operations(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_repository_ssh_secret_authority(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE repository_ssh_credentials (
|
||||
workspace_id TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
public_key_algorithm TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
current_revision INTEGER NOT NULL CHECK (current_revision >= 1),
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'revoked')),
|
||||
created_at TEXT NOT NULL,
|
||||
rotated_at TEXT,
|
||||
PRIMARY KEY (workspace_id, credential_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_credential_revisions (
|
||||
workspace_id TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
public_key_algorithm TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, credential_id, revision),
|
||||
FOREIGN KEY (workspace_id, credential_id)
|
||||
REFERENCES repository_ssh_credentials(workspace_id, credential_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE server_secret_versions (
|
||||
workspace_id TEXT NOT NULL,
|
||||
secret_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
purpose TEXT NOT NULL CHECK (purpose IN ('private_key', 'passphrase')),
|
||||
encryption_algorithm TEXT NOT NULL CHECK (encryption_algorithm = 'aes-256-gcm-v1'),
|
||||
nonce BLOB NOT NULL CHECK (length(nonce) = 12),
|
||||
ciphertext BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, secret_id, revision, purpose),
|
||||
FOREIGN KEY (workspace_id, secret_id, revision)
|
||||
REFERENCES repository_ssh_credential_revisions(workspace_id, credential_id, revision)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_host_trusts (
|
||||
workspace_id TEXT NOT NULL,
|
||||
host_trust_id TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
key_algorithm TEXT NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
current_revision INTEGER NOT NULL CHECK (current_revision >= 1),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, host_trust_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_ssh_host_trust_revisions (
|
||||
workspace_id TEXT NOT NULL,
|
||||
host_trust_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
hostname TEXT NOT NULL,
|
||||
port INTEGER NOT NULL CHECK (port >= 1 AND port <= 65535),
|
||||
key_algorithm TEXT NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, host_trust_id, revision),
|
||||
FOREIGN KEY (workspace_id, host_trust_id)
|
||||
REFERENCES repository_ssh_host_trusts(workspace_id, host_trust_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_secret_operations (
|
||||
workspace_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
request_fingerprint TEXT NOT NULL,
|
||||
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('credential', 'host_trust')),
|
||||
resource_id TEXT NOT NULL,
|
||||
result_revision INTEGER NOT NULL CHECK (result_revision >= 1),
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, operation_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE repository_secret_audit_events (
|
||||
workspace_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
resource_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
actor_account_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, event_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX idx_repository_ssh_credentials_workspace_status
|
||||
ON repository_ssh_credentials(workspace_id, status, credential_id);
|
||||
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host
|
||||
ON repository_ssh_host_trusts(workspace_id, hostname, port);
|
||||
CREATE INDEX idx_repository_secret_audit_workspace_created
|
||||
ON repository_secret_audit_events(workspace_id, created_at, event_id);
|
||||
"#,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
@@ -9588,7 +9697,7 @@ mod tests {
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
let remote = conn
|
||||
.query_row(
|
||||
"SELECT source_kind, source_uri, source_revision, source_fingerprint, observed_status \
|
||||
@@ -9666,7 +9775,7 @@ mod tests {
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap();
|
||||
assert_eq!(plan.current_schema_version, 36);
|
||||
assert_eq!(plan.target_schema_version, 45);
|
||||
assert_eq!(plan.target_schema_version, 46);
|
||||
assert!(plan.migration_required);
|
||||
assert_eq!(plan.worker_count, 1);
|
||||
assert_eq!(plan.mappings[0].legacy_worker_id, 7);
|
||||
@@ -9680,7 +9789,7 @@ mod tests {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert!(table_exists(conn, "worker_diagnostics_archives")?);
|
||||
assert_eq!(current_schema_version(conn)?, 45);
|
||||
assert_eq!(current_schema_version(conn)?, 46);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
@@ -9816,7 +9925,7 @@ mod tests {
|
||||
),
|
||||
]
|
||||
);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
let foreign_key_error: Option<String> = conn
|
||||
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
|
||||
.optional()
|
||||
@@ -9945,7 +10054,7 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap());
|
||||
let controller_worker_id: String = conn
|
||||
.query_row(
|
||||
@@ -10063,7 +10172,7 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap());
|
||||
}
|
||||
|
||||
@@ -10081,7 +10190,7 @@ INSERT INTO worker_orphan_diagnostics (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
let settings = conn
|
||||
.query_row(
|
||||
"SELECT settings_revision, language FROM workspace_memory_settings \
|
||||
@@ -10122,7 +10231,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
assert!(table_exists(&conn, "flow_sources").unwrap());
|
||||
assert!(table_exists(&conn, "flow_source_revisions").unwrap());
|
||||
assert!(!table_exists(&conn, "flow_instances").unwrap());
|
||||
@@ -10189,7 +10298,7 @@ INSERT INTO worker_workdir_attachment_reservations (
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
let repositories_sql: String = conn
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'",
|
||||
@@ -10372,7 +10481,7 @@ INSERT INTO workdir_registry (
|
||||
let db = dir.path().join("control-plane.sqlite");
|
||||
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 46);
|
||||
assert!(
|
||||
!store
|
||||
.with_conn(|conn| table_exists(conn, "worker_workspace_credentials"))
|
||||
@@ -10389,7 +10498,7 @@ INSERT INTO workdir_registry (
|
||||
store.upsert_workspace(&record).await.unwrap();
|
||||
|
||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 45);
|
||||
assert_eq!(reopened.schema_version().await.unwrap(), 46);
|
||||
assert_eq!(
|
||||
reopened.get_workspace("local-dev").await.unwrap(),
|
||||
Some(record)
|
||||
@@ -11143,7 +11252,7 @@ INSERT INTO worker_registry (
|
||||
let migrated = SqliteWorkspaceStore::open(&db_path).unwrap();
|
||||
migrated
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(current_schema_version(conn)?, 45);
|
||||
assert_eq!(current_schema_version(conn)?, 46);
|
||||
assert_eq!(
|
||||
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))?,
|
||||
1,
|
||||
@@ -11492,14 +11601,21 @@ INSERT INTO worker_registry (
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE workdir_create_operations;
|
||||
DELETE FROM __yoi_schema_migrations WHERE version = 45;",
|
||||
"DROP TABLE repository_secret_audit_events;
|
||||
DROP TABLE repository_secret_operations;
|
||||
DROP TABLE server_secret_versions;
|
||||
DROP TABLE repository_ssh_credential_revisions;
|
||||
DROP TABLE repository_ssh_credentials;
|
||||
DROP TABLE repository_ssh_host_trust_revisions;
|
||||
DROP TABLE repository_ssh_host_trusts;
|
||||
DROP TABLE workdir_create_operations;
|
||||
DELETE FROM __yoi_schema_migrations WHERE version IN (45, 46);",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 44);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
assert!(table_exists(&conn, "workdir_create_operations").unwrap());
|
||||
let columns = table_columns(&conn, "workdir_create_operations").unwrap();
|
||||
for required in [
|
||||
@@ -11518,19 +11634,57 @@ INSERT INTO worker_registry (
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v46_adds_repository_ssh_secret_authority_to_v45_database() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute_batch(
|
||||
"DROP TABLE repository_secret_audit_events;
|
||||
DROP TABLE repository_secret_operations;
|
||||
DROP TABLE server_secret_versions;
|
||||
DROP TABLE repository_ssh_credential_revisions;
|
||||
DROP TABLE repository_ssh_credentials;
|
||||
DROP TABLE repository_ssh_host_trust_revisions;
|
||||
DROP TABLE repository_ssh_host_trusts;
|
||||
DELETE FROM __yoi_schema_migrations WHERE version = 46;",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
|
||||
apply_migrations(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
for table in [
|
||||
"repository_ssh_credentials",
|
||||
"repository_ssh_credential_revisions",
|
||||
"server_secret_versions",
|
||||
"repository_ssh_host_trusts",
|
||||
"repository_ssh_host_trust_revisions",
|
||||
"repository_secret_operations",
|
||||
"repository_secret_audit_events",
|
||||
] {
|
||||
assert!(table_exists(&conn, table).unwrap(), "missing table {table}");
|
||||
}
|
||||
let foreign_key_error: Option<String> = conn
|
||||
.query_row("PRAGMA foreign_key_check", [], |row| row.get(0))
|
||||
.optional()
|
||||
.unwrap();
|
||||
assert!(foreign_key_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_refuses_a_database_from_a_newer_schema_generation() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
apply_migrations(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (46, 'future')",
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (47, 'future')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = apply_migrations(&conn).unwrap_err().to_string();
|
||||
assert!(error.contains("schema version 46 is newer"), "{error}");
|
||||
assert!(error.contains("schema version 47 is newer"), "{error}");
|
||||
assert!(error.contains("refusing to serve"), "{error}");
|
||||
}
|
||||
|
||||
@@ -11751,7 +11905,7 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-
|
||||
|
||||
apply_migrations(&mut conn).unwrap();
|
||||
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 45);
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 46);
|
||||
let workspace_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'",
|
||||
@@ -12374,7 +12528,7 @@ WHERE workspace_id = 'workspace-a'
|
||||
.unwrap();
|
||||
|
||||
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 46);
|
||||
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
@@ -12563,7 +12717,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn repository_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 46);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -12641,7 +12795,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn memory_authority_records_round_trip_and_close_staging() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 46);
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "local-dev".to_string(),
|
||||
owner_account_id: None,
|
||||
@@ -13048,7 +13202,7 @@ CREATE TABLE ticket_assignment_operations (
|
||||
#[tokio::test]
|
||||
async fn account_and_login_records_round_trip() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
assert_eq!(store.schema_version().await.unwrap(), 45);
|
||||
assert_eq!(store.schema_version().await.unwrap(), 46);
|
||||
let now = "2026-07-22T00:00:00Z".to_string();
|
||||
let account = AccountRecord {
|
||||
account_id: "acct-user-alice".to_string(),
|
||||
|
||||
Reference in New Issue
Block a user