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
39 changes: 39 additions & 0 deletions apps/api/migrations/0022_registry_accounts.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Account-level Knotree Registry connection: one consent per Cloud user grants
-- pull access to that user's whole Registry namespace. Project connections
-- created from it reference the account and always use its live credential.
CREATE TABLE knotree_registry_accounts (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
registry_username TEXT NOT NULL CHECK (char_length(registry_username) BETWEEN 1 AND 128),
credential_ciphertext TEXT NOT NULL,
delegated_credential_id UUID NOT NULL,
credential_expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX knotree_registry_accounts_active_user
ON knotree_registry_accounts (user_id) WHERE revoked_at IS NULL;

CREATE TABLE registry_account_consent_attempts (
state_hash BYTEA PRIMARY KEY,
session_hash BYTEA NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
issuer TEXT NOT NULL,
subject TEXT NOT NULL,
verifier_ciphertext TEXT NOT NULL,
return_to TEXT,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX registry_account_consent_attempts_expiry
ON registry_account_consent_attempts (expires_at);

ALTER TABLE knotree_registry_connections
ADD COLUMN account_id UUID REFERENCES knotree_registry_accounts(id) ON DELETE CASCADE;

CREATE UNIQUE INDEX knotree_registry_connections_account_repository
ON knotree_registry_connections (project_id, account_id, repository)
WHERE account_id IS NOT NULL AND revoked_at IS NULL;
99 changes: 82 additions & 17 deletions apps/api/src/knotree_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,26 @@ struct RegistryEventMetadata {
registry: Option<String>,
tagged_image: Option<String>,
is_tag: Option<bool>,
#[serde(default)]
owner_issuer: Option<String>,
#[serde(default)]
owner_subject: Option<String>,
}

/// An account-derived connection auto-deploys only when Registry reports the
/// pushed namespace belongs to the same central identity that connected it.
/// Legacy per-repository connections keep their verified-at-consent scope.
fn owner_matches(
account_identity: Option<(&str, &str)>,
metadata: &RegistryEventMetadata,
) -> bool {
match account_identity {
None => true,
Some((issuer, subject)) => {
metadata.owner_issuer.as_deref() == Some(issuer)
&& metadata.owner_subject.as_deref() == Some(subject)
}
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand Down Expand Up @@ -219,7 +239,8 @@ pub async fn update_connection(
let connection = sqlx::query_as::<_, RegistryConnectionRow>(
"SELECT id, registry_username, repository, verified_at
FROM knotree_registry_connections
WHERE id = $1 AND project_id = $2 AND revoked_at IS NULL",
WHERE id = $1 AND project_id = $2 AND revoked_at IS NULL
AND account_id IS NULL",
)
.bind(connection_id)
.bind(project_id)
Expand Down Expand Up @@ -376,11 +397,17 @@ pub(crate) async fn load_credentials(
project_id: Uuid,
connection_id: Uuid,
) -> Result<Option<RegistryDockerCredentials>, AppError> {
// Connections created from an account always use the account's current
// credential, so reconnecting the account renews every derived project.
let row = sqlx::query_as::<_, (String, String)>(
"SELECT registry_username, credential_ciphertext
FROM knotree_registry_connections
WHERE id = $1 AND project_id = $2 AND revoked_at IS NULL
AND (credential_expires_at IS NULL OR credential_expires_at > now())",
"SELECT connection.registry_username,
COALESCE(account.credential_ciphertext, connection.credential_ciphertext)
FROM knotree_registry_connections AS connection
LEFT JOIN knotree_registry_accounts AS account ON account.id = connection.account_id
WHERE connection.id = $1 AND connection.project_id = $2 AND connection.revoked_at IS NULL
AND (connection.credential_expires_at IS NULL OR connection.credential_expires_at > now())
AND (connection.account_id IS NULL
OR (account.revoked_at IS NULL AND account.credential_expires_at > now()))",
)
.bind(connection_id)
.bind(project_id)
Expand Down Expand Up @@ -735,24 +762,43 @@ async fn persist_registry_event(
}
let image_ref =
immutable_image(repository, digest).expect("event repository and digest were validated");
let services = sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, image
FROM project_app_services
WHERE image_source = 'knotree_registry'
AND auto_deploy_enabled = TRUE
AND status IN ('ready', 'provisioning')
AND registry_connection_id IN (
SELECT id FROM knotree_registry_connections WHERE revoked_at IS NULL
AND (credential_expires_at IS NULL OR credential_expires_at > now())
)",
let services = sqlx::query_as::<_, (Uuid, String, String, Option<String>, Option<String>)>(
"SELECT service.id, service.image, connection.repository,
account.issuer, account.subject
FROM project_app_services AS service
JOIN knotree_registry_connections AS connection
ON connection.id = service.registry_connection_id
LEFT JOIN knotree_registry_accounts AS account ON account.id = connection.account_id
WHERE service.image_source = 'knotree_registry'
AND service.auto_deploy_enabled = TRUE
AND service.status IN ('ready', 'provisioning')
AND connection.revoked_at IS NULL
AND (connection.credential_expires_at IS NULL OR connection.credential_expires_at > now())
AND (connection.account_id IS NULL
OR (account.revoked_at IS NULL AND account.credential_expires_at > now()))",
)
.fetch_all(&mut *transaction)
.await?;
for (service_id, image) in services {
for (service_id, image, connected_repository, account_issuer, account_subject) in services {
let Some(target) = parse_registry_image(&image) else {
continue;
};
if target.repository != repository || target.tag != tag {
if target.repository != repository
|| target.tag != tag
|| connected_repository != repository
{
continue;
}
let account_identity = match (account_issuer.as_deref(), account_subject.as_deref()) {
(Some(issuer), Some(subject)) => Some((issuer, subject)),
_ => None,
};
if !owner_matches(account_identity, &event.metadata) {
tracing::warn!(
delivery_id = %delivery_id,
app_service_id = %service_id,
"ignoring Knotree Registry push whose owner does not match the connected account"
);
continue;
}
sqlx::query(
Expand Down Expand Up @@ -850,6 +896,25 @@ mod tests {
));
}

#[test]
fn account_connections_deploy_only_for_matching_owner() {
let metadata = |issuer: Option<&str>, subject: Option<&str>| RegistryEventMetadata {
registry: Some(REGISTRY_HOST.into()),
tagged_image: None,
is_tag: Some(true),
owner_issuer: issuer.map(Into::into),
owner_subject: subject.map(Into::into),
};
let issuer = "https://accounts.knotree.com";
let alice = Some((issuer, "alice"));
assert!(owner_matches(alice, &metadata(Some(issuer), Some("alice"))));
assert!(!owner_matches(alice, &metadata(Some(issuer), Some("bob"))));
assert!(!owner_matches(alice, &metadata(Some("https://evil.example"), Some("alice"))));
assert!(!owner_matches(alice, &metadata(None, None)));
// Legacy repository-scoped connections are unaffected.
assert!(owner_matches(None, &metadata(None, None)));
}

#[test]
fn creates_registry_specific_docker_auth_without_returning_plaintext() {
let config = docker_config_json("service-user", "pull-token").unwrap();
Expand Down
21 changes: 21 additions & 0 deletions apps/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub mod models;
pub mod projects;
pub mod public_access;
pub mod redis_resources;
pub mod registry_accounts;
pub mod registry_consent;
pub mod resources;
pub mod security;
Expand Down Expand Up @@ -48,6 +49,26 @@ pub fn router(state: AppState) -> Router {
let api = Router::new()
.route("/auth/knotree-registry/callback", get(registry_consent::callback))
.route("/workspaces/{workspace_id}/projects/{project_slug}/registry-connections/authorize", post(registry_consent::start))
.route(
"/workspaces/{workspace_id}/projects/{project_slug}/registry-connections/from-account",
post(registry_accounts::import_into_project),
)
.route(
"/integrations/knotree-registry",
get(registry_accounts::status).delete(registry_accounts::disconnect),
)
.route(
"/integrations/knotree-registry/authorize",
post(registry_accounts::start),
)
.route(
"/integrations/knotree-registry/repositories",
get(registry_accounts::repositories),
)
.route(
"/integrations/knotree-registry/repositories/{*repository}",
get(registry_accounts::repository_tags),
)
.route("/auth/sso/config", get(sso::configuration))
.route("/auth/sso/start", get(sso::start))
.route("/auth/sso/callback", get(sso::callback))
Expand Down
Loading
Loading