diff --git a/frontend/src/components/registry/dialogs/repository-sync-dialog.tsx b/frontend/src/components/registry/dialogs/repository-sync-dialog.tsx index c3b230e002..dce0ea7c97 100644 --- a/frontend/src/components/registry/dialogs/repository-sync-dialog.tsx +++ b/frontend/src/components/registry/dialogs/repository-sync-dialog.tsx @@ -28,6 +28,7 @@ interface SyncRepositoryDialogProps { params: RegistryRepositoriesSyncRegistryRepositoryData ) => Promise syncRepoIsPending: boolean + force?: boolean } export function SyncRepositoryDialog({ @@ -37,7 +38,15 @@ export function SyncRepositoryDialog({ setSelectedRepo, syncRepo, syncRepoIsPending, + force = false, }: SyncRepositoryDialogProps) { + let actionLabel = "Sync" + if (syncRepoIsPending) { + actionLabel = "Syncing..." + } else if (force) { + actionLabel = "Force sync" + } + const handleSync = async () => { if (!selectedRepo) { console.error("No repository selected") @@ -59,7 +68,10 @@ export function SyncRepositoryDialog({ ), }) - await syncRepo({ repositoryId: selectedRepo.id }) + await syncRepo({ + repositoryId: selectedRepo.id, + requestBody: { force }, + }) toast({ title: "Successfully synced repository", description: ( @@ -82,12 +94,20 @@ export function SyncRepositoryDialog({ - Sync repository + + {force ? "Force sync repository" : "Sync repository"} + You are about to pull the latest version of the repository. + {force ? ( + + Force sync deletes the existing synced version before + reloading actions from the remote repository. + + ) : null} {selectedRepo?.origin} @@ -121,7 +141,7 @@ export function SyncRepositoryDialog({ - {syncRepoIsPending ? "Syncing..." : "Sync"} + {actionLabel} diff --git a/frontend/src/components/registry/workspace-actions-controls.tsx b/frontend/src/components/registry/workspace-actions-controls.tsx index 0424fb028d..339a1236fa 100644 --- a/frontend/src/components/registry/workspace-actions-controls.tsx +++ b/frontend/src/components/registry/workspace-actions-controls.tsx @@ -27,14 +27,16 @@ import { toast } from "@/components/ui/use-toast" import { useRegistryRepositories } from "@/lib/hooks" import { copyToClipboard } from "@/lib/utils" -type ActiveDialog = "sync" | "commit" | "versions" | null +type ActiveDialog = "sync" | "force-sync" | "commit" | "versions" | null function RegistryActionsControlsMenu() { const canUpdateRegistry = useScopeCheck("org:registry:update") === true + const canDeleteRegistry = useScopeCheck("org:registry:delete") === true const { repos, syncRepo, syncRepoIsPending } = useRegistryRepositories() const customRepo = getCustomRegistryRepository(repos) const showAddRegistry = canUpdateRegistry const showRegistryActions = canUpdateRegistry && !!customRepo + const showForceSync = showRegistryActions && canDeleteRegistry const showCopyOrigin = !!customRepo const hasVisibleActions = showAddRegistry || showRegistryActions || showCopyOrigin @@ -134,6 +136,15 @@ function RegistryActionsControlsMenu() { ) : null} + {showForceSync ? ( + handleOpenDialog("force-sync")} + > + + Force sync from remote + + ) : null} + {showRegistryActions ? ( handleOpenDialog("commit")}> @@ -160,7 +171,7 @@ function RegistryActionsControlsMenu() { { if (!open) { setActiveDialog(null) @@ -170,6 +181,7 @@ function RegistryActionsControlsMenu() { setSelectedRepo={setSelectedRepo} syncRepo={syncRepo} syncRepoIsPending={syncRepoIsPending} + force={activeDialog === "force-sync"} /> Role: ) +@pytest.fixture +def role_with_update_scope() -> Role: + return Role( + type="service", + service_id="tracecat-api", + workspace_id=uuid.uuid4(), + organization_id=uuid.uuid4(), + user_id=uuid.uuid4(), + scopes=frozenset({"org:registry:update"}), + ) + + @pytest.fixture def role_without_registry_scopes() -> Role: return Role( @@ -107,6 +120,27 @@ async def test_sync_repository_requires_registry_update_scope( await service.sync_repository(repository, RegistryRepositorySync(force=False)) +@pytest.mark.anyio +async def test_force_sync_repository_requires_registry_delete_scope( + role_with_update_scope: Role, +) -> None: + """force sync must reject roles missing org:registry:delete.""" + service = RegistryReposService(AsyncMock(), role=role_with_update_scope) + repository = RegistryRepository( + organization_id=role_with_update_scope.organization_id, + origin=DEFAULT_REGISTRY_ORIGIN, + ) + + with pytest.raises(ScopeDeniedError) as exc_info: + await service.sync_repository(repository, RegistryRepositorySync(force=True)) + + assert exc_info.value.detail == { + "code": "insufficient_scope", + "required_scopes": ["org:registry:delete"], + "missing_scopes": ["org:registry:delete"], + } + + @pytest.mark.anyio async def test_list_repositories_requires_registry_read_scope( role_without_registry_scopes: Role, diff --git a/tracecat/registry/repositories/service.py b/tracecat/registry/repositories/service.py index 08801eeb59..5ea2867843 100644 --- a/tracecat/registry/repositories/service.py +++ b/tracecat/registry/repositories/service.py @@ -7,9 +7,9 @@ from sqlalchemy import select from sqlalchemy.orm import selectinload -from tracecat.authz.controls import require_scope +from tracecat.authz.controls import has_scope, require_scope from tracecat.db.models import RegistryRepository, RegistryVersion -from tracecat.exceptions import RegistryError, RegistryNotFound +from tracecat.exceptions import RegistryError, RegistryNotFound, ScopeDeniedError from tracecat.registry.constants import DEFAULT_REGISTRY_ORIGIN from tracecat.registry.repositories.schemas import ( RegistryRepositoryCreate, @@ -122,6 +122,16 @@ async def sync_repository( if repository.organization_id != self.organization_id: raise RegistryNotFound("Registry repository not found") + target_commit_sha = sync_params.target_commit_sha if sync_params else None + force = sync_params.force if sync_params else False + if force and not has_scope( + self.role.scopes or frozenset(), "org:registry:delete" + ): + raise ScopeDeniedError( + required_scopes=["org:registry:delete"], + missing_scopes=["org:registry:delete"], + ) + if repository.origin != DEFAULT_REGISTRY_ORIGIN: await check_entitlement( self.session, self.role, Entitlement.CUSTOM_REGISTRY @@ -129,8 +139,6 @@ async def sync_repository( actions_service = RegistryActionsService(self.session, self.role) last_synced_at = datetime.now(UTC) - target_commit_sha = sync_params.target_commit_sha if sync_params else None - force = sync_params.force if sync_params else False is_git_ssh = repository.origin.startswith("git+ssh://") git_repo_package_name: str | None = None