Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface SyncRepositoryDialogProps {
params: RegistryRepositoriesSyncRegistryRepositoryData
) => Promise<tracecat__registry__repositories__schemas__RegistrySyncResponse>
syncRepoIsPending: boolean
force?: boolean
}

export function SyncRepositoryDialog({
Expand All @@ -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")
Expand All @@ -59,7 +68,10 @@ export function SyncRepositoryDialog({
</span>
),
})
await syncRepo({ repositoryId: selectedRepo.id })
await syncRepo({
repositoryId: selectedRepo.id,
requestBody: { force },
})
toast({
title: "Successfully synced repository",
description: (
Expand All @@ -82,12 +94,20 @@ export function SyncRepositoryDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle>Sync repository</AlertDialogTitle>
<AlertDialogTitle>
{force ? "Force sync repository" : "Sync repository"}
</AlertDialogTitle>
<AlertDialogDescription>
<span className="flex flex-col space-y-3">
<span>
You are about to pull the latest version of the repository.
</span>
{force ? (
<span className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
Force sync deletes the existing synced version before
reloading actions from the remote repository.
</span>
) : null}
<span className="max-w-full rounded-md border px-3 py-2 font-mono text-sm font-semibold tracking-tight text-foreground break-all whitespace-normal">
{selectedRepo?.origin}
</span>
Expand Down Expand Up @@ -121,7 +141,7 @@ export function SyncRepositoryDialog({
<RefreshCcw
className={`size-4 ${syncRepoIsPending ? "animate-spin" : ""}`}
/>
<span>{syncRepoIsPending ? "Syncing..." : "Sync"}</span>
<span>{actionLabel}</span>
</div>
</AlertDialogAction>
</AlertDialogFooter>
Expand Down
16 changes: 14 additions & 2 deletions frontend/src/components/registry/workspace-actions-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,6 +136,15 @@ function RegistryActionsControlsMenu() {
</DropdownMenuItem>
) : null}

{showForceSync ? (
<DropdownMenuItem
onSelect={() => handleOpenDialog("force-sync")}
Comment thread
daryllimyt marked this conversation as resolved.
>
<RefreshCcw className="mr-2 size-4" />
<span>Force sync from remote</span>
</DropdownMenuItem>
) : null}

{showRegistryActions ? (
<DropdownMenuItem onSelect={() => handleOpenDialog("commit")}>
<GitBranchIcon className="mr-2 size-4" />
Expand All @@ -160,7 +171,7 @@ function RegistryActionsControlsMenu() {
</DropdownMenu>

<SyncRepositoryDialog
open={activeDialog === "sync"}
open={activeDialog === "sync" || activeDialog === "force-sync"}
onOpenChange={(open) => {
if (!open) {
setActiveDialog(null)
Expand All @@ -170,6 +181,7 @@ function RegistryActionsControlsMenu() {
setSelectedRepo={setSelectedRepo}
syncRepo={syncRepo}
syncRepoIsPending={syncRepoIsPending}
force={activeDialog === "force-sync"}
/>

<CommitSelectorDialog
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/test_registry_repositories_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from tracecat.auth.types import Role
from tracecat.db.models import RegistryRepository
from tracecat.exceptions import RegistryNotFound, ScopeDeniedError
from tracecat.registry.constants import DEFAULT_REGISTRY_ORIGIN
from tracecat.registry.repositories.schemas import RegistryRepositorySync
from tracecat.registry.repositories.service import RegistryReposService

Expand Down Expand Up @@ -38,6 +39,18 @@ def role_with_read_only_scope() -> 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(
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 12 additions & 4 deletions tracecat/registry/repositories/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,15 +122,23 @@ 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: ScopeDeniedError raised here will be caught by the route handler's broad except Exception block in router.py, which converts it into a generic 500 response instead of the intended 403 scope-denied response. The route only re-raises EntitlementRequired/RegistryNotFound/HTTPException before its catch-all. Either add ScopeDeniedError to the route's explicit exception handling, or let it propagate by adding it to the re-raise conditions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tracecat/registry/repositories/service.py, line 130:

<comment>`ScopeDeniedError` raised here will be caught by the route handler's broad `except Exception` block in `router.py`, which converts it into a generic 500 response instead of the intended 403 scope-denied response. The route only re-raises `EntitlementRequired`/`RegistryNotFound`/`HTTPException` before its catch-all. Either add `ScopeDeniedError` to the route's explicit exception handling, or let it propagate by adding it to the re-raise conditions.</comment>

<file context>
@@ -122,15 +122,23 @@ async def sync_repository(
+        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"],
</file context>

required_scopes=["org:registry:delete"],
missing_scopes=["org:registry:delete"],
)
Comment on lines +130 to +133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Let scope denials propagate as 403

When a caller has org:registry:update but not org:registry:delete and sends force: true, this new check raises ScopeDeniedError from inside sync_registry_repository's try block. That route only re-raises EntitlementRequired/HTTPException before its broad except Exception, so the intended authorization failure is converted into a 500 "Unexpected error" instead of the app-level 403 scope-denied response. Re-raise or catch ScopeDeniedError in the route so missing delete scope is reported correctly.

Useful? React with 👍 / 👎.


if repository.origin != DEFAULT_REGISTRY_ORIGIN:
await check_entitlement(
self.session, self.role, Entitlement.CUSTOM_REGISTRY
)

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
Expand Down
Loading