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
13 changes: 13 additions & 0 deletions core/switch_core/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,19 @@ def administers_tenant(*, is_operator: bool, tenant_role: str | None) -> bool:
return is_operator or tenant_role in TENANT_ADMIN_ROLES


def owns_tenant(*, is_operator: bool, tenant_role: str | None) -> bool:
"""Whether a caller may dispose of the tenant they hold `tenant_role` in.

Narrower than `administers_tenant` on purpose. An `admin` runs a workspace;
an `owner` decides who else gets to. The two collapse into one bit for
everything inside a workspace — rooms, agents, references — but not for the
ownership set itself: if an admin may grant `owner`, demote an owner, or
remove one, then an admin may take the workspace, and each of those steps
passes a last-owner guard on its own. Gate those three on this.
"""
return is_operator or tenant_role == "owner"


@runtime_checkable
class Authorizable(Protocol):
"""Structural interface for any entity `can` arbitrates over.
Expand Down
10 changes: 10 additions & 0 deletions core/switch_core/db/stores/agent_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ async def get_all(self, session: AsyncSession) -> list[Agent]:
result = await session.execute(select(Agent))
return list(result.scalars().all())

async def get_by_owner(self, session: AsyncSession, owner_id: str) -> list[Agent]:
"""Every agent `owner_id` owns in the bound tenant.

Backs member removal: an owner's agents authenticate with a key that
carries the same `user_id`, so this is what a removal cascade walks
to find them.
"""
result = await session.execute(select(Agent).where(Agent.owner_id == owner_id))
return list(result.scalars().all())

async def get_children(
self, session: AsyncSession, parent_agent_ids: list[str]
) -> list[Agent]:
Expand Down
70 changes: 69 additions & 1 deletion core/switch_core/db/stores/user_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from switch_core.authz import administers_tenant
from switch_core.authz import administers_tenant, owns_tenant
from switch_core.db.models import (
OidcIdentity,
TenantMember,
Expand Down Expand Up @@ -300,6 +300,26 @@ async def get_all(self, session: AsyncSession) -> list[User]:
result = await session.execute(select(User))
return list(result.scalars().all())

async def add_membership(
self, session: AsyncSession, *, tenant_id: str, user_id: str, role: str
) -> TenantMember:
"""Insert a `role` membership for `user_id` in `tenant_id`.

Distinct from `ensure_membership`, which derives the role from the
caller's global bit and is a no-op when a membership already exists:
this is the explicit write for the two places that grant a *specific*
role by a caller's own action — creating a workspace (the creator
becomes its `owner`) and accepting an invitation (the invited role).
It does not check for an existing row first; a caller that needs that
checks before calling. Kept as the one place `TenantMember` is
constructed (`tests/switch_core/test_seed_admin_membership.py` pins
that), so a route never writes one directly.
"""
membership = TenantMember(tenant_id=tenant_id, user_id=user_id, role=role)
session.add(membership)
await session.flush()
return membership

async def tenant_role(
self, session: AsyncSession, tenant_id: str, user_id: str
) -> str | None:
Expand All @@ -312,6 +332,34 @@ async def tenant_role(
membership = await session.get(TenantMember, (tenant_id, user_id))
return membership.role if membership is not None else None

async def list_tenant_members(
self, session: AsyncSession
) -> list[tuple[User, TenantMember]]:
"""Every member of the session's bound tenant, joined with their user row.

No `WHERE tenant_id = …` of its own: the policy on `tenant_members` is
what narrows this, the same as `InvitationStore.list_for_tenant`.
"""
result = await session.execute(
select(User, TenantMember).join(
TenantMember, TenantMember.user_id == User.id
)
)
return [(row[0], row[1]) for row in result.all()]

async def count_owners(self, session: AsyncSession) -> int:
"""How many `owner` memberships exist in the session's bound tenant.

Backs "a workspace must always have an owner": removing or demoting a
member is refused when they are the one row this counts.
"""
result = await session.execute(
select(func.count())
.select_from(TenantMember)
.where(TenantMember.role == "owner")
)
return result.scalar_one()

async def administers(self, session: AsyncSession, user: User) -> bool:
"""Whether `user` may administer the tenant bound to `session`'s
context — the operator bypass, or an owner/admin membership in it.
Expand All @@ -336,3 +384,23 @@ async def administers(self, session: AsyncSession, user: User) -> bool:
)
role = await self.tenant_role(session, tenant_id, user.id)
return administers_tenant(is_operator=user.role == "admin", tenant_role=role)

async def owns(self, session: AsyncSession, user: User) -> bool:
"""Whether `user` may decide who owns the tenant bound to `session`'s
context — the operator bypass, or an `owner` membership in it.

The same composition as `administers`, over `authz.owns_tenant`; see
there for why the two are separate bits rather than one.

Raises:
RuntimeError: no tenant is bound, for the same reason
`administers` raises.
"""
tenant_id = current_tenant_id()
if tenant_id is None:
raise RuntimeError(
"owns requires a bound tenant; whether someone owns a "
"workspace is only answerable about a particular one"
)
role = await self.tenant_role(session, tenant_id, user.id)
return owns_tenant(is_operator=user.role == "admin", tenant_role=role)
3 changes: 3 additions & 0 deletions core/switch_core/gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from switch_core.db.stores.api_key_store import ApiKeyStore
from switch_core.db.stores.collaboration_bridge_store import CollaborationBridgeStore
from switch_core.db.stores.external_user_store import ExternalUserStore
from switch_core.db.stores.invitation_store import InvitationStore
from switch_core.db.stores.room_group_store import RoomGroupStore
from switch_core.db.stores.room_store import RoomStore
from switch_core.db.stores.server_connector_store import ServerConnectorStore
Expand Down Expand Up @@ -59,6 +60,7 @@ def create_gateway_app(
user_store: UserStore,
external_user_store: ExternalUserStore,
api_key_store: ApiKeyStore,
invitation_store: InvitationStore,
template_store: TemplateStore,
resource_service: ResourceService,
protocol: ProtocolService,
Expand All @@ -79,6 +81,7 @@ def create_gateway_app(
user_store=user_store,
external_user_store=external_user_store,
api_key_store=api_key_store,
invitation_store=invitation_store,
template_store=template_store,
resource_service=resource_service,
protocol=protocol,
Expand Down
67 changes: 66 additions & 1 deletion core/switch_core/gateway/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import datetime
import logging
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Annotated

import bcrypt
Expand All @@ -16,7 +17,7 @@
from switch_core.db.session_scope import tenant_session
from switch_core.db.stores.room_store import RoomStore
from switch_core.db.stores.user_store import UserStore
from switch_core.db.tenant_lookup import tenants_of_user
from switch_core.db.tenant_lookup import tenant_of_invitation, tenants_of_user
from switch_core.gateway.dependencies import (
get_config,
get_session,
Expand Down Expand Up @@ -162,6 +163,56 @@ async def get_authenticated_user_id(
return payload["sub"] # type: ignore[no-any-return]


@dataclass(frozen=True)
class AuthenticatedCaller:
"""An authenticated caller with no tenant bound — id and email, read from
`users`, which carries neither a tenant nor a policy."""

id: str
email: str


async def get_authenticated_caller(
request: Request,
session_factory: Annotated[
async_sessionmaker[AsyncSession], Depends(get_session_factory)
],
user_store: Annotated[UserStore, Depends(get_user_store)],
config: Annotated[SwitchConfig, Depends(get_config)],
) -> AuthenticatedCaller:
"""Like `get_authenticated_user_id`, but with the caller's own email too.

Backs `POST /invitations/accept` (`gateway/tenants.py`), which has
to check an email-bound invitation against the caller's *current* address
before any tenant is bound — the same "no tenant chosen yet" shape as
`get_authenticated_user_id`, plus one more column of the same global,
unscoped `users` row. Reads the row fresh rather than trusting the JWT's
own `email` claim, which is a snapshot from login and can go stale.
"""
payload = await _authenticate(request, session_factory, user_store, config)
async with session_factory() as system_session:
user = await user_store.get(system_session, payload["sub"])
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return AuthenticatedCaller(id=user.id, email=user.email)


async def tenant_of_invitation_token(
session_factory: async_sessionmaker[AsyncSession], token_hash: str
) -> str | None:
"""Which tenant an invitation token belongs to, or `None` if it names none.

A thin wrapper around `db.tenant_lookup.tenant_of_invitation`, kept here
rather than called directly from `gateway/tenants.py`: the exemption from
row-level security is meant to be reachable through this one module,
never through a store or lookup any endpoint could reach for itself (see
`db/tenant_lookup.py` and `test_tenant_exemption_allowlist.py`) — the same
reason `_resolve_tenant_id` and `list_tenant_memberships` live here rather
than being called from the gateway directly.
"""
return await tenant_of_invitation(session_factory, token_hash)


async def is_tenant_member(
session_factory: async_sessionmaker[AsyncSession], user_id: str, tenant_id: str
) -> bool:
Expand Down Expand Up @@ -410,6 +461,20 @@ async def get_tenant_is_admin(
return await user_store.administers(session, user)


async def get_tenant_is_owner(
session: Annotated[AsyncSession, Depends(get_session)],
user: Annotated[User, Depends(get_current_user)],
user_store: Annotated[UserStore, Depends(get_user_store)],
) -> bool:
"""Whether `user` may decide who owns the tenant this request is bound to.

Strictly narrower than ``get_tenant_is_admin`` — see
``authz.owns_tenant``. A route that changes the ownership set needs both:
``require_tenant_admin`` to get in, this to make the call.
"""
return await user_store.owns(session, user)


async def require_tenant_admin(
user: Annotated[User, Depends(get_current_user)],
is_admin: Annotated[bool, Depends(get_tenant_is_admin)],
Expand Down
7 changes: 7 additions & 0 deletions core/switch_core/gateway/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from switch_core.db.stores.api_key_store import ApiKeyStore
from switch_core.db.stores.collaboration_bridge_store import CollaborationBridgeStore
from switch_core.db.stores.external_user_store import ExternalUserStore
from switch_core.db.stores.invitation_store import InvitationStore
from switch_core.db.stores.room_group_store import RoomGroupStore
from switch_core.db.stores.room_store import RoomStore
from switch_core.db.stores.server_connector_store import ServerConnectorStore
Expand Down Expand Up @@ -47,6 +48,7 @@ def init_dependencies(
user_store: UserStore,
external_user_store: ExternalUserStore,
api_key_store: ApiKeyStore,
invitation_store: InvitationStore,
template_store: TemplateStore,
resource_service: ResourceService,
protocol: ProtocolService,
Expand All @@ -66,6 +68,7 @@ def init_dependencies(
_state["user_store"] = user_store
_state["external_user_store"] = external_user_store
_state["api_key_store"] = api_key_store
_state["invitation_store"] = invitation_store
_state["template_store"] = template_store
_state["resource_service"] = resource_service
_state["protocol"] = protocol
Expand Down Expand Up @@ -187,6 +190,10 @@ def get_api_key_store() -> ApiKeyStore:
return _state["api_key_store"] # type: ignore[no-any-return]


def get_invitation_store() -> InvitationStore:
return _state["invitation_store"] # type: ignore[no-any-return]


def get_connector_lifecycle() -> ServerSideConnectorLifecycleService:
return _state["connector_lifecycle"] # type: ignore[no-any-return]

Expand Down
52 changes: 52 additions & 0 deletions core/switch_core/gateway/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,58 @@ class TenantMembershipResponse(BaseModel):
role: str


class TenantCreateRequest(BaseModel):
name: str


class InvitationCreateRequest(BaseModel):
role: str = "member"
# None mints a shareable link; set, the invitation is addressed to one
# email and accepting it with any other is refused.
email: str | None = None
# Bounded above as well as below: `timedelta` raises `OverflowError` past
# roughly 2.4e9 hours, so an unbounded value turns a bad request into a 500.
# A year is well beyond any legitimate invitation's life.
expires_in_hours: int = Field(default=168, gt=0, le=8760)
uses_remaining: int = Field(default=1, ge=1)


class InvitationAcceptRequest(BaseModel):
# In the body rather than the path: a URL travels through proxy logs,
# browser history and `Referer` headers, and this one is a bearer
# credential.
token: str


class InvitationDetail(BaseModel):
id: str
role: str
email: str | None
expires_at: str
uses_remaining: int
revoked_at: str | None
created_by: str
created_at: str


class InvitationCreateResponse(InvitationDetail):
# The plaintext token. Present only here — see
# `InvitationStore.create`, which is the one call that can hand it back.
token: str


class MemberDetail(BaseModel):
user_id: str
name: str
email: str
role: str
created_at: str


class MemberUpdateRequest(BaseModel):
role: str


class AuthConfigResponse(BaseModel):
# Read unauthenticated by the login page to decide which login methods to
# show. `oidc_provider_label` is the button text (e.g. "Okta").
Expand Down
Loading
Loading