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
7 changes: 6 additions & 1 deletion backend/druks/api/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from druks.durable.models import Artifact, Run
from druks.events.models import Event
from druks.settings import get_settings
from druks.teams.dependencies import schedule_app_access

PREVIEW_SIZE = 4
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
Expand Down Expand Up @@ -192,7 +193,11 @@ async def list_current_schedules(session: SessionDep, response: Response) -> Das
return DashboardSchedules(rows=rows)


@router.post("/schedules/{kind}/run", status_code=202)
@router.post(
"/schedules/{kind}/run",
status_code=202,
dependencies=[Depends(schedule_app_access)],
)
async def run_schedule(kind: str) -> dict[str, str]:
workflow = workflows.get(kind)
if not workflow or not workflow.every:
Expand Down
8 changes: 8 additions & 0 deletions backend/druks/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ def __init__(self, run_id: str) -> None:
super().__init__(f"No run {run_id}.")


class AppPermissionDenied(AgentApiError):
status_code = 403
code = "APP_PERMISSION_DENIED"

def __init__(self, app: str) -> None:
super().__init__(f"This account cannot use app {app!r}.")


class RunNotActive(AgentApiError):
status_code = 409
code = "RUN_NOT_ACTIVE"
Expand Down
17 changes: 14 additions & 3 deletions backend/druks/api/runs.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from typing import Annotated

from fastapi import APIRouter, Body, HTTPException, Path, status
from fastapi import APIRouter, Body, Depends, HTTPException, Path, status

from druks.accounts.context import current_account_id
from druks.api.dependencies import SessionDep
from druks.api.exceptions import (
AppPermissionDenied,
RunNotActive,
RunNotFailed,
RunNotFound,
Expand All @@ -19,11 +20,16 @@
from druks.events.models import Event
from druks.notifications.exceptions import AnswerNotAllowedError, InvalidChoiceError
from druks.notifications.services import validate_in_app_answer
from druks.teams.dependencies import run_app_access

router = APIRouter(prefix="/api/runs", tags=["runs"])


@router.post("/{run}/resume", status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/{run}/resume",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(run_app_access)],
)
async def resume_run(
session: SessionDep, run_id: Annotated[str, Path(alias="run")], body: ResumeRequest
) -> None:
Expand Down Expand Up @@ -60,9 +66,12 @@ async def resume_run(
operation_id="cancel_run",
tags=["agent"],
openapi_extra={"x-idempotent": True},
dependencies=[Depends(run_app_access)],
response_model=CancelRunResponse,
response_model_by_alias=True,
responses=agent_error_responses(RunNotFound("run-123"), RunNotActive("run-123")),
responses=agent_error_responses(
AppPermissionDenied("app-name"), RunNotFound("run-123"), RunNotActive("run-123")
),
)
async def cancel_run(
session: SessionDep,
Expand Down Expand Up @@ -111,9 +120,11 @@ async def cancel_run(
operation_id="retry_run",
tags=["agent"],
openapi_extra={"x-destructive": False},
dependencies=[Depends(run_app_access)],
response_model=RetryRunResponse,
response_model_by_alias=True,
responses=agent_error_responses(
AppPermissionDenied("app-name"),
RunNotFound("run-123"),
RunNotFailed("run-123"),
SubjectBusy("run-456"),
Expand Down
7 changes: 6 additions & 1 deletion backend/druks/api/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
from druks.durable.engine import configure_engine, init_dbos, launch, shutdown
from druks.durable.exceptions import AgentCallNotFound
from druks.events.routes import router as events_router
from druks.exceptions import ObjectNotFound, ValidationError
from druks.exceptions import ObjectNotFound, PermissionDenied, ValidationError
from druks.files.routes import router as files_router
from druks.harnesses.exceptions import AgentConfigError, CatalogError
from druks.harnesses.routes import router as providers_router
Expand Down Expand Up @@ -187,6 +187,11 @@ async def _validation_error_handler(request: Request, exc: ValidationError) -> J
return await _http_exception_handler(request, HTTPException(422, str(exc)))


@app.exception_handler(PermissionDenied)
async def _permission_denied_handler(request: Request, exc: PermissionDenied) -> JSONResponse:
return await _http_exception_handler(request, HTTPException(403, str(exc)))


@app.exception_handler(PageReadError)
async def _page_read_error_handler(request: Request, exc: PageReadError) -> JSONResponse:
logging.getLogger(__name__).exception("page read failed: %s", exc)
Expand Down
3 changes: 2 additions & 1 deletion backend/druks/apps/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from druks.models import Base
from druks.settings import get_settings

from .exceptions import AppImportError, AppNotFound, AppsNotRegisteredError, MalformedApp

Check failure on line 11 in backend/druks/apps/loader.py

View check run for this annotation

Druks AI / druks-house-rules

R21

four or more names from .exceptions; import the module and qualify

if TYPE_CHECKING:
from importlib.metadata import EntryPoint
Expand Down Expand Up @@ -230,14 +230,15 @@
from fastapi import Depends

from druks.accounts.dependencies import current_account
from druks.teams.dependencies import app_access

prefix = f"/api/{app.name}"
for router in app.get_routers(modules):
api.include_router(
router,
prefix=prefix,
tags=[app.name],
dependencies=[Depends(current_account)],
dependencies=[Depends(current_account), Depends(app_access(app.name))],
)
dist = app.frontend_dist()
if dist:
Expand Down
8 changes: 7 additions & 1 deletion backend/druks/chat/bots/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from druks.apps.loader import get_app
from druks.chat.enums import BotAccess
from druks.chat.models import Conversation
from druks.teams.dependencies import pairing_app_access

from .constants import ONE_TIME_CODE_TTL_SECONDS
from .schemas import OneTimeCodeResponse, ResumeConversationResponse
Expand Down Expand Up @@ -67,6 +68,7 @@ async def open_one_time_code(
"/connections/{connection_id}/pairing",
response_model=OneTimeCodeResponse,
response_model_by_alias=True,
dependencies=[Depends(pairing_app_access)],
)
async def pair(
session: SessionDep,
Expand All @@ -81,7 +83,11 @@ async def pair(
raise HTTPException(404, "Connection not found.")


@router.delete("/connections/{connection_id}/pairing", status_code=204)
@router.delete(
"/connections/{connection_id}/pairing",
status_code=204,
dependencies=[Depends(pairing_app_access)],
)
async def unpair(
session: SessionDep,
connection_id: str,
Expand Down
8 changes: 7 additions & 1 deletion backend/druks/contrib/software_factory/subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from druks.contrib.software_factory.ticketing.enums import TicketStatus
from druks.contrib.software_factory.workflows import Build, Profile, PullRequestReview
from druks.db import atomic
from druks.exceptions import PermissionDenied
from druks.models import Base
from druks.services.exceptions import ServiceNotConnectedError
from druks.signals import publish, subscribe
Expand Down Expand Up @@ -153,7 +154,12 @@ async def ticket_transition_drives_the_funnel(*, payload: dict) -> None:
if payload["source"] == settings.tracker and payload["status"] == trigger:
try:
await Build.dispatch(ticket=payload)
except (TicketAlreadyMerged, TicketNotRoutable, ServiceNotConnectedError) as error:
except (
TicketAlreadyMerged,
TicketNotRoutable,
ServiceNotConnectedError,
PermissionDenied,
) as error:
# The delivery succeeded. A raise would answer 5xx, and the tracker would deliver
# the same ticket again.
logger.info("Ticket %s opens no build: %s", payload["identifier"], error)
36 changes: 26 additions & 10 deletions backend/druks/contrib/software_factory/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,14 @@
from druks.contrib.software_factory.ticketing.enums import TicketStatus
from druks.core.services import Github
from druks.db import db_session
from druks.exceptions import PermissionDenied
from druks.mcp.inbound import get_druks_mcp_server
from druks.sandbox import SandboxMcpServer, SandboxSecret
from druks.sandbox.layout import get_related_root, get_work_root
from druks.services.exceptions import ServiceNotConnectedError
from druks.settings import get_settings
from druks.skills.models import Skill
from druks.teams.models import Team

Check failure on line 24 in backend/druks/contrib/software_factory/workflows.py

View check run for this annotation

Druks AI / druks-house-rules

R16

an app imports an internal module: druks.teams.models
from druks.workflows import FatalError, Workflow, step
from druks.workspaces import RepoWorkspace

Expand Down Expand Up @@ -174,16 +176,30 @@
await item.announce("build.rejected", reason=str(error))
raise
account_id = None
if tracker := await SoftwareFactory.get_tracker():
async with tracker:
account_id = await tracker.get_account_id(ticket["actor_id"])
account_id = account_id or await tracker.get_account_id(ticket["assignee_id"])
return await cls.start(
subject=item,
account_id=account_id,
assignee_email=ticket["assignee_email"],
assignee_name=ticket["assignee_name"],
)
try:
if tracker := await SoftwareFactory.get_tracker():
async with tracker:
account_id = await tracker.get_account_id(ticket["actor_id"])

if (
ticket["actor_id"]
and not account_id
and not await Team.allows(db_session(), None, cls.app)
):
raise PermissionDenied(
"The person who moved the ticket has no Druks account and so "
f"cannot use app {cls.app!r}."
)
account_id = account_id or await tracker.get_account_id(ticket["assignee_id"])
return await cls.start(
subject=item,
account_id=account_id,
assignee_email=ticket["assignee_email"],
assignee_name=ticket["assignee_name"],
)
except PermissionDenied as error:
await item.announce("build.rejected", reason=str(error))
raise

async def run_multistep(
self,
Expand Down
4 changes: 4 additions & 0 deletions backend/druks/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ class ValidationError(DruksError):
"""A person's input is refused. The message says why."""


class PermissionDenied(DruksError):
"""An account may not do this. The message says why."""


class DetachedRowError(DruksError):
"""A row was used outside the session that loaded it."""

Expand Down
7 changes: 6 additions & 1 deletion backend/druks/mcp/gateway/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
from druks.accounts.dependencies import current_account
from druks.accounts.models import Account
from druks.api.dependencies import SessionDep
from druks.api.exceptions import RunNotFound, agent_error_responses
from druks.api.exceptions import AppPermissionDenied, RunNotFound, agent_error_responses
from druks.mcp.gateway import exceptions as gate_errors
from druks.mcp.gateway import schemas, services
from druks.teams.dependencies import run_app_access

# Docstrings here are the derived tool descriptions and operation_id is the
# tool name — renaming one is a break, never a refactor side effect.
Expand All @@ -20,7 +21,9 @@
operation_id="get_gate",
response_model=schemas.GateResponse,
response_model_by_alias=True,
dependencies=[Depends(run_app_access)],
responses=agent_error_responses(
AppPermissionDenied("app-name"),
RunNotFound("run-123"),
gate_errors.GateNotOpen("run-123"),
),
Expand All @@ -43,7 +46,9 @@ async def get_gate(
openapi_extra={"x-destructive": False, "x-idempotent": True},
response_model=schemas.GateAnswerResponse,
response_model_by_alias=True,
dependencies=[Depends(run_app_access)],
responses=agent_error_responses(
AppPermissionDenied("app-name"),
gate_errors.InvalidGateAnswer("unknown control 'merge'"),
gate_errors.GateAnswerNotAllowed("Only the number's admin can answer this run."),
RunNotFound("run-123"),
Expand Down
24 changes: 19 additions & 5 deletions backend/druks/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@
from mcp.types import CallToolRequestParams, ToolAnnotations

from druks.accounts.exceptions import InvalidPatError
from druks.accounts.models import PersonalAccessToken
from druks.accounts.models import Account, PersonalAccessToken
from druks.api.exceptions import AgentApiError, InvalidToolArguments, ToolNotAvailable
from druks.apps.exceptions import AppBotError
from druks.apps.loader import iter_apps
from druks.apps.registry import bots
from druks.database import session_scope
from druks.mcp.exceptions import InvalidAgentToolError
from druks.teams.models import Team

_INSTRUCTIONS = """\
Druks coordinates durable agent runs over shared work items. Start with
Expand Down Expand Up @@ -300,13 +301,22 @@ def _annotate(
tool.auth = _visible_while(preconditions)


def _is_visible(context: AuthContext) -> bool:
async def _is_visible(app_names: set[str], context: AuthContext) -> bool:
# A limited key lists and calls only the tools it names. Any other key gets the
# Druks toolkit: the agent tools, never a tool only a Bot or a browser call calls.
allowed = context.token.claims["allowed_tools"]
if allowed is not None:
return context.component.name in allowed
return "agent" in context.component.tags
is_visible = context.component.name in allowed
else:
is_visible = "agent" in context.component.tags
if not is_visible:
return False
app = next((tag for tag in context.component.tags if tag in app_names), "")
if not app:
return True
async with session_scope() as session:
account = await Account.get(session, id=context.token.claims["account_id"])
return await Team.allows(session, account, app)


def create_mcp_app(api: FastAPI) -> StarletteWithLifespan:
Expand Down Expand Up @@ -339,7 +349,11 @@ def create_mcp_app(api: FastAPI) -> StarletteWithLifespan:
providers=[provider],
instructions=_INSTRUCTIONS,
auth=PatTokenVerifier(),
middleware=[AuthMiddleware(auth=_is_visible), ToolErrors(), StrictInputValidation()],
middleware=[
AuthMiddleware(auth=partial(_is_visible, app_names)),
ToolErrors(),
StrictInputValidation(),
],
)
# Derivation primed app.openapi()'s cache mid-assembly; drop it.
api.openapi_schema = None
Expand Down
53 changes: 53 additions & 0 deletions backend/druks/teams/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from collections.abc import Awaitable, Callable

from fastapi import Depends, Request

from druks.accounts.dependencies import current_account, current_session_account
from druks.accounts.models import Account
from druks.api.dependencies import SessionDep
from druks.api.exceptions import AppPermissionDenied
from druks.apps.registry import workflows
from druks.chat.bots.service import get_bot_connection
from druks.durable.models import Run
from druks.teams.models import Team

# The gates below read a path value from the request. A path parameter in a gate's
# signature would replace the route's own one, and its description, in the schema
# that the MCP tools come from.


def app_access(app: str) -> Callable[..., Awaitable[None]]:
"""The gate in front of every route of one app."""

async def access(session: SessionDep, account: Account = Depends(current_account)) -> None:
if not await Team.allows(session, account, app):
raise AppPermissionDenied(app)

return access


async def run_app_access(
request: Request, session: SessionDep, account: Account = Depends(current_account)
) -> None:
"""The gate for a route that acts on a run. An unknown run is the route's to answer."""
run = await session.get(Run, request.path_params["run"])
if run and not await Team.allows(session, account, workflows.get(run.kind).app):
raise AppPermissionDenied(workflows.get(run.kind).app)


async def schedule_app_access(
request: Request, session: SessionDep, account: Account = Depends(current_account)
) -> None:
"""The gate for a schedule. An unknown kind is the route's to answer."""
workflow = workflows.get(request.path_params["kind"])
if workflow and not await Team.allows(session, account, workflow.app):
raise AppPermissionDenied(workflow.app)


async def pairing_app_access(
request: Request, session: SessionDep, account: Account = Depends(current_session_account)
) -> None:
"""The gate for a number's pairing. An unknown connection is the route's to answer."""
connection = await get_bot_connection(session, request.path_params["connection_id"])
if connection and not await Team.allows(session, account, connection.identity["app"]):
raise AppPermissionDenied(connection.identity["app"])
Loading
Loading