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
9 changes: 8 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
from druks.exceptions import ObjectNotFound, 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 All @@ -63,6 +63,7 @@
setup_logging,
)
from druks.skills.routes import router as skills_router
from druks.teams.routes import router as teams_router
from druks.ui.exceptions import PageReadError
from druks.user_settings.routes import agents_router
from druks.user_settings.routes import router as settings_router
Expand Down Expand Up @@ -181,6 +182,11 @@ async def _object_not_found_handler(request: Request, exc: ObjectNotFound) -> JS
return await _http_exception_handler(request, HTTPException(404, str(exc)))


@app.exception_handler(ValidationError)
async def _validation_error_handler(request: Request, exc: ValidationError) -> JSONResponse:
return await _http_exception_handler(request, HTTPException(422, 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 Expand Up @@ -315,6 +321,7 @@ async def _unhandled_exception_handler(
app.include_router(notifications_external_router)
app.include_router(webhooks_router)
app.include_router(auth_router)
app.include_router(teams_router)
app.include_router(providers_router)
app.include_router(secrets_router)
app.include_router(browser_sessions_router)
Expand Down
4 changes: 4 additions & 0 deletions backend/druks/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ def __reduce__(self) -> tuple[Any, ...]:
return copyreg.__newobj__, (type(self),), {"args": self.args, **vars(self)}


class ValidationError(DruksError):
"""A person's input is refused. The message says why."""


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

Expand Down
1 change: 1 addition & 0 deletions backend/druks/migrations/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import druks.notifications.models # noqa: F401
import druks.services.models # noqa: F401
import druks.skills.models # noqa: F401
import druks.teams.models # noqa: F401
import druks.user_settings.models # noqa: F401
from alembic import context
from druks.alembic_support import run_alembic_env
Expand Down
40 changes: 40 additions & 0 deletions backend/druks/migrations/versions/1e4a9c2b7d3f_add_teams.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Add teams.

Revision ID: 1e4a9c2b7d3f
Revises: f2b6c8d1e4a9
"""

from collections.abc import Sequence

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql

revision: str = "1e4a9c2b7d3f"
down_revision: str | Sequence[str] | None = "f2b6c8d1e4a9"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.create_table(
"teams",
sa.Column("id", sa.String(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("apps", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table(
"team_members",
sa.Column("team_id", sa.String(), nullable=False),
sa.Column("account_id", sa.String(), nullable=False),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["team_id"], ["teams.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("team_id", "account_id"),
)


def downgrade() -> None:
op.drop_table("team_members")
op.drop_table("teams")
13 changes: 8 additions & 5 deletions backend/druks/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,23 @@ async def delete(self) -> None:
await self.session.flush()

@classmethod
async def get(cls, **fields: object) -> Self:
async def get(cls, session: AsyncSession | None = None, **fields: object) -> Self:
"""The one row whose fields hold these values. A miss raises ``ObjectNotFound``,
which a route answers with 404 and a page with an empty state."""
if row := await cls.get_or_none(**fields):
which a route answers with 404 and a page with an empty state. Platform code
passes its session, and an app leaves it out."""
if row := await cls.get_or_none(session, **fields):
return row
raise ObjectNotFound(snake_name(cls.__name__).replace("_", " "), fields)

@classmethod
async def get_or_none(cls, **fields: object) -> Self | None:
async def get_or_none(
cls, session: AsyncSession | None = None, **fields: object
) -> Self | None:
"""The one row whose fields hold these values, or None. Two rows raise
SQLAlchemy's ``MultipleResultsFound``."""
from druks.db import db_session

return (await db_session().scalars(cls._select_matching(fields))).one_or_none()
return (await (session or db_session()).scalars(cls._select_matching(fields))).one_or_none()

@classmethod
async def all(cls) -> list[Self]:
Expand Down
Empty file.
93 changes: 93 additions & 0 deletions backend/druks/teams/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import sqlalchemy
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Mapped, mapped_column, relationship

from druks.accounts.enums import AccountKind
from druks.accounts.models import Account
from druks.apps.loader import iter_apps
from druks.core.models import Uuid7Pk
from druks.exceptions import ValidationError
from druks.models import Base, Model

team_members = sqlalchemy.Table(
"team_members",
Base.metadata,
sqlalchemy.Column(
"team_id", sqlalchemy.ForeignKey("teams.id", ondelete="CASCADE"), primary_key=True
),
sqlalchemy.Column(
"account_id",
sqlalchemy.ForeignKey("accounts.id", ondelete="RESTRICT"),
primary_key=True,
),
)


class Team(Model, Uuid7Pk, ordering=("name",)):
__tablename__ = "teams"

name: Mapped[str] = mapped_column(unique=True)
members: Mapped[list[Account]] = relationship(
secondary=team_members,
lazy="selectin",
order_by=(Account.created_at, Account.id),
passive_deletes=True,
)
apps: Mapped[list[str]] = mapped_column(JSONB, default=list)

@classmethod
async def list_all(cls, session: AsyncSession) -> list["Team"]:
return list(await session.scalars(sqlalchemy.select(cls).order_by(cls.name)))

@classmethod
async def create(
cls, session: AsyncSession, *, name: str, member_ids: list[str], apps: list[str]
) -> "Team":
if await cls.get_or_none(session, name=name):
raise ValidationError(f"A team named {name!r} already exists.")
team = cls(
name=name,
members=await cls._get_members(session, member_ids),
apps=cls._get_apps(apps),
)
session.add(team)
await session.flush()
return team

async def replace(
self, session: AsyncSession, *, name: str, member_ids: list[str], apps: list[str]
) -> None:
"""Set the name, the members and the apps."""
other = await Team.get_or_none(session, name=name)
if other and other.id != self.id:
raise ValidationError(f"A team named {name!r} already exists.")
self.name = name
self.members = await self._get_members(session, member_ids)
self.apps = self._get_apps(apps)
await session.flush()

@staticmethod
async def _get_members(session: AsyncSession, member_ids: list[str]) -> list[Account]:
"""The operator accounts with these ids. Bot and bot admin accounts are refused."""
operators = await session.scalars(
sqlalchemy.select(Account).where(
Account.id.in_(member_ids), Account.kind == AccountKind.OPERATOR
)
)
members = {member.id: member for member in operators}
for member_id in member_ids:
if member_id not in members:
raise ValidationError(f"Account {member_id!r} is not an operator account.")
return list(members.values())

@staticmethod
def _get_apps(names: list[str]) -> list[str]:
"""The names, each one time. An app must be installed and not built in."""
installed = {app.name: app for app in iter_apps()}
for name in names:
if name not in installed:
raise ValidationError(f"App {name!r} is not installed.")
if installed[name].builtin:
raise ValidationError(f"App {name!r} is built in.")
return list(dict.fromkeys(names))
33 changes: 33 additions & 0 deletions backend/druks/teams/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends

from druks.accounts.dependencies import current_admin_account
from druks.api.dependencies import SessionDep
from druks.teams.models import Team
from druks.teams.schemas import TeamRequest, TeamResponse

router = APIRouter(
prefix="/api/teams", tags=["teams"], dependencies=[Depends(current_admin_account)]
)


@router.get("", response_model=list[TeamResponse], response_model_by_alias=True)
async def list_teams(session: SessionDep) -> list[Team]:
return await Team.list_all(session)


@router.post("", status_code=201, response_model=TeamResponse, response_model_by_alias=True)
async def create_team(session: SessionDep, body: TeamRequest) -> Team:
return await Team.create(session, name=body.name, member_ids=body.member_ids, apps=body.apps)


@router.put("/{team_id}", response_model=TeamResponse, response_model_by_alias=True)
async def update_team(team_id: str, session: SessionDep, body: TeamRequest) -> Team:
team = await Team.get(session, id=team_id)
await team.replace(session, name=body.name, member_ids=body.member_ids, apps=body.apps)
return team


@router.delete("/{team_id}", status_code=204)
async def delete_team(team_id: str, session: SessionDep) -> None:
team = await Team.get(session, id=team_id)
await team.delete()
23 changes: 23 additions & 0 deletions backend/druks/teams/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Annotated

from pydantic import BaseModel, ConfigDict, Field, StringConstraints

from druks.accounts.schemas import AccountResponse
from druks.schemas import Schema

NonBlank = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1)]


class TeamRequest(BaseModel):
name: NonBlank
member_ids: list[str] = Field(alias="memberIds")
apps: list[str]


class TeamResponse(Schema):
model_config = ConfigDict(from_attributes=True)

id: str
name: str
members: list[AccountResponse]
apps: list[str]
1 change: 1 addition & 0 deletions backend/druks/testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import druks.redis
import druks.services.models # noqa: F401
import druks.skills.models # noqa: F401
import druks.teams.models # noqa: F401
import druks.user_settings.models # noqa: F401
from druks.accounts.context import current_account_id
from druks.accounts.models import Account
Expand Down
4 changes: 4 additions & 0 deletions backend/tests/test_auth_boundary.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@

# Shared settings and connections admit an admin from the browser only.
ADMIN_API_ROUTES = {
("GET", "/api/teams"),
("POST", "/api/teams"),
("PUT", "/api/teams/{team_id}"),
("DELETE", "/api/teams/{team_id}"),
("PATCH", "/api/auth/accounts/{account_id}"),
("DELETE", "/api/browser-sessions/{name}"),
("POST", "/api/browser-sessions/{name}/login-window"),
Expand Down
Loading
Loading