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
4 changes: 2 additions & 2 deletions backend/druks/core/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from fastapi.responses import HTMLResponse

from druks.core.apis.github import GITHUB
from druks.core.services import GitHubApp
from druks.core.services import Github
from druks.core.templates import render_page
from druks.services.models import ServiceIdentity

Expand All @@ -33,7 +33,7 @@ async def create_github_app(request: Request) -> HTMLResponse:
f"https://{settings.urls.webhook_host}" if settings.urls.webhook_host else endpoint
)
manifest = {
**GitHubApp.manifest,
**Github.manifest,
"url": endpoint,
"redirect_url": f"{endpoint}/api/core/github/manifest/callback",
"hook_attributes": {"url": f"{webhook_base}/_external/github/events/", "active": True},
Expand Down
7 changes: 2 additions & 5 deletions backend/druks/core/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import httpx
from pydantic import BaseModel, Field, SecretStr

from druks.core.apis.github import GITHUB, GitHubClient
from druks.core.apis.github import GitHubClient
from druks.core.apis.linear import LINEAR_GRAPHQL_URL
from druks.services import Service, ServiceConnectError
from druks.settings import load_settings
Expand All @@ -14,8 +14,7 @@
_VERIFY_TIMEOUT = 10.0


class GitHubApp(Service):
name = GITHUB
class Github(Service):
description = (
"The GitHub App druks acts as — it receives webhooks and writes branches, "
"pull requests, and comments. Create it from here, or paste an existing "
Expand Down Expand Up @@ -64,7 +63,6 @@ async def verify(cls, settings: Settings) -> dict[str, Any]:


class Linear(Service):
name = "linear"
description = (
"The Linear identity druks reads and updates tickets as; its webhook "
"secret verifies inbound deliveries."
Expand Down Expand Up @@ -94,7 +92,6 @@ async def verify(cls, settings: Settings) -> dict[str, Any]:


class Jira(Service):
name = "jira"
description = (
"The Jira Cloud identity druks reads and updates tickets as; its webhook "
"secret authenticates Automation deliveries."
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/core/templates/service_oauth_callback.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{% extends "page.html" %}
{% block content %}
<p>Connected <b>{{ name }}</b>.
<p>Connected <b>{{ slug }}</b>.
You can close this tab and return to druks.</p>
<script>
new BroadcastChannel('druks-service-connect').postMessage({{ name | tojson }});
new BroadcastChannel('druks-service-connect').postMessage({{ slug | tojson }});
window.close();
</script>
{% endblock %}
13 changes: 9 additions & 4 deletions backend/druks/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ def check_service_identities(settings: Settings) -> list[CheckResult]:
db_session.registry.set(session)
results: list[CheckResult] = []
for service in services.all():
name = f"{service.name}_identity"
name = f"{service.slug}_identity"
try:
row = ServiceIdentity.get(service.name)
row = ServiceIdentity.get(service.slug)
except ServiceNotConnectedError:
results.append(
CheckResult(
Expand Down Expand Up @@ -337,8 +337,13 @@ def _defined_capability(module: ModuleType) -> tuple[str, str] | None:
and value.__module__ == name
):
return "webhooks", f"{value.__module__}.{value.__qualname__}"
if isinstance(value, type) and issubclass(value, Service) and value.__module__ == name:
return "services", value.name
if (
isinstance(value, type)
and issubclass(value, Service)
and not value.abstract
and value.__module__ == name
):
return "services", value.slug
if isinstance(value, Agent) and value.module == name:
return "agents", value.name
return
Expand Down
2 changes: 1 addition & 1 deletion backend/druks/extensions/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def autodiscover(package: str) -> list[ModuleType]:


webhooks = Registry("webhooks", key=lambda cls: f"{cls.__module__}.{cls.__qualname__}")
services = Registry("services", key=lambda cls: cls.name)
services = Registry("services", key=lambda cls: cls.slug)
workflows = Registry("workflows", key=lambda cls: cls.kind)
agents = Registry("agents", key=lambda agent: agent.id)
browser_sessions = Registry("browser_sessions", key=lambda session: session.name)
Expand Down
74 changes: 44 additions & 30 deletions backend/druks/services/base.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Any, ClassVar

from pydantic import BaseModel, ValidationError
Expand All @@ -11,6 +12,9 @@
from .models import OauthConnection, ServiceIdentity
from .oauth import OauthClient, fetch_identity

# GoogleCalendar -> google_calendar, HTTPServer -> http_server.
_CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")


class Connection:
"""One signed-in provider account, reached through the extension's
Expand Down Expand Up @@ -47,7 +51,7 @@ async def get_access_token(self, scopes: tuple[str, ...] = (), cached: bool = Tr
)

async def disconnect(self) -> None:
await OauthClient(provider=self.service.name).disconnect(self.row, reason="user")
await OauthClient(provider=self.service.slug).disconnect(self.row, reason="user")


class ScopedService:
Expand All @@ -71,35 +75,36 @@ def label(self) -> str:
def list_for_account(self, account_id: str) -> list[Connection]:
return [
Connection(self.service, row)
for row in OauthConnection.list_for_account(self.service.name, account_id)
for row in OauthConnection.list_for_account(self.service.slug, account_id)
]

def get(self, connection_id: str) -> Connection | None:
row = OauthConnection.get(connection_id)
if row and row.provider == self.service.name and not row.revoked_at:
if row and row.provider == self.service.slug and not row.revoked_at:
return Connection(self.service, row)


class Service:
"""The appliance's own identity at an external provider — one per service,
declared by the code that consumes it. Subclass in a ``services`` module,
set ``name`` and an inner ``Settings`` model; the platform renders the
connect card, verifies and stores the paste, and reports doctor state, all
from the declaration. Read back through the same class:
declared by the code that consumes it. Subclass in a ``services`` module
with an inner ``Settings`` model; the platform renders the connect card,
verifies and stores the paste, and reports doctor state, all from the
declaration. Read back through the same class:
``Gmail.get().secrets["client_secret"]``.

Used as a class, never instantiated — the same install-singleton shape as
``Extension``.
"""

name: ClassVar[str]
# The connect card's heading — the platform derives it from ``name``.
# Keys the service_identities row and the connect wire. Druks derives it
# from the class name. Set it only to keep the key after a class rename.
slug: ClassVar[str]
# The connect card's heading. Druks derives it from the slug.
title: ClassVar[str]
description: ClassVar[str] = ""
# Whether doctor fails when this service is not connected.
required: ClassVar[bool] = True
# True marks a shared provider base — subclasses inherit its declarations
# and register; the base itself never does.
# True marks a shared provider base. It never registers; its subclasses do.
abstract: ClassVar[bool] = False
settings_model: ClassVar[type[BaseModel]]
# Set both endpoints when the registered app is an OAuth client;
Expand All @@ -126,25 +131,31 @@ class Service:

def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
if "name" in cls.__dict__:
raise TypeError(
f"{cls.__name__} declares a `name`. A service keys by `slug`, "
"derived from the class name. Drop `name` or set `slug`."
)
if "title" in cls.__dict__:
raise TypeError(
f"{cls.__name__} declares a `title`. Druks derives the card "
"heading from the slug. Drop `title`."
)
if cls.__dict__.get("abstract"):
if "name" in cls.__dict__:
if "slug" in cls.__dict__:
raise TypeError(
f"{cls.__name__} sets both `abstract` and `name` — an abstract "
f"{cls.__name__} sets both `abstract` and `slug`. An abstract "
"base never registers. Drop one."
)
return
name = getattr(cls, "name", None)
if not name:
raise TypeError(f"{cls.__name__} must set a `name`")
if "title" in cls.__dict__:
raise TypeError(
f"{cls.__name__} declares a `title` — the card heading derives "
"from `name`. Drop it."
)
if not NAME_RE.match(name):
if "slug" in cls.__dict__:
slug = cls.__dict__["slug"]
else:
slug = _CAMEL_BOUNDARY.sub("_", cls.__name__).lower()
if not NAME_RE.match(slug):
raise TypeError(
f"service name {name!r} must match {NAME_RE.pattern!r} — it keys the "
"service_identities row and the connect wire"
f"service slug {slug!r} must match {NAME_RE.pattern!r}. It keys the "
"service_identities row and the connect wire."
)
declared = getattr(cls, "Settings", None)
if not isinstance(declared, type) or not issubclass(declared, BaseModel):
Expand All @@ -156,7 +167,10 @@ def __init_subclass__(cls, **kwargs: Any) -> None:
f"{cls.__name__}.Settings must declare client_id and client_secret "
"fields — get_oauth_client() reads the OAuth client from them"
)
cls.title = name.replace("_", " ").title()
# A registered service is concrete even under an abstract base.
cls.abstract = False
cls.slug = slug
cls.title = slug.replace("_", " ").title()
cls.settings_model = declared
services.register(cls)

Expand Down Expand Up @@ -185,7 +199,7 @@ def connect_fields(cls) -> list[dict[str, Any]]:

@classmethod
def get(cls) -> ServiceIdentity:
return ServiceIdentity.get(cls.name)
return ServiceIdentity.get(cls.slug)

@classmethod
def with_scopes(cls, *scopes: str) -> ScopedService:
Expand Down Expand Up @@ -222,13 +236,13 @@ async def get_identity(cls, access_token: str) -> dict[str, Any]:
@classmethod
def get_oauth_client(cls) -> OauthClient:
"""The connected identity as a configured ``OauthClient``, keyed by
the service name. Raises ``ServiceNotConnectedError`` until the
the service slug. Raises ``ServiceNotConnectedError`` until the
operator connects the service."""
if not cls.token_endpoint:
raise TypeError(f"{cls.__name__} declares no OAuth endpoints")
connected = cls.get()
return OauthClient(
provider=cls.name,
provider=cls.slug,
authorization_endpoint=cls.authorization_endpoint,
token_endpoint=cls.token_endpoint,
client_id=connected.identity["client_id"],
Expand All @@ -240,7 +254,7 @@ def get_oauth_client(cls) -> OauthClient:
@classmethod
def is_connected(cls) -> bool:
try:
ServiceIdentity.get(cls.name)
ServiceIdentity.get(cls.slug)
except ServiceNotConnectedError:
return False
return True
Expand Down Expand Up @@ -275,6 +289,6 @@ async def connect(cls, payload: dict[str, Any]) -> ServiceIdentity:
if all(str(value).strip() for value in (*identity.values(), *secrets.values())):
proven = await cls.verify(settings)
return ServiceIdentity.connect(
cls.name, identity={**identity, **proven}, secrets=secrets
cls.slug, identity={**identity, **proven}, secrets=secrets
)
raise ServiceConnectError("Every field is required.")
36 changes: 18 additions & 18 deletions backend/druks/services/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,66 +24,66 @@ async def list_services() -> list[ServiceResponse]:
entries = []
for service in services.all():
try:
row = ServiceIdentity.get(service.name)
row = ServiceIdentity.get(service.slug)
except ServiceNotConnectedError:
row = None
connections = []
if service.token_endpoint:
# The detail shows revoked connections as history beside the live.
connections = OauthConnection.list_for_provider(service.name, include_revoked=True)
connections = OauthConnection.list_for_provider(service.slug, include_revoked=True)
entries.append(ServiceResponse.from_row(service, row, connections))
return entries


# Session identity only, like the settings PATCH: the appliance's own
# credentials are never writable with an agent PAT.
@router.post(
"/{name}",
"/{slug}",
response_model=ServiceResponse,
response_model_by_alias=True,
dependencies=[Depends(current_session_account)],
)
async def connect_service(name: str, payload: dict[str, str]) -> ServiceResponse:
service = services.get(name)
async def connect_service(slug: str, payload: dict[str, str]) -> ServiceResponse:
service = services.get(slug)
if not service:
raise HTTPException(status_code=404, detail=f"No service {name!r}.")
raise HTTPException(status_code=404, detail=f"No service {slug!r}.")
try:
row = await service.connect(payload)
except ServiceConnectError as error:
raise HTTPException(status_code=422, detail=str(error)) from error
if service.token_endpoint:
# A replaced client can never refresh the old client's connections —
# revoke every live one; the consents stay on record.
client = OauthClient(provider=name)
for connection in OauthConnection.list_for_provider(name):
client = OauthClient(provider=slug)
for connection in OauthConnection.list_for_provider(slug):
await client.disconnect(connection, reason="client_replaced")
await publish(
"oauth.disconnected",
provider=name,
provider=slug,
connection_id=connection.id,
account_id=connection.account_id,
)
return ServiceResponse.from_row(service, row)


def _get_oauth_service(name: str):
service = services.get(name)
def _get_oauth_service(slug: str):
service = services.get(slug)
if not service or not service.token_endpoint:
raise HTTPException(status_code=404, detail=f"No OAuth service {name!r}.")
raise HTTPException(status_code=404, detail=f"No OAuth service {slug!r}.")
return service


@oauth_router.get("/{name}/connect", dependencies=[Depends(current_session_account)])
@oauth_router.get("/{slug}/connect", dependencies=[Depends(current_session_account)])
async def connect_oauth_service(
name: str, request: Request, connection: str = "", next: str = ""
slug: str, request: Request, connection: str = "", next: str = ""
) -> RedirectResponse:
service = _get_oauth_service(name)
service = _get_oauth_service(slug)
account_id = current_account_id.get()
if connection:
row = OauthConnection.get(connection)
if not row or row.provider != name:
if not row or row.provider != slug:
raise HTTPException(
status_code=404, detail=f"No connection {connection!r} on {name!r}."
status_code=404, detail=f"No connection {connection!r} on {slug!r}."
)
if next and (not next.startswith("/") or next.startswith(("//", "/\\"))):
# A bare same-origin path only — anything host-shaped is an open redirect.
Expand Down Expand Up @@ -162,7 +162,7 @@ async def oauth_callback(state: str = "", code: str = "", error: str = "") -> Re
)
if pending["next"]:
return RedirectResponse(pending["next"])
return render_page("service_oauth_callback.html", name=provider)
return render_page("service_oauth_callback.html", slug=provider)


@oauth_router.get("/connections", dependencies=[Depends(current_session_account)])
Expand Down
4 changes: 2 additions & 2 deletions backend/druks/services/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class ConnectionResponse(BaseResponse):

class ServiceResponse(BaseResponse):
# Connection state and identity facts only — never a stored secret.
name: str
slug: str
title: str
description: str
required: bool
Expand All @@ -55,7 +55,7 @@ def from_row(
connections: "list[OauthConnection] | None" = None,
) -> "ServiceResponse":
return cls(
name=service.name,
slug=service.slug,
title=service.title,
description=service.description,
required=service.required,
Expand Down
Loading