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
12 changes: 12 additions & 0 deletions api/controllers/openapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@
DeviceMutateRequest,
DeviceMutateResponse,
DevicePollRequest,
MemberActionResponse,
MemberInvitePayload,
MemberInviteResponse,
MemberListResponse,
MemberResponse,
MemberRoleUpdatePayload,
MessageMetadata,
PermittedExternalAppsListQuery,
PermittedExternalAppsListResponse,
Expand All @@ -61,6 +67,8 @@
DevicePollRequest,
DeviceLookupQuery,
DeviceMutateRequest,
MemberInvitePayload,
MemberRoleUpdatePayload,
PermittedExternalAppsListQuery,
)
register_response_schema_models(
Expand All @@ -84,6 +92,10 @@
WorkspaceSummaryResponse,
WorkspaceListResponse,
WorkspaceDetailResponse,
MemberResponse,
MemberListResponse,
MemberInviteResponse,
MemberActionResponse,
DeviceCodeResponse,
DeviceLookupResponse,
DeviceMutateResponse,
Expand Down
47 changes: 46 additions & 1 deletion api/controllers/openapi/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from pydantic import BaseModel, ConfigDict, Field, field_validator

from libs.helper import UUIDStrOrEmpty, uuid_value
from libs.helper import EmailStr, UUIDStrOrEmpty, uuid_value
from models.model import AppMode

# Server-side cap on `limit` query param for /openapi/v1/* list endpoints.
Expand Down Expand Up @@ -317,3 +317,48 @@ class PermittedExternalAppsListQuery(BaseModel):
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
mode: AppMode | None = None
name: str | None = Field(None, max_length=200)


# Closed enum for invite/update-role payloads. Owner is intentionally not
# assignable through these endpoints — ownership transfer goes through the
# console's three-step email-verification flow.
MemberAssignableRole = Literal["normal", "admin"]


class MemberResponse(BaseModel):
id: str
name: str
email: str
role: str
status: str
avatar: str | None = None


class MemberListResponse(BaseModel):
members: list[MemberResponse]


class MemberInvitePayload(BaseModel):
model_config = ConfigDict(extra="forbid")

email: EmailStr
role: MemberAssignableRole


class MemberRoleUpdatePayload(BaseModel):
model_config = ConfigDict(extra="forbid")

role: MemberAssignableRole


class MemberInviteResponse(BaseModel):
result: Literal["success"] = "success"
email: str
role: str
member_id: str
invite_url: str
tenant_id: str


class MemberActionResponse(BaseModel):
result: Literal["success"] = "success"
85 changes: 85 additions & 0 deletions api/controllers/openapi/auth/role_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Workspace role gate.

Layered on top of `validate_bearer` + `accept_subjects(SubjectType.ACCOUNT)`
for routes whose access depends on the caller's `TenantAccountJoin.role`
in the workspace named by the `workspace_id` path parameter.

Usage::

@openapi_ns.route("/workspaces/<string:workspace_id>/members")
class Members(Resource):
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role() # any member
def get(self, workspace_id: str): ...

@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def post(self, workspace_id: str): ...

Non-member callers get 404 (matching `GET /openapi/v1/workspaces/<id>`)
so workspace IDs do not leak across tenants. A member without one of the
allowed roles gets 403.
"""

from __future__ import annotations

from collections.abc import Callable
from functools import wraps
from typing import TypeVar

from flask import g
from sqlalchemy import select
from werkzeug.exceptions import Forbidden, NotFound

from extensions.ext_database import db
from models import TenantAccountJoin
from models.account import TenantAccountRole

F = TypeVar("F", bound=Callable[..., object])


def require_workspace_role(*allowed_roles: TenantAccountRole) -> Callable[[F], F]:
"""Gate a route on the caller's role in ``workspace_id``.

Pass no roles to require only membership. Pass one or more roles to
require the caller's role be in that set.
"""

allowed = frozenset(allowed_roles)

def deco(fn: F) -> F:
@wraps(fn)
def wrapper(*args: object, **kwargs: object) -> object:
ctx = getattr(g, "auth_ctx", None)
if ctx is None or getattr(ctx, "account_id", None) is None:
raise RuntimeError(
"require_workspace_role called without account-bearer context; "
"stack validate_bearer + accept_subjects(SubjectType.ACCOUNT) above it"
)

workspace_id = kwargs.get("workspace_id")
if not workspace_id:
raise RuntimeError(
"require_workspace_role expects a 'workspace_id' route parameter"
)

join = db.session.execute(
select(TenantAccountJoin).where(
TenantAccountJoin.tenant_id == str(workspace_id),
TenantAccountJoin.account_id == str(ctx.account_id),
)
).scalar_one_or_none()

if join is None:
raise NotFound("workspace not found")

if allowed and TenantAccountRole(join.role) not in allowed:
raise Forbidden("insufficient workspace role")

return fn(*args, **kwargs)

return wrapper # type: ignore[return-value]

return deco
Loading