Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/api/src/knotree_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ struct RegistryEventMetadata {
owner_issuer: Option<String>,
#[serde(default)]
owner_subject: Option<String>,
#[serde(default)]
credential_id: Option<Uuid>,
}

/// An account-derived connection auto-deploys only when Registry reports the
Expand Down Expand Up @@ -663,6 +665,26 @@ pub async fn webhook(State(state): State<AppState>, headers: HeaderMap, body: By
if event.schema_version != 1 || event.kind != event_header {
return StatusCode::BAD_REQUEST;
}
if event.kind == "grant_revoked" {
let Some(credential_id) = event.metadata.credential_id else {
return StatusCode::BAD_REQUEST;
};
let owner = event
.metadata
.owner_issuer
.as_deref()
.zip(event.metadata.owner_subject.as_deref());
return match crate::registry_accounts::revoke_from_registry(&state, credential_id, owner)
.await
{
Ok(()) => StatusCode::NO_CONTENT,
Err(error) => {
tracing::error!(delivery_id = %delivery_id, error = ?error,
"could not apply Knotree Registry grant revocation");
StatusCode::INTERNAL_SERVER_ERROR
}
};
}
if event.kind != "tag_updated" {
return StatusCode::NO_CONTENT;
}
Expand Down Expand Up @@ -904,6 +926,7 @@ mod tests {
is_tag: Some(true),
owner_issuer: issuer.map(Into::into),
owner_subject: subject.map(Into::into),
credential_id: None,
};
let issuer = "https://accounts.knotree.com";
let alice = Some((issuer, "alice"));
Expand Down
156 changes: 144 additions & 12 deletions apps/api/src/registry_accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ pub(crate) async fn complete_callback(
"connected"
};
let mut destination = Url::parse(&config.frontend_url).map_err(|_| invalid())?;
let path = attempt.return_to.as_deref().unwrap_or("/integrations");
let path = attempt.return_to.as_deref().unwrap_or("/settings/integrations");
let (path, query) = path.split_once('?').unwrap_or((path, ""));
destination.set_path(path);
destination.set_query((!query.is_empty()).then_some(query));
Expand Down Expand Up @@ -334,13 +334,74 @@ async fn revoke_account_rows(
.bind(account_id)
.fetch_all(&mut **transaction)
.await?;
stop_connection_deploys(transaction, &connection_ids, reason).await
}

/// Registry reported that the user revoked a credential it issued to Cloud.
/// Revokes the account that holds it (only when the reported owner matches)
/// and any legacy repository connection, then stops their auto-deploys.
pub(crate) async fn revoke_from_registry(
state: &AppState,
credential_id: Uuid,
owner: Option<(&str, &str)>,
) -> Result<(), AppError> {
const REASON: &str = "Knotree Registry access was revoked in Registry. Reconnect to continue.";
let mut transaction = state.db.begin().await?;
let mut service_ids = Vec::new();
if let Some((issuer, subject)) = owner {
let account_ids: Vec<Uuid> = sqlx::query_scalar(
"SELECT id FROM knotree_registry_accounts
WHERE delegated_credential_id=$1 AND issuer=$2 AND subject=$3 AND revoked_at IS NULL",
)
.bind(credential_id)
.bind(issuer)
.bind(subject)
.fetch_all(&mut *transaction)
.await?;
for account_id in account_ids {
service_ids.extend(revoke_account_rows(&mut transaction, account_id, REASON).await?);
}
}
let connection_ids: Vec<Uuid> = sqlx::query_scalar(
"UPDATE knotree_registry_connections SET revoked_at=now(), updated_at=now()
WHERE delegated_credential_id=$1 AND account_id IS NULL AND revoked_at IS NULL
RETURNING id",
)
.bind(credential_id)
.fetch_all(&mut *transaction)
.await?;
service_ids.extend(stop_connection_deploys(&mut transaction, &connection_ids, REASON).await?);
transaction.commit().await?;
remove_pull_secrets(state, service_ids).await;
Ok(())
}

async fn remove_pull_secrets(state: &AppState, service_ids: Vec<Uuid>) {
if !state.config.uses_kubernetes_workloads() {
return;
}
for service_id in service_ids {
if let Err(error) =
crate::cluster_kubernetes::delete_app_image_pull_secret(&state.config, service_id).await
{
tracing::warn!(app_service_id = %service_id, error = %error,
"could not remove Knotree Registry Kubernetes pull Secret");
}
}
}

async fn stop_connection_deploys(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
connection_ids: &[Uuid],
reason: &str,
) -> Result<Vec<Uuid>, AppError> {
let service_ids: Vec<Uuid> = sqlx::query_scalar(
"UPDATE project_app_services
SET auto_deploy_enabled = FALSE, registry_connection_id = NULL,
auto_deploy_error = $2, updated_at = now()
WHERE registry_connection_id = ANY($1) RETURNING id",
)
.bind(&connection_ids)
.bind(connection_ids)
.bind(reason)
.fetch_all(&mut **transaction)
.await?;
Expand Down Expand Up @@ -424,16 +485,7 @@ pub async fn disconnect(
)
.await?;
transaction.commit().await?;
if state.config.uses_kubernetes_workloads() {
for service_id in service_ids {
if let Err(error) =
crate::cluster_kubernetes::delete_app_image_pull_secret(&state.config, service_id).await
{
tracing::warn!(app_service_id = %service_id, error = %error,
"could not remove Knotree Registry Kubernetes pull Secret");
}
}
}
remove_pull_secrets(&state, service_ids).await;
Ok(StatusCode::NO_CONTENT)
}

Expand Down Expand Up @@ -739,4 +791,84 @@ mod tests {
assert!(safe_return_to(Some("/\\evil.example")).is_none());
assert!(safe_return_to(None).is_none());
}

#[tokio::test]
async fn registry_revocation_only_revokes_the_reported_owner() {
use crate::test_support::{seed_owner_project, test_app_state};
let Some(state) = test_app_state().await else {
return;
};
let project = seed_owner_project(&state).await;
let issuer = "https://accounts.knotree.com";
let subject = project.user_id.to_string();
let credential_id = Uuid::new_v4();
let account_id = Uuid::new_v4();
sqlx::query(
"INSERT INTO knotree_registry_accounts
(id, user_id, issuer, subject, registry_username, credential_ciphertext,
delegated_credential_id, credential_expires_at)
VALUES ($1, $2, $3, $4, 'kt-owner', 'unused', $5, now() + interval '30 days')",
)
.bind(account_id)
.bind(project.user_id)
.bind(issuer)
.bind(&subject)
.bind(credential_id)
.execute(&state.db)
.await
.unwrap();
let derived = Uuid::new_v4();
let legacy_credential = Uuid::new_v4();
let legacy = Uuid::new_v4();
for (id, account, delegated) in [
(derived, Some(account_id), None),
(legacy, None, Some(legacy_credential)),
] {
sqlx::query(
"INSERT INTO knotree_registry_connections
(id, project_id, user_id, registry_username, repository,
credential_ciphertext, account_id, delegated_credential_id)
VALUES ($1, $2, $3, 'kt-owner', 'kt-owner/app', 'unused', $4, $5)",
)
.bind(id)
.bind(project.project_id)
.bind(project.user_id)
.bind(account)
.bind(delegated)
.execute(&state.db)
.await
.unwrap();
}
let revoked = |table: &'static str, id: Uuid| {
let db = state.db.clone();
async move {
sqlx::query_scalar::<_, bool>(&format!(
"SELECT revoked_at IS NOT NULL FROM {table} WHERE id = $1"
))
.bind(id)
.fetch_one(&db)
.await
.unwrap()
}
};

// A different owner cannot revoke someone else's account.
revoke_from_registry(&state, credential_id, Some((issuer, "someone-else")))
.await
.unwrap();
assert!(!revoked("knotree_registry_accounts", account_id).await);
assert!(!revoked("knotree_registry_connections", derived).await);

revoke_from_registry(&state, credential_id, Some((issuer, &subject)))
.await
.unwrap();
assert!(revoked("knotree_registry_accounts", account_id).await);
assert!(revoked("knotree_registry_connections", derived).await);
assert!(!revoked("knotree_registry_connections", legacy).await);

revoke_from_registry(&state, legacy_credential, None).await.unwrap();
assert!(revoked("knotree_registry_connections", legacy).await);
// Replays are harmless.
revoke_from_registry(&state, legacy_credential, None).await.unwrap();
}
}
128 changes: 128 additions & 0 deletions apps/web/src/components/app-service-create-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ const mocks = vi.hoisted(() => ({
getGithubConnectionStatus: vi.fn(),
getGithubAuthorizationUrl: vi.fn(),
listAppServices: vi.fn(),
getKnotreeRegistryAccount: vi.fn(),
listKnotreeRegistryRepositories: vi.fn(),
listKnotreeRegistryTags: vi.fn(),
importKnotreeRegistryRepository: vi.fn(),
startKnotreeRegistryAccountConsent: vi.fn(),
}))

vi.mock("@/lib/resources", () => ({
Expand All @@ -23,11 +28,26 @@ vi.mock("@/lib/resources", () => ({
listAppServices: mocks.listAppServices,
listKnotreeRegistryConnections: mocks.listKnotreeRegistryConnections,
appServiceDeploymentEventsUrl: () => "/events",
getKnotreeRegistryAccount: mocks.getKnotreeRegistryAccount,
listKnotreeRegistryRepositories: mocks.listKnotreeRegistryRepositories,
listKnotreeRegistryTags: mocks.listKnotreeRegistryTags,
importKnotreeRegistryRepository: mocks.importKnotreeRegistryRepository,
startKnotreeRegistryAccountConsent: mocks.startKnotreeRegistryAccountConsent,
isKnotreeRegistryAuthorizationUrl: (value: string) =>
value.startsWith("https://registry.knotree.com/cloud/authorize/"),
}))

describe("AppServiceCreateDialog HTML pages", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getKnotreeRegistryAccount.mockResolvedValue({
connected: false,
consentReady: true,
autoDeployReady: false,
namespace: null,
expiresAt: null,
expired: false,
})
mocks.createAppService.mockResolvedValue({
id: "svc-1",
name: "Docs",
Expand Down Expand Up @@ -264,4 +284,112 @@ describe("AppServiceCreateDialog HTML pages", () => {
)
})
})

it("imports an image from the connected Registry account with auto-deploy", async () => {
const user = userEvent.setup()
mocks.getKnotreeRegistryAccount.mockResolvedValue({
connected: true,
consentReady: true,
autoDeployReady: true,
namespace: "kt-owner",
expiresAt: "2026-11-01T00:00:00Z",
expired: false,
})
mocks.listKnotreeRegistryConnections.mockResolvedValue({
connections: [],
autoDeployReady: true,
consentReady: true,
})
mocks.listKnotreeRegistryRepositories.mockResolvedValue({
namespace: "kt-owner",
registryHost: "registry.knotree.com",
repositories: [
{
name: "kt-owner/api",
tagCount: 2,
latestTag: "production",
latestDigest: null,
size: 0,
updatedAt: null,
},
],
})
mocks.listKnotreeRegistryTags.mockResolvedValue({
repository: "kt-owner/api",
tags: [
{
tag: "production",
digest: `sha256:${"a".repeat(64)}`,
size: 0,
createdAt: null,
},
{
tag: "latest",
digest: `sha256:${"b".repeat(64)}`,
size: 0,
createdAt: null,
},
],
})
mocks.importKnotreeRegistryRepository.mockResolvedValue({
id: "account-connection-1",
registryHost: "registry.knotree.com",
username: "kt-owner",
repository: "kt-owner/api",
verifiedAt: "2026-10-03T00:00:00Z",
})

render(
<AppServiceCreateDialog
workspaceId="de305d54-75b4-431b-adb2-eb6b9e546014"
projectSlug="proj"
open
onOpenChange={vi.fn()}
onCreated={vi.fn()}
/>
)
await user.selectOptions(
screen.getByLabelText("Image access"),
"knotree_registry"
)
await user.click(
await screen.findByRole("button", { name: /kt-owner\/api/ })
)
await waitFor(() =>
expect(screen.getByLabelText("Docker image")).toHaveValue(
"registry.knotree.com/kt-owner/api:production"
)
)
await user.selectOptions(screen.getByLabelText("Tag"), "latest")
expect(screen.getByLabelText("Docker image")).toHaveValue(
"registry.knotree.com/kt-owner/api:latest"
)
// The per-repository token fields are not needed for the user's own images.
expect(screen.queryByLabelText("Pull-only access token")).toBeNull()
await user.click(
screen.getByRole("checkbox", { name: /Auto-deploy new image digests/ })
)
await user.click(screen.getByRole("button", { name: "Deploy service" }))

await waitFor(() => {
expect(mocks.importKnotreeRegistryRepository).toHaveBeenCalledWith(
"de305d54-75b4-431b-adb2-eb6b9e546014",
"proj",
"kt-owner/api"
)
expect(mocks.createAppService).toHaveBeenCalledWith(
"de305d54-75b4-431b-adb2-eb6b9e546014",
"proj",
{
name: "App service",
image: "registry.knotree.com/kt-owner/api:latest",
imageSource: "knotree_registry",
appPort: 3000,
registryConnectionId: "account-connection-1",
autoDeploy: true,
}
)
})
expect(mocks.createKnotreeRegistryConnection).not.toHaveBeenCalled()
})
})
Loading
Loading