feat: add organization-related models and update schemas for organization management

This commit is contained in:
Abhimanyu Saharan
2026-02-08 21:16:26 +05:30
parent 8422b0ca01
commit e03125a382
86 changed files with 8673 additions and 628 deletions

View File

@@ -0,0 +1,89 @@
"""backfill_invite_access
Revision ID: 050c16fde00e
Revises: 2c7b1c4d9e10
Create Date: 2026-02-08 20:07:14.621575
"""
from __future__ import annotations
from datetime import datetime
import uuid
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '050c16fde00e'
down_revision = '2c7b1c4d9e10'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
now = datetime.utcnow()
rows = bind.execute(
sa.text(
"""
SELECT
m.id AS member_id,
iba.board_id AS board_id,
iba.can_read AS can_read,
iba.can_write AS can_write
FROM organization_invites i
JOIN organization_invite_board_access iba
ON iba.organization_invite_id = i.id
JOIN organization_members m
ON m.user_id = i.accepted_by_user_id
AND m.organization_id = i.organization_id
WHERE i.accepted_at IS NOT NULL
"""
)
).fetchall()
for row in rows:
can_write = bool(row.can_write)
can_read = bool(row.can_read or row.can_write)
bind.execute(
sa.text(
"""
INSERT INTO organization_board_access (
id,
organization_member_id,
board_id,
can_read,
can_write,
created_at,
updated_at
)
VALUES (
:id,
:member_id,
:board_id,
:can_read,
:can_write,
:now,
:now
)
ON CONFLICT (organization_member_id, board_id) DO UPDATE
SET
can_read = organization_board_access.can_read OR EXCLUDED.can_read,
can_write = organization_board_access.can_write OR EXCLUDED.can_write,
updated_at = EXCLUDED.updated_at
"""
),
{
"id": uuid.uuid4(),
"member_id": row.member_id,
"board_id": row.board_id,
"can_read": can_read,
"can_write": can_write,
"now": now,
},
)
def downgrade() -> None:
pass

View File

@@ -0,0 +1,259 @@
"""add organizations
Revision ID: 1f2a3b4c5d6e
Revises: 9f0c4fb2a7b8
Create Date: 2026-02-07
"""
from __future__ import annotations
from datetime import datetime
import uuid
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "1f2a3b4c5d6e"
down_revision = "9f0c4fb2a7b8"
branch_labels = None
depends_on = None
DEFAULT_ORG_NAME = "Personal"
def upgrade() -> None:
op.create_table(
"organizations",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("name", name="uq_organizations_name"),
)
op.create_index("ix_organizations_name", "organizations", ["name"])
op.create_table(
"organization_members",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("role", sa.String(), nullable=False, server_default="member"),
sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_members_org"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], name="fk_org_members_user"),
sa.UniqueConstraint(
"organization_id",
"user_id",
name="uq_organization_members_org_user",
),
)
op.create_index("ix_org_members_org", "organization_members", ["organization_id"])
op.create_index("ix_org_members_user", "organization_members", ["user_id"])
op.create_index("ix_org_members_role", "organization_members", ["role"])
op.create_table(
"organization_board_access",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_member_id", sa.UUID(), nullable=False),
sa.Column("board_id", sa.UUID(), nullable=False),
sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_member_id"],
["organization_members.id"],
name="fk_org_board_access_member",
),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_board_access_board"),
sa.UniqueConstraint(
"organization_member_id",
"board_id",
name="uq_org_board_access_member_board",
),
)
op.create_index(
"ix_org_board_access_member",
"organization_board_access",
["organization_member_id"],
)
op.create_index(
"ix_org_board_access_board",
"organization_board_access",
["board_id"],
)
op.create_table(
"organization_invites",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_id", sa.UUID(), nullable=False),
sa.Column("invited_email", sa.String(), nullable=False),
sa.Column("token", sa.String(), nullable=False),
sa.Column("role", sa.String(), nullable=False, server_default="member"),
sa.Column("all_boards_read", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("all_boards_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_by_user_id", sa.UUID(), nullable=True),
sa.Column("accepted_by_user_id", sa.UUID(), nullable=True),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["organization_id"], ["organizations.id"], name="fk_org_invites_org"),
sa.ForeignKeyConstraint(["created_by_user_id"], ["users.id"], name="fk_org_invites_creator"),
sa.ForeignKeyConstraint(["accepted_by_user_id"], ["users.id"], name="fk_org_invites_acceptor"),
sa.UniqueConstraint("token", name="uq_org_invites_token"),
)
op.create_index("ix_org_invites_org", "organization_invites", ["organization_id"])
op.create_index("ix_org_invites_email", "organization_invites", ["invited_email"])
op.create_index("ix_org_invites_token", "organization_invites", ["token"])
op.create_table(
"organization_invite_board_access",
sa.Column("id", sa.UUID(), primary_key=True, nullable=False),
sa.Column("organization_invite_id", sa.UUID(), nullable=False),
sa.Column("board_id", sa.UUID(), nullable=False),
sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("true")),
sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["organization_invite_id"],
["organization_invites.id"],
name="fk_org_invite_access_invite",
),
sa.ForeignKeyConstraint(["board_id"], ["boards.id"], name="fk_org_invite_access_board"),
sa.UniqueConstraint(
"organization_invite_id",
"board_id",
name="uq_org_invite_board_access_invite_board",
),
)
op.create_index(
"ix_org_invite_access_invite",
"organization_invite_board_access",
["organization_invite_id"],
)
op.create_index(
"ix_org_invite_access_board",
"organization_invite_board_access",
["board_id"],
)
op.add_column("boards", sa.Column("organization_id", sa.UUID(), nullable=True))
op.add_column("board_groups", sa.Column("organization_id", sa.UUID(), nullable=True))
op.add_column("gateways", sa.Column("organization_id", sa.UUID(), nullable=True))
op.create_index("ix_boards_organization_id", "boards", ["organization_id"])
op.create_index("ix_board_groups_organization_id", "board_groups", ["organization_id"])
op.create_index("ix_gateways_organization_id", "gateways", ["organization_id"])
op.create_foreign_key(
"fk_boards_organization_id",
"boards",
"organizations",
["organization_id"],
["id"],
)
op.create_foreign_key(
"fk_board_groups_organization_id",
"board_groups",
"organizations",
["organization_id"],
["id"],
)
op.create_foreign_key(
"fk_gateways_organization_id",
"gateways",
"organizations",
["organization_id"],
["id"],
)
bind = op.get_bind()
now = datetime.utcnow()
org_id = uuid.uuid4()
bind.execute(
sa.text(
"INSERT INTO organizations (id, name, created_at, updated_at) VALUES (:id, :name, :now, :now)"
),
{"id": org_id, "name": DEFAULT_ORG_NAME, "now": now},
)
bind.execute(
sa.text("UPDATE boards SET organization_id = :org_id"),
{"org_id": org_id},
)
bind.execute(
sa.text("UPDATE board_groups SET organization_id = :org_id"),
{"org_id": org_id},
)
bind.execute(
sa.text("UPDATE gateways SET organization_id = :org_id"),
{"org_id": org_id},
)
user_rows = list(bind.execute(sa.text("SELECT id FROM users")))
for row in user_rows:
user_id = row[0]
bind.execute(
sa.text(
"""
INSERT INTO organization_members
(id, organization_id, user_id, role, all_boards_read, all_boards_write, created_at, updated_at)
VALUES
(:id, :org_id, :user_id, :role, :all_read, :all_write, :now, :now)
"""
),
{
"id": uuid.uuid4(),
"org_id": org_id,
"user_id": user_id,
"role": "owner",
"all_read": True,
"all_write": True,
"now": now,
},
)
op.alter_column("boards", "organization_id", nullable=False)
op.alter_column("board_groups", "organization_id", nullable=False)
op.alter_column("gateways", "organization_id", nullable=False)
def downgrade() -> None:
op.drop_constraint("fk_gateways_organization_id", "gateways", type_="foreignkey")
op.drop_constraint("fk_board_groups_organization_id", "board_groups", type_="foreignkey")
op.drop_constraint("fk_boards_organization_id", "boards", type_="foreignkey")
op.drop_index("ix_gateways_organization_id", table_name="gateways")
op.drop_index("ix_board_groups_organization_id", table_name="board_groups")
op.drop_index("ix_boards_organization_id", table_name="boards")
op.drop_column("gateways", "organization_id")
op.drop_column("board_groups", "organization_id")
op.drop_column("boards", "organization_id")
op.drop_index("ix_org_invite_access_board", table_name="organization_invite_board_access")
op.drop_index("ix_org_invite_access_invite", table_name="organization_invite_board_access")
op.drop_table("organization_invite_board_access")
op.drop_index("ix_org_invites_token", table_name="organization_invites")
op.drop_index("ix_org_invites_email", table_name="organization_invites")
op.drop_index("ix_org_invites_org", table_name="organization_invites")
op.drop_table("organization_invites")
op.drop_index("ix_org_board_access_board", table_name="organization_board_access")
op.drop_index("ix_org_board_access_member", table_name="organization_board_access")
op.drop_table("organization_board_access")
op.drop_index("ix_org_members_role", table_name="organization_members")
op.drop_index("ix_org_members_user", table_name="organization_members")
op.drop_index("ix_org_members_org", table_name="organization_members")
op.drop_table("organization_members")
op.drop_index("ix_organizations_name", table_name="organizations")
op.drop_table("organizations")

View File

@@ -0,0 +1,24 @@
"""merge heads
Revision ID: 2c7b1c4d9e10
Revises: 1f2a3b4c5d6e, af403671a8c4
Create Date: 2026-02-07
"""
from __future__ import annotations
from alembic import op
# revision identifiers, used by Alembic.
revision = "2c7b1c4d9e10"
down_revision = ("1f2a3b4c5d6e", "af403671a8c4")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,70 @@
"""add active organization to users
Revision ID: 6e1c9b2f7a4d
Revises: 050c16fde00e
Create Date: 2026-02-08
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "6e1c9b2f7a4d"
down_revision = "050c16fde00e"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"users",
sa.Column("active_organization_id", sa.UUID(), nullable=True),
)
op.create_index(
"ix_users_active_organization_id",
"users",
["active_organization_id"],
)
op.create_foreign_key(
"fk_users_active_organization",
"users",
"organizations",
["active_organization_id"],
["id"],
)
bind = op.get_bind()
rows = bind.execute(
sa.text(
"""
SELECT user_id, organization_id
FROM organization_members
ORDER BY user_id, created_at ASC
"""
)
).fetchall()
seen: set[str] = set()
for row in rows:
user_id = str(row.user_id)
if user_id in seen:
continue
seen.add(user_id)
bind.execute(
sa.text(
"""
UPDATE users
SET active_organization_id = :org_id
WHERE id = :user_id
AND active_organization_id IS NULL
"""
),
{"org_id": row.organization_id, "user_id": row.user_id},
)
def downgrade() -> None:
op.drop_constraint("fk_users_active_organization", "users", type_="foreignkey")
op.drop_index("ix_users_active_organization_id", table_name="users")
op.drop_column("users", "active_organization_id")

View File

@@ -8,14 +8,13 @@ from datetime import datetime, timezone
from typing import Any, cast
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from sqlalchemy import asc, desc, func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import ActorContext, require_admin_or_agent, require_org_member
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
@@ -25,6 +24,7 @@ from app.models.boards import Board
from app.models.tasks import Task
from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import get_active_membership, list_accessible_board_ids
router = APIRouter(prefix="/activity", tags=["activity"])
@@ -112,6 +112,17 @@ async def list_activity(
statement = select(ActivityEvent)
if actor.actor_type == "agent" and actor.agent:
statement = statement.where(ActivityEvent.agent_id == actor.agent.id)
elif actor.actor_type == "user" and actor.user:
member = await get_active_membership(session, actor.user)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
board_ids = await list_accessible_board_ids(session, member=member, write=False)
if not board_ids:
statement = statement.where(col(ActivityEvent.id).is_(None))
else:
statement = statement.join(Task, col(ActivityEvent.task_id) == col(Task.id)).where(
col(Task.board_id).in_(board_ids)
)
statement = statement.order_by(desc(col(ActivityEvent.created_at)))
return await paginate(session, statement)
@@ -123,7 +134,7 @@ async def list_activity(
async def list_task_comment_feed(
board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_member),
) -> DefaultLimitOffsetPage[ActivityTaskCommentFeedItemRead]:
statement = (
select(ActivityEvent, Task, Board, Agent)
@@ -134,8 +145,15 @@ async def list_task_comment_feed(
.where(func.length(func.trim(col(ActivityEvent.message))) > 0)
.order_by(desc(col(ActivityEvent.created_at)))
)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
if board_id is not None:
if board_id not in set(board_ids):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = statement.where(col(Task.board_id) == board_id)
elif board_ids:
statement = statement.where(col(Task.board_id).in_(board_ids))
else:
statement = statement.where(col(Task.id).is_(None))
def _transform(items: Sequence[Any]) -> Sequence[Any]:
rows = cast(Sequence[tuple[ActivityEvent, Task, Board, Agent | None]], items)
@@ -149,9 +167,14 @@ async def stream_task_comment_feed(
request: Request,
board_id: UUID | None = Query(default=None),
since: str | None = Query(default=None),
auth: AuthContext = Depends(require_admin_auth),
session: AsyncSession = Depends(get_session),
ctx=Depends(require_org_member),
) -> EventSourceResponse:
since_dt = _parse_since(since) or utcnow()
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
allowed_ids = set(board_ids)
if board_id is not None and board_id not in allowed_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
seen_ids: set[UUID] = set()
seen_queue: deque[UUID] = deque()
@@ -161,7 +184,13 @@ async def stream_task_comment_feed(
if await request.is_disconnected():
break
async with async_session_maker() as session:
rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id)
if board_id is not None:
rows = await _fetch_task_comment_events(session, last_seen, board_id=board_id)
elif allowed_ids:
rows = await _fetch_task_comment_events(session, last_seen)
rows = [row for row in rows if row[1].board_id in allowed_ids]
else:
rows = []
for event, task, board, agent in rows:
event_id = event.id
if event_id in seen_ids:

View File

@@ -334,7 +334,6 @@ async def list_task_comments(
return await tasks_api.list_task_comments(
task=task,
session=session,
actor=_actor(agent_ctx),
)

View File

@@ -14,9 +14,9 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
@@ -26,7 +26,9 @@ from app.models.activity_events import ActivityEvent
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organizations import Organization
from app.models.tasks import Task
from app.models.users import User
from app.schemas.agents import (
AgentCreate,
AgentHeartbeat,
@@ -43,6 +45,14 @@ from app.services.agent_provisioning import (
provision_agent,
provision_main_agent,
)
from app.services.organizations import (
OrganizationContext,
get_active_membership,
has_board_access,
is_org_admin,
list_accessible_board_ids,
require_board_access,
)
router = APIRouter(prefix="/agents", tags=["agents"])
@@ -85,7 +95,13 @@ def _workspace_path(agent_name: str, workspace_root: str | None) -> str:
return f"{root}/workspace-{_slugify(agent_name)}"
async def _require_board(session: AsyncSession, board_id: UUID | str | None) -> Board:
async def _require_board(
session: AsyncSession,
board_id: UUID | str | None,
*,
user: object | None = None,
write: bool = False,
) -> Board:
if not board_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -94,6 +110,8 @@ async def _require_board(session: AsyncSession, board_id: UUID | str | None) ->
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if user is not None:
await require_board_access(session, user=user, board=board, write=write) # type: ignore[arg-type]
return board
@@ -111,6 +129,11 @@ async def _require_gateway(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_id is invalid",
)
if gateway.organization_id != board.organization_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board gateway_id is invalid",
)
if not gateway.main_session_key:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -206,6 +229,42 @@ async def _fetch_agent_events(
return list(await session.exec(statement))
async def _require_user_context(
session: AsyncSession, user: User | None
) -> OrganizationContext:
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_active_membership(session, user)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
organization = await session.get(Organization, member.organization_id)
if organization is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return OrganizationContext(organization=organization, member=member)
async def _require_agent_access(
session: AsyncSession,
*,
agent: Agent,
ctx,
write: bool,
) -> None:
if agent.board_id is None:
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
gateway = await _find_gateway_for_main_session(session, agent.openclaw_session_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return
board = await session.get(Board, agent.board_id)
if board is None or board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if not await has_board_access(session, member=ctx.member, board=board, write=write):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
def _record_heartbeat(session: AsyncSession, agent: Agent) -> None:
record_activity(
session,
@@ -245,13 +304,28 @@ async def list_agents(
board_id: UUID | None = Query(default=None),
gateway_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_admin),
) -> DefaultLimitOffsetPage[AgentRead]:
main_session_keys = await _get_gateway_main_session_keys(session)
statement = select(Agent)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
if board_id is not None and board_id not in set(board_ids):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not board_ids:
statement = select(Agent).where(col(Agent.id).is_(None))
else:
base_filter = col(Agent.board_id).in_(board_ids)
if is_org_admin(ctx.member):
gateway_keys = select(Gateway.main_session_key).where(
col(Gateway.organization_id) == ctx.organization.id
)
base_filter = or_(base_filter, col(Agent.openclaw_session_id).in_(gateway_keys))
statement = select(Agent).where(base_filter)
if board_id is not None:
statement = statement.where(col(Agent.board_id) == board_id)
if gateway_id is not None:
gateway = await session.get(Gateway, gateway_id)
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
statement = statement.join(Board, col(Agent.board_id) == col(Board.id)).where(
col(Board.gateway_id) == gateway_id
)
@@ -269,10 +343,15 @@ async def stream_agents(
request: Request,
board_id: UUID | None = Query(default=None),
since: str | None = Query(default=None),
auth: AuthContext = Depends(require_admin_auth),
session: AsyncSession = Depends(get_session),
ctx=Depends(require_org_admin),
) -> EventSourceResponse:
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
allowed_ids = set(board_ids)
if board_id is not None and board_id not in allowed_ids:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
async def event_generator() -> AsyncIterator[dict[str, str]]:
nonlocal last_seen
@@ -280,7 +359,13 @@ async def stream_agents(
if await request.is_disconnected():
break
async with async_session_maker() as session:
agents = await _fetch_agent_events(session, board_id, last_seen)
if board_id is not None:
agents = await _fetch_agent_events(session, board_id, last_seen)
elif allowed_ids:
agents = await _fetch_agent_events(session, None, last_seen)
agents = [agent for agent in agents if agent.board_id in allowed_ids]
else:
agents = []
main_session_keys = (
await _get_gateway_main_session_keys(session) if agents else set()
)
@@ -301,6 +386,10 @@ async def create_agent(
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> AgentRead:
if actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if actor.actor_type == "agent":
if not actor.agent or not actor.agent.is_board_lead:
raise HTTPException(
@@ -319,7 +408,12 @@ async def create_agent(
)
payload = AgentCreate(**{**payload.model_dump(), "board_id": actor.agent.board_id})
board = await _require_board(session, payload.board_id)
board = await _require_board(
session,
payload.board_id,
user=actor.user if actor.actor_type == "user" else None,
write=actor.actor_type == "user",
)
gateway, client_config = await _require_gateway(session, board)
data = payload.model_dump()
requested_name = (data.get("name") or "").strip()
@@ -436,11 +530,12 @@ async def create_agent(
async def get_agent(
agent_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_admin),
) -> AgentRead:
agent = await session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _require_agent_access(session, agent=agent, ctx=ctx, write=False)
main_session_keys = await _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@@ -451,18 +546,28 @@ async def update_agent(
payload: AgentUpdate,
force: bool = False,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> AgentRead:
agent = await session.get(Agent, agent_id)
if agent is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
updates = payload.model_dump(exclude_unset=True)
make_main = updates.pop("is_gateway_main", None)
if make_main is True and not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if "status" in updates:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="status is controlled by agent heartbeat",
)
if "board_id" in updates and updates["board_id"] is not None:
new_board = await _require_board(session, updates["board_id"])
if new_board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if not await has_board_access(session, member=ctx.member, board=new_board, write=True):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if not updates and not force and make_main is None:
main_session_keys = await _get_gateway_main_session_keys(session)
return _to_agent_read(_with_computed_status(agent), main_session_keys)
@@ -628,6 +733,11 @@ async def heartbeat_agent(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
if payload.status:
agent.status = payload.status
elif agent.status == "provisioning":
@@ -664,7 +774,16 @@ async def heartbeat_or_create_agent(
if agent is None:
if actor.actor_type == "agent":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
board = await _require_board(session, payload.board_id)
if actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
board = await _require_board(
session,
payload.board_id,
user=actor.user,
write=True,
)
gateway, client_config = await _require_gateway(session, board)
agent = Agent(
name=payload.name,
@@ -724,6 +843,9 @@ async def heartbeat_or_create_agent(
except Exception as exc: # pragma: no cover - unexpected provisioning errors
_record_instruction_failure(session, agent, str(exc), "provision")
await session.commit()
elif actor.actor_type == "user":
ctx = await _require_user_context(session, actor.user)
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
elif actor.actor_type == "agent" and actor.agent and actor.agent.id != agent.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
elif agent.agent_token_hash is None and actor.actor_type == "user":
@@ -737,7 +859,12 @@ async def heartbeat_or_create_agent(
await session.commit()
await session.refresh(agent)
try:
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
board = await _require_board(
session,
str(agent.board_id) if agent.board_id else None,
user=actor.user if actor.actor_type == "user" else None,
write=actor.actor_type == "user",
)
gateway, client_config = await _require_gateway(session, board)
await provision_agent(agent, board, gateway, raw_token, actor.user, action="provision")
await _send_wakeup_message(agent, client_config, verb="provisioned")
@@ -767,7 +894,12 @@ async def heartbeat_or_create_agent(
_record_instruction_failure(session, agent, str(exc), "provision")
await session.commit()
elif not agent.openclaw_session_id:
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
board = await _require_board(
session,
str(agent.board_id) if agent.board_id else None,
user=actor.user if actor.actor_type == "user" else None,
write=actor.actor_type == "user",
)
gateway, client_config = await _require_gateway(session, board)
session_key, session_error = await _ensure_gateway_session(agent.name, client_config)
agent.openclaw_session_id = session_key
@@ -804,11 +936,12 @@ async def heartbeat_or_create_agent(
async def delete_agent(
agent_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_admin),
) -> OkResponse:
agent = await session.get(Agent, agent_id)
if agent is None:
return OkResponse()
await _require_agent_access(session, agent=agent, ctx=ctx, write=True)
board = await _require_board(session, str(agent.board_id) if agent.board_id else None)
gateway, client_config = await _require_gateway(session, board)

View File

@@ -12,8 +12,13 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import (
ActorContext,
get_board_for_actor_read,
get_board_for_actor_write,
get_board_for_user_write,
require_admin_or_agent,
)
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import async_session_maker, get_session
@@ -88,13 +93,10 @@ async def _fetch_approval_events(
@router.get("", response_model=DefaultLimitOffsetPage[ApprovalRead])
async def list_approvals(
status_filter: ApprovalStatus | None = Query(default=None, alias="status"),
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[ApprovalRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = select(Approval).where(col(Approval.board_id) == board.id)
if status_filter:
statement = statement.where(col(Approval.status) == status_filter)
@@ -105,13 +107,10 @@ async def list_approvals(
@router.get("/stream")
async def stream_approvals(
request: Request,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
) -> EventSourceResponse:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -180,13 +179,10 @@ async def stream_approvals(
@router.post("", response_model=ApprovalRead)
async def create_approval(
payload: ApprovalCreate,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Approval:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
task_id = payload.task_id or _extract_task_id(payload.payload)
approval = Approval(
board_id=board.id,
@@ -208,9 +204,8 @@ async def create_approval(
async def update_approval(
approval_id: str,
payload: ApprovalUpdate,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Approval:
approval = await session.get(Approval, approval_id)
if approval is None or approval.board_id != board.id:

View File

@@ -12,8 +12,13 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import (
ActorContext,
get_board_for_actor_read,
get_board_for_actor_write,
require_admin_or_agent,
require_org_member,
)
from app.core.config import settings
from app.core.time import utcnow
from app.db.pagination import paginate
@@ -25,8 +30,16 @@ from app.models.board_group_memory import BoardGroupMemory
from app.models.board_groups import BoardGroup
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
from app.schemas.board_group_memory import BoardGroupMemoryCreate, BoardGroupMemoryRead
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import (
OrganizationContext,
is_org_admin,
list_accessible_board_ids,
member_all_boards_read,
member_all_boards_write,
)
from app.services.mentions import extract_mentions, matches_agent_mention
router = APIRouter(tags=["board-group-memory"])
@@ -96,6 +109,38 @@ async def _fetch_memory_events(
return list(await session.exec(statement))
async def _require_group_access(
session: AsyncSession,
*,
group_id: UUID,
ctx: OrganizationContext,
write: bool,
) -> BoardGroup:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if group.organization_id != ctx.member.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if write and member_all_boards_write(ctx.member):
return group
if not write and member_all_boards_read(ctx.member):
return group
board_ids = list(
await session.exec(select(Board.id).where(col(Board.board_group_id) == group_id))
)
if not board_ids:
if is_org_admin(ctx.member):
return group
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
allowed_ids = await list_accessible_board_ids(session, member=ctx.member, write=write)
if not set(board_ids).intersection(set(allowed_ids)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return group
async def _notify_group_memory_targets(
*,
session: AsyncSession,
@@ -193,11 +238,9 @@ async def list_board_group_memory(
group_id: UUID,
is_chat: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
statement = (
select(BoardGroupMemory).where(col(BoardGroupMemory.board_group_id) == group_id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
@@ -217,11 +260,9 @@ async def stream_board_group_memory(
since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_member),
) -> EventSourceResponse:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await _require_group_access(session, group_id=group_id, ctx=ctx, write=False)
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -252,13 +293,12 @@ async def create_board_group_memory(
group_id: UUID,
payload: BoardGroupMemoryCreate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx: OrganizationContext = Depends(require_org_member),
) -> BoardGroupMemory:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
group = await _require_group_access(session, group_id=group_id, ctx=ctx, write=True)
actor = ActorContext(actor_type="user", user=auth.user)
user = await session.get(User, ctx.member.user_id)
actor = ActorContext(actor_type="user", user=user)
tags = set(payload.tags or [])
is_chat = "chat" in tags
mentions = extract_mentions(payload.content)
@@ -287,13 +327,9 @@ async def create_board_group_memory(
@board_router.get("", response_model=DefaultLimitOffsetPage[BoardGroupMemoryRead])
async def list_board_group_memory_for_board(
is_chat: bool | None = Query(default=None),
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[BoardGroupMemoryRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
group_id = board.board_group_id
if group_id is None:
statement = select(BoardGroupMemory).where(col(BoardGroupMemory.id).is_(None))
@@ -314,14 +350,10 @@ async def list_board_group_memory_for_board(
@board_router.get("/stream")
async def stream_board_group_memory_for_board(
request: Request,
board: Board = Depends(get_board_or_404),
actor: ActorContext = Depends(require_admin_or_agent),
board: Board = Depends(get_board_for_actor_read),
since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None),
) -> EventSourceResponse:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
group_id = board.board_group_id
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -354,13 +386,10 @@ async def stream_board_group_memory_for_board(
@board_router.post("", response_model=BoardGroupMemoryRead)
async def create_board_group_memory_for_board(
payload: BoardGroupMemoryCreate,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardGroupMemory:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
group_id = board.board_group_id
if group_id is None:
raise HTTPException(

View File

@@ -9,8 +9,7 @@ from sqlalchemy import delete, func, update
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import ActorContext, require_admin_or_agent, require_org_admin, require_org_member
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
@@ -29,6 +28,14 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot
from app.services.agent_provisioning import DEFAULT_HEARTBEAT_CONFIG, sync_gateway_agent_heartbeats
from app.services.board_group_snapshot import build_group_snapshot
from app.services.organizations import (
board_access_filter,
get_member,
is_org_admin,
list_accessible_board_ids,
member_all_boards_read,
member_all_boards_write,
)
router = APIRouter(prefix="/board-groups", tags=["board-groups"])
@@ -38,12 +45,56 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex
async def _require_group_access(
session: AsyncSession,
*,
group_id: UUID,
member,
write: bool,
) -> BoardGroup:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if group.organization_id != member.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if write and member_all_boards_write(member):
return group
if not write and member_all_boards_read(member):
return group
board_ids = list(
await session.exec(select(Board.id).where(col(Board.board_group_id) == group_id))
)
if not board_ids:
if is_org_admin(member):
return group
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
allowed_ids = await list_accessible_board_ids(session, member=member, write=write)
if not set(board_ids).intersection(set(allowed_ids)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return group
@router.get("", response_model=DefaultLimitOffsetPage[BoardGroupRead])
async def list_board_groups(
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardGroupRead]:
statement = select(BoardGroup).order_by(func.lower(col(BoardGroup.name)).asc())
if member_all_boards_read(ctx.member):
statement = select(BoardGroup).where(
col(BoardGroup.organization_id) == ctx.organization.id
)
else:
accessible_boards = select(Board.board_group_id).where(
board_access_filter(ctx.member, write=False)
)
statement = select(BoardGroup).where(
col(BoardGroup.organization_id) == ctx.organization.id,
col(BoardGroup.id).in_(accessible_boards),
)
statement = statement.order_by(func.lower(col(BoardGroup.name)).asc())
return await paginate(session, statement)
@@ -51,11 +102,12 @@ async def list_board_groups(
async def create_board_group(
payload: BoardGroupCreate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_admin),
) -> BoardGroup:
data = payload.model_dump()
if not (data.get("slug") or "").strip():
data["slug"] = _slugify(data.get("name") or "")
data["organization_id"] = ctx.organization.id
return await crud.create(session, BoardGroup, **data)
@@ -63,12 +115,9 @@ async def create_board_group(
async def get_board_group(
group_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_member),
) -> BoardGroup:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return group
return await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
@router.get("/{group_id}/snapshot", response_model=BoardGroupSnapshot)
@@ -77,20 +126,22 @@ async def get_board_group_snapshot(
include_done: bool = False,
per_board_task_limit: int = 5,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_member),
) -> BoardGroupSnapshot:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=False)
if per_board_task_limit < 0:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
return await build_group_snapshot(
snapshot = await build_group_snapshot(
session,
group=group,
exclude_board_id=None,
include_done=include_done,
per_board_task_limit=per_board_task_limit,
)
if not member_all_boards_read(ctx.member) and snapshot.boards:
allowed_ids = set(await list_accessible_board_ids(session, member=ctx.member, write=False))
snapshot.boards = [item for item in snapshot.boards if item.board.id in allowed_ids]
return snapshot
@router.post("/{group_id}/heartbeat", response_model=BoardGroupHeartbeatApplyResult)
@@ -104,7 +155,23 @@ async def apply_board_group_heartbeat(
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent":
if actor.actor_type == "user":
if actor.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_member(
session,
user_id=actor.user.id,
organization_id=group.organization_id,
)
if member is None or not is_org_admin(member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
await _require_group_access(
session,
group_id=group_id,
member=member,
write=True,
)
elif actor.actor_type == "agent":
agent = actor.agent
if agent is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
@@ -188,11 +255,9 @@ async def update_board_group(
payload: BoardGroupUpdate,
group_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_admin),
) -> BoardGroup:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
updates = payload.model_dump(exclude_unset=True)
if "slug" in updates and updates["slug"] is not None and not updates["slug"].strip():
updates["slug"] = _slugify(updates.get("name") or group.name)
@@ -206,11 +271,9 @@ async def update_board_group(
async def delete_board_group(
group_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_admin),
) -> OkResponse:
group = await session.get(BoardGroup, group_id)
if group is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
group = await _require_group_access(session, group_id=group_id, member=ctx.member, write=True)
# Boards reference groups, so clear the FK first to keep deletes simple.
await session.execute(

View File

@@ -12,7 +12,12 @@ from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from sse_starlette.sse import EventSourceResponse
from app.api.deps import ActorContext, get_board_or_404, require_admin_or_agent
from app.api.deps import (
ActorContext,
get_board_for_actor_read,
get_board_for_actor_write,
require_admin_or_agent,
)
from app.core.config import settings
from app.core.time import utcnow
from app.db.pagination import paginate
@@ -178,13 +183,10 @@ async def _notify_chat_targets(
@router.get("", response_model=DefaultLimitOffsetPage[BoardMemoryRead])
async def list_board_memory(
is_chat: bool | None = Query(default=None),
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[BoardMemoryRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = (
select(BoardMemory).where(col(BoardMemory.board_id) == board.id)
# Old/invalid rows (empty/whitespace-only content) can exist; exclude them to
@@ -200,14 +202,11 @@ async def list_board_memory(
@router.get("/stream")
async def stream_board_memory(
request: Request,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
is_chat: bool | None = Query(default=None),
) -> EventSourceResponse:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
since_dt = _parse_since(since) or utcnow()
last_seen = since_dt
@@ -236,13 +235,10 @@ async def stream_board_memory(
@router.post("", response_model=BoardMemoryRead)
async def create_board_memory(
payload: BoardMemoryCreate,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_write),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardMemory:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
is_chat = payload.tags is not None and "chat" in payload.tags
source = payload.source
if is_chat and not source:

View File

@@ -9,7 +9,14 @@ from pydantic import ValidationError
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
from app.api.deps import (
ActorContext,
get_board_for_user_read,
get_board_for_user_write,
get_board_or_404,
require_admin_auth,
require_admin_or_agent,
)
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext
from app.core.config import settings
@@ -136,9 +143,8 @@ async def _ensure_lead_agent(
@router.get("", response_model=BoardOnboardingRead)
async def get_onboarding(
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_user_read),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession:
onboarding = (
await session.exec(
@@ -155,9 +161,8 @@ async def get_onboarding(
@router.post("/start", response_model=BoardOnboardingRead)
async def start_onboarding(
payload: BoardOnboardingStart,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession:
onboarding = (
await session.exec(
@@ -239,9 +244,8 @@ async def start_onboarding(
@router.post("/answer", response_model=BoardOnboardingRead)
async def answer_onboarding(
payload: BoardOnboardingAnswer,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> BoardOnboardingSession:
onboarding = (
await session.exec(
@@ -342,7 +346,7 @@ async def agent_onboarding_update(
@router.post("/confirm", response_model=BoardRead)
async def confirm_onboarding(
payload: BoardOnboardingConfirm,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> Board:

View File

@@ -8,8 +8,13 @@ from sqlalchemy import delete, func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent
from app.core.auth import AuthContext
from app.api.deps import (
get_board_for_actor_read,
get_board_for_user_read,
get_board_for_user_write,
require_org_admin,
require_org_member,
)
from app.core.time import utcnow
from app.db import crud
from app.db.pagination import paginate
@@ -38,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.view_models import BoardGroupSnapshot, BoardSnapshot
from app.services.board_group_snapshot import build_board_group_snapshot
from app.services.board_snapshot import build_board_snapshot
from app.services.organizations import board_access_filter
router = APIRouter(prefix="/boards", tags=["boards"])
@@ -53,40 +59,66 @@ def _build_session_key(agent_name: str) -> str:
return f"{AGENT_SESSION_PREFIX}:{_slugify(agent_name)}:main"
async def _require_gateway(session: AsyncSession, gateway_id: object) -> Gateway:
async def _require_gateway(
session: AsyncSession,
gateway_id: object,
*,
organization_id: UUID | None = None,
) -> Gateway:
gateway = await crud.get_by_id(session, Gateway, gateway_id)
if gateway is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="gateway_id is invalid",
)
if organization_id is not None and gateway.organization_id != organization_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="gateway_id is invalid",
)
return gateway
async def _require_gateway_for_create(
payload: BoardCreate,
ctx=Depends(require_org_admin),
session: AsyncSession = Depends(get_session),
) -> Gateway:
return await _require_gateway(session, payload.gateway_id)
return await _require_gateway(session, payload.gateway_id, organization_id=ctx.organization.id)
async def _require_board_group(session: AsyncSession, board_group_id: object) -> BoardGroup:
async def _require_board_group(
session: AsyncSession,
board_group_id: object,
*,
organization_id: UUID | None = None,
) -> BoardGroup:
group = await crud.get_by_id(session, BoardGroup, board_group_id)
if group is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_group_id is invalid",
)
if organization_id is not None and group.organization_id != organization_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="board_group_id is invalid",
)
return group
async def _require_board_group_for_create(
payload: BoardCreate,
ctx=Depends(require_org_admin),
session: AsyncSession = Depends(get_session),
) -> BoardGroup | None:
if payload.board_group_id is None:
return None
return await _require_board_group(session, payload.board_group_id)
return await _require_board_group(
session,
payload.board_group_id,
organization_id=ctx.organization.id,
)
async def _apply_board_update(
@@ -97,9 +129,13 @@ async def _apply_board_update(
) -> Board:
updates = payload.model_dump(exclude_unset=True)
if "gateway_id" in updates:
await _require_gateway(session, updates["gateway_id"])
await _require_gateway(session, updates["gateway_id"], organization_id=board.organization_id)
if "board_group_id" in updates and updates["board_group_id"] is not None:
await _require_board_group(session, updates["board_group_id"])
await _require_board_group(
session,
updates["board_group_id"],
organization_id=board.organization_id,
)
for key, value in updates.items():
setattr(board, key, value)
if updates.get("board_type") == "goal":
@@ -182,9 +218,9 @@ async def list_boards(
gateway_id: UUID | None = Query(default=None),
board_group_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
ctx=Depends(require_org_member),
) -> DefaultLimitOffsetPage[BoardRead]:
statement = select(Board)
statement = select(Board).where(board_access_filter(ctx.member, write=False))
if gateway_id is not None:
statement = statement.where(col(Board.gateway_id) == gateway_id)
if board_group_id is not None:
@@ -199,28 +235,25 @@ async def create_board(
_gateway: Gateway = Depends(_require_gateway_for_create),
_board_group: BoardGroup | None = Depends(_require_board_group_for_create),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_admin),
) -> Board:
return await crud.create(session, Board, **payload.model_dump())
data = payload.model_dump()
data["organization_id"] = ctx.organization.id
return await crud.create(session, Board, **data)
@router.get("/{board_id}", response_model=BoardRead)
def get_board(
board: Board = Depends(get_board_or_404),
actor: ActorContext = Depends(require_admin_or_agent),
board: Board = Depends(get_board_for_user_read),
) -> Board:
return board
@router.get("/{board_id}/snapshot", response_model=BoardSnapshot)
async def get_board_snapshot(
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardSnapshot:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await build_board_snapshot(session, board)
@@ -229,13 +262,9 @@ async def get_board_group_snapshot(
include_self: bool = Query(default=False),
include_done: bool = Query(default=False),
per_board_task_limit: int = Query(default=5, ge=0, le=100),
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> BoardGroupSnapshot:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return await build_board_group_snapshot(
session,
board=board,
@@ -249,8 +278,7 @@ async def get_board_group_snapshot(
async def update_board(
payload: BoardUpdate,
session: AsyncSession = Depends(get_session),
board: Board = Depends(get_board_or_404),
auth: AuthContext = Depends(require_admin_auth),
board: Board = Depends(get_board_for_user_write),
) -> Board:
return await _apply_board_update(payload=payload, session=session, board=board)
@@ -258,8 +286,7 @@ async def update_board(
@router.delete("/{board_id}", response_model=OkResponse)
async def delete_board(
session: AsyncSession = Depends(get_session),
board: Board = Depends(get_board_or_404),
auth: AuthContext = Depends(require_admin_auth),
board: Board = Depends(get_board_for_user_write),
) -> OkResponse:
agents = list(await session.exec(select(Agent).where(Agent.board_id == board.id)))
task_ids = list(await session.exec(select(Task.id).where(Task.board_id == board.id)))

View File

@@ -13,6 +13,14 @@ from app.models.agents import Agent
from app.models.boards import Board
from app.models.tasks import Task
from app.models.users import User
from app.models.organizations import Organization
from app.services.organizations import (
OrganizationContext,
ensure_member_for_user,
get_active_membership,
is_org_admin,
require_board_access,
)
from app.services.admin_access import require_admin
@@ -40,6 +48,31 @@ def require_admin_or_agent(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
async def require_org_member(
auth: AuthContext = Depends(get_auth_context),
session: AsyncSession = Depends(get_session),
) -> OrganizationContext:
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await get_active_membership(session, auth.user)
if member is None:
member = await ensure_member_for_user(session, auth.user)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
organization = await session.get(Organization, member.organization_id)
if organization is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return OrganizationContext(organization=organization, member=member)
async def require_org_admin(
ctx: OrganizationContext = Depends(require_org_member),
) -> OrganizationContext:
if not is_org_admin(ctx.member):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return ctx
async def get_board_or_404(
board_id: str,
session: AsyncSession = Depends(get_session),
@@ -50,9 +83,73 @@ async def get_board_or_404(
return board
async def get_board_for_actor_read(
board_id: str,
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return board
if actor.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=actor.user, board=board, write=False)
return board
async def get_board_for_actor_write(
board_id: str,
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if actor.actor_type == "agent":
if actor.agent and actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
return board
if actor.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=actor.user, board=board, write=True)
return board
async def get_board_for_user_read(
board_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=False)
return board
async def get_board_for_user_write(
board_id: str,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> Board:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=True)
return board
async def get_task_or_404(
task_id: str,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
) -> Task:
task = await session.get(Task, task_id)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin
from app.core.auth import AuthContext, get_auth_context
from app.db.session import get_session
from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig
@@ -20,6 +21,7 @@ from app.integrations.openclaw_gateway_protocol import (
)
from app.models.boards import Board
from app.models.gateways import Gateway
from app.services.organizations import OrganizationContext, require_board_access
from app.schemas.common import OkResponse
from app.schemas.gateway_api import (
GatewayCommandsResponse,
@@ -40,6 +42,8 @@ async def _resolve_gateway(
gateway_url: str | None,
gateway_token: str | None,
gateway_main_session_key: str | None,
*,
user: object | None = None,
) -> tuple[Board | None, GatewayClientConfig, str | None]:
if gateway_url:
return (
@@ -55,6 +59,8 @@ async def _resolve_gateway(
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Board not found")
if isinstance(user, object) and user is not None:
await require_board_access(session, user=user, board=board, write=False) # type: ignore[arg-type]
if not board.gateway_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -79,9 +85,16 @@ async def _resolve_gateway(
async def _require_gateway(
session: AsyncSession, board_id: str | None
session: AsyncSession, board_id: str | None, *, user: object | None = None
) -> tuple[Board, GatewayClientConfig, str | None]:
board, config, main_session = await _resolve_gateway(session, board_id, None, None, None)
board, config, main_session = await _resolve_gateway(
session,
board_id,
None,
None,
None,
user=user,
)
if board is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -95,6 +108,7 @@ async def gateways_status(
params: GatewayResolveQuery = Depends(),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaysStatusResponse:
board, config, main_session = await _resolve_gateway(
session,
@@ -102,7 +116,10 @@ async def gateways_status(
params.gateway_url,
params.gateway_token,
params.gateway_main_session_key,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
sessions = await openclaw_call("sessions.list", config=config)
if isinstance(sessions, dict):
@@ -136,6 +153,7 @@ async def list_gateway_sessions(
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaySessionsResponse:
board, config, main_session = await _resolve_gateway(
session,
@@ -143,7 +161,10 @@ async def list_gateway_sessions(
None,
None,
None,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
sessions = await openclaw_call("sessions.list", config=config)
except OpenClawGatewayError as exc:
@@ -175,6 +196,7 @@ async def get_gateway_session(
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaySessionResponse:
board, config, main_session = await _resolve_gateway(
session,
@@ -182,7 +204,10 @@ async def get_gateway_session(
None,
None,
None,
user=auth.user,
)
if board is not None and board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
sessions = await openclaw_call("sessions.list", config=config)
except OpenClawGatewayError as exc:
@@ -220,8 +245,11 @@ async def get_session_history(
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewaySessionHistoryResponse:
_, config, _ = await _require_gateway(session, board_id)
board, config, _ = await _require_gateway(session, board_id, user=auth.user)
if board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
try:
history = await get_chat_history(session_id, config=config)
except OpenClawGatewayError as exc:
@@ -238,8 +266,14 @@ async def send_gateway_session_message(
board_id: str | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OkResponse:
board, config, main_session = await _require_gateway(session, board_id)
board, config, main_session = await _require_gateway(session, board_id, user=auth.user)
if board.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=True)
try:
if main_session and session_id == main_session:
await ensure_session(main_session, config=config, label="Main Agent")
@@ -252,6 +286,7 @@ async def send_gateway_session_message(
@router.get("/commands", response_model=GatewayCommandsResponse)
async def gateway_commands(
auth: AuthContext = Depends(get_auth_context),
_ctx: OrganizationContext = Depends(require_org_admin),
) -> GatewayCommandsResponse:
return GatewayCommandsResponse(
protocol_version=PROTOCOL_VERSION,

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_admin_auth
from app.api.deps import require_org_admin
from app.core.agent_tokens import generate_agent_token, hash_agent_token
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
@@ -131,9 +131,13 @@ async def _ensure_main_agent(
@router.get("", response_model=DefaultLimitOffsetPage[GatewayRead])
async def list_gateways(
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> DefaultLimitOffsetPage[GatewayRead]:
statement = select(Gateway).order_by(col(Gateway.created_at).desc())
statement = (
select(Gateway)
.where(col(Gateway.organization_id) == ctx.organization.id)
.order_by(col(Gateway.created_at).desc())
)
return await paginate(session, statement)
@@ -142,8 +146,10 @@ async def create_gateway(
payload: GatewayCreate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> Gateway:
data = payload.model_dump()
data["organization_id"] = ctx.organization.id
gateway = Gateway.model_validate(data)
session.add(gateway)
await session.commit()
@@ -156,10 +162,10 @@ async def create_gateway(
async def get_gateway(
gateway_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> Gateway:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
return gateway
@@ -170,9 +176,10 @@ async def update_gateway(
payload: GatewayUpdate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> Gateway:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
previous_name = gateway.name
previous_session_key = gateway.main_session_key
@@ -202,10 +209,11 @@ async def sync_gateway_templates(
force_bootstrap: bool = Query(default=False),
board_id: UUID | None = Query(default=None),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> GatewayTemplatesSyncResult:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
return await sync_gateway_templates_service(
session,
@@ -223,10 +231,10 @@ async def sync_gateway_templates(
async def delete_gateway(
gateway_id: UUID,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
ctx=Depends(require_org_admin),
) -> OkResponse:
gateway = await session.get(Gateway, gateway_id)
if gateway is None:
if gateway is None or gateway.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Gateway not found")
await session.delete(gateway)
await session.commit()

View File

@@ -3,14 +3,14 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import DateTime, case, cast, func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_admin_auth
from app.core.auth import AuthContext
from app.api.deps import require_org_member
from app.core.time import utcnow
from app.db.session import get_session
from app.models.activity_events import ActivityEvent
@@ -26,6 +26,7 @@ from app.schemas.metrics import (
DashboardWipRangeSeries,
DashboardWipSeriesSet,
)
from app.services.organizations import list_accessible_board_ids
router = APIRouter(prefix="/metrics", tags=["metrics"])
@@ -113,22 +114,29 @@ def _wip_series_from_mapping(
)
async def _query_throughput(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries:
async def _query_throughput(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
statement = (
select(bucket_col, func.count())
.where(col(Task.status) == "review")
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
if not board_ids:
return _series_from_mapping(range_spec, {})
statement = (
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
)
results = (await session.exec(statement)).all()
mapping = {row[0]: float(row[1]) for row in results}
return _series_from_mapping(range_spec, mapping)
async def _query_cycle_time(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries:
async def _query_cycle_time(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
in_progress = cast(Task.in_progress_at, DateTime)
duration_hours = func.extract("epoch", Task.updated_at - in_progress) / 3600.0
@@ -138,15 +146,20 @@ async def _query_cycle_time(session: AsyncSession, range_spec: RangeSpec) -> Das
.where(col(Task.in_progress_at).is_not(None))
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
if not board_ids:
return _series_from_mapping(range_spec, {})
statement = (
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
)
results = (await session.exec(statement)).all()
mapping = {row[0]: float(row[1] or 0) for row in results}
return _series_from_mapping(range_spec, mapping)
async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> DashboardRangeSeries:
async def _query_error_rate(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
) -> DashboardRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, ActivityEvent.created_at).label("bucket")
error_case = case(
(
@@ -157,10 +170,14 @@ async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> Das
)
statement = (
select(bucket_col, func.sum(error_case), func.count())
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
.where(col(ActivityEvent.created_at) >= range_spec.start)
.where(col(ActivityEvent.created_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
if not board_ids:
return _series_from_mapping(range_spec, {})
statement = (
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
)
results = (await session.exec(statement)).all()
mapping: dict[datetime, float] = {}
@@ -172,7 +189,9 @@ async def _query_error_rate(session: AsyncSession, range_spec: RangeSpec) -> Das
return _series_from_mapping(range_spec, mapping)
async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardWipRangeSeries:
async def _query_wip(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
) -> DashboardWipRangeSeries:
bucket_col = func.date_trunc(range_spec.bucket, Task.updated_at).label("bucket")
inbox_case = case((col(Task.status) == "inbox", 1), else_=0)
progress_case = case((col(Task.status) == "in_progress", 1), else_=0)
@@ -186,8 +205,11 @@ async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardW
)
.where(col(Task.updated_at) >= range_spec.start)
.where(col(Task.updated_at) <= range_spec.end)
.group_by(bucket_col)
.order_by(bucket_col)
)
if not board_ids:
return _wip_series_from_mapping(range_spec, {})
statement = (
statement.where(col(Task.board_id).in_(board_ids)).group_by(bucket_col).order_by(bucket_col)
)
results = (await session.exec(statement)).all()
mapping: dict[datetime, dict[str, int]] = {}
@@ -200,7 +222,7 @@ async def _query_wip(session: AsyncSession, range_spec: RangeSpec) -> DashboardW
return _wip_series_from_mapping(range_spec, mapping)
async def _median_cycle_time_7d(session: AsyncSession) -> float | None:
async def _median_cycle_time_7d(session: AsyncSession, board_ids: list[UUID]) -> float | None:
now = utcnow()
start = now - timedelta(days=7)
in_progress = cast(Task.in_progress_at, DateTime)
@@ -212,6 +234,9 @@ async def _median_cycle_time_7d(session: AsyncSession) -> float | None:
.where(col(Task.updated_at) >= start)
.where(col(Task.updated_at) <= now)
)
if not board_ids:
return None
statement = statement.where(col(Task.board_id).in_(board_ids))
value = (await session.exec(statement)).one_or_none()
if value is None:
return None
@@ -222,7 +247,9 @@ async def _median_cycle_time_7d(session: AsyncSession) -> float | None:
return float(value)
async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float:
async def _error_rate_kpi(
session: AsyncSession, range_spec: RangeSpec, board_ids: list[UUID]
) -> float:
error_case = case(
(
col(ActivityEvent.event_type).like(ERROR_EVENT_PATTERN),
@@ -232,9 +259,13 @@ async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float
)
statement = (
select(func.sum(error_case), func.count())
.join(Task, col(ActivityEvent.task_id) == col(Task.id))
.where(col(ActivityEvent.created_at) >= range_spec.start)
.where(col(ActivityEvent.created_at) <= range_spec.end)
)
if not board_ids:
return 0.0
statement = statement.where(col(Task.board_id).in_(board_ids))
result = (await session.exec(statement)).one_or_none()
if result is None:
return 0.0
@@ -244,18 +275,27 @@ async def _error_rate_kpi(session: AsyncSession, range_spec: RangeSpec) -> float
return (error_count / total_count) * 100 if total_count > 0 else 0.0
async def _active_agents(session: AsyncSession) -> int:
async def _active_agents(session: AsyncSession, board_ids: list[UUID]) -> int:
threshold = utcnow() - OFFLINE_AFTER
statement = select(func.count()).where(
col(Agent.last_seen_at).is_not(None),
col(Agent.last_seen_at) >= threshold,
)
if not board_ids:
return 0
statement = statement.where(col(Agent.board_id).in_(board_ids))
result = (await session.exec(statement)).one()
return int(result)
async def _tasks_in_progress(session: AsyncSession) -> int:
statement = select(func.count()).where(col(Task.status) == "in_progress")
async def _tasks_in_progress(session: AsyncSession, board_ids: list[UUID]) -> int:
if not board_ids:
return 0
statement = (
select(func.count())
.where(col(Task.status) == "in_progress")
.where(col(Task.board_id).in_(board_ids))
)
result = (await session.exec(statement)).one()
return int(result)
@@ -264,41 +304,42 @@ async def _tasks_in_progress(session: AsyncSession) -> int:
async def dashboard_metrics(
range: Literal["24h", "7d"] = Query(default="24h"),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
ctx=Depends(require_org_member),
) -> DashboardMetrics:
primary = _resolve_range(range)
comparison = _comparison_range(range)
board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False)
throughput_primary = await _query_throughput(session, primary)
throughput_comparison = await _query_throughput(session, comparison)
throughput_primary = await _query_throughput(session, primary, board_ids)
throughput_comparison = await _query_throughput(session, comparison, board_ids)
throughput = DashboardSeriesSet(
primary=throughput_primary,
comparison=throughput_comparison,
)
cycle_time_primary = await _query_cycle_time(session, primary)
cycle_time_comparison = await _query_cycle_time(session, comparison)
cycle_time_primary = await _query_cycle_time(session, primary, board_ids)
cycle_time_comparison = await _query_cycle_time(session, comparison, board_ids)
cycle_time = DashboardSeriesSet(
primary=cycle_time_primary,
comparison=cycle_time_comparison,
)
error_rate_primary = await _query_error_rate(session, primary)
error_rate_comparison = await _query_error_rate(session, comparison)
error_rate_primary = await _query_error_rate(session, primary, board_ids)
error_rate_comparison = await _query_error_rate(session, comparison, board_ids)
error_rate = DashboardSeriesSet(
primary=error_rate_primary,
comparison=error_rate_comparison,
)
wip_primary = await _query_wip(session, primary)
wip_comparison = await _query_wip(session, comparison)
wip_primary = await _query_wip(session, primary, board_ids)
wip_comparison = await _query_wip(session, comparison, board_ids)
wip = DashboardWipSeriesSet(
primary=wip_primary,
comparison=wip_comparison,
)
kpis = DashboardKpis(
active_agents=await _active_agents(session),
tasks_in_progress=await _tasks_in_progress(session),
error_rate_pct=await _error_rate_kpi(session, primary),
median_cycle_time_hours_7d=await _median_cycle_time_7d(session),
active_agents=await _active_agents(session, board_ids),
tasks_in_progress=await _tasks_in_progress(session, board_ids),
error_rate_pct=await _error_rate_kpi(session, primary, board_ids),
median_cycle_time_hours_7d=await _median_cycle_time_7d(session, board_ids),
)
return DashboardMetrics(

View File

@@ -0,0 +1,403 @@
from __future__ import annotations
import secrets
from typing import Any, Sequence
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import require_org_admin, require_org_member
from app.core.auth import AuthContext, get_auth_context
from app.core.time import utcnow
from app.db.pagination import paginate
from app.db.session import get_session
from app.models.boards import Board
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.users import User
from app.schemas.organizations import (
OrganizationActiveUpdate,
OrganizationCreate,
OrganizationInviteAccept,
OrganizationInviteCreate,
OrganizationInviteRead,
OrganizationListItem,
OrganizationMemberAccessUpdate,
OrganizationMemberRead,
OrganizationMemberUpdate,
OrganizationBoardAccessRead,
OrganizationRead,
OrganizationUserRead,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.services.organizations import (
OrganizationContext,
accept_invite,
apply_invite_to_member,
apply_invite_board_access,
apply_member_access_update,
get_active_membership,
get_member,
is_org_admin,
normalize_invited_email,
normalize_role,
set_active_organization,
)
router = APIRouter(prefix="/organizations", tags=["organizations"])
def _member_to_read(member: OrganizationMember, user: User | None) -> OrganizationMemberRead:
model = OrganizationMemberRead.model_validate(member, from_attributes=True)
if user is not None:
model.user = OrganizationUserRead.model_validate(user, from_attributes=True)
return model
@router.post("", response_model=OrganizationRead)
async def create_organization(
payload: OrganizationCreate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> OrganizationRead:
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
name = payload.name.strip()
if not name:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
existing = (
await session.exec(
select(Organization).where(func.lower(col(Organization.name)) == name.lower())
)
).first()
if existing is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
now = utcnow()
org = Organization(name=name, created_at=now, updated_at=now)
session.add(org)
await session.flush()
member = OrganizationMember(
organization_id=org.id,
user_id=auth.user.id,
role="owner",
all_boards_read=True,
all_boards_write=True,
created_at=now,
updated_at=now,
)
session.add(member)
await session.flush()
await set_active_organization(session, user=auth.user, organization_id=org.id)
await session.commit()
await session.refresh(org)
return OrganizationRead.model_validate(org, from_attributes=True)
@router.get("/me/list", response_model=list[OrganizationListItem])
async def list_my_organizations(
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> list[OrganizationListItem]:
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await get_active_membership(session, auth.user)
db_user = await session.get(User, auth.user.id)
active_id = db_user.active_organization_id if db_user else auth.user.active_organization_id
statement = (
select(Organization, OrganizationMember)
.join(OrganizationMember, col(OrganizationMember.organization_id) == col(Organization.id))
.where(col(OrganizationMember.user_id) == auth.user.id)
.order_by(func.lower(col(Organization.name)).asc())
)
rows = list(await session.exec(statement))
return [
OrganizationListItem(
id=org.id,
name=org.name,
role=member.role,
is_active=org.id == active_id,
)
for org, member in rows
]
@router.patch("/me/active", response_model=OrganizationRead)
async def set_active_org(
payload: OrganizationActiveUpdate,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> OrganizationRead:
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
member = await set_active_organization(
session, user=auth.user, organization_id=payload.organization_id
)
organization = await session.get(Organization, member.organization_id)
if organization is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
return OrganizationRead.model_validate(organization, from_attributes=True)
@router.get("/me", response_model=OrganizationRead)
async def get_my_org(ctx: OrganizationContext = Depends(require_org_member)) -> OrganizationRead:
return OrganizationRead.model_validate(ctx.organization, from_attributes=True)
@router.get("/me/member", response_model=OrganizationMemberRead)
async def get_my_membership(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
) -> OrganizationMemberRead:
user = await session.get(User, ctx.member.user_id)
access_rows = list(
await session.exec(
select(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id) == ctx.member.id
)
)
)
model = _member_to_read(ctx.member, user)
model.board_access = [
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) # type: ignore[name-defined]
for row in access_rows
]
return model
@router.get("/me/members", response_model=DefaultLimitOffsetPage[OrganizationMemberRead])
async def list_org_members(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
) -> DefaultLimitOffsetPage[OrganizationMemberRead]:
statement = (
select(OrganizationMember, User)
.join(User, col(User.id) == col(OrganizationMember.user_id))
.where(col(OrganizationMember.organization_id) == ctx.organization.id)
.order_by(func.lower(col(User.email)).asc(), col(User.name).asc())
)
def _transform(items: Sequence[Any]) -> Sequence[Any]:
output: list[OrganizationMemberRead] = []
for member, user in items:
output.append(_member_to_read(member, user))
return output
return await paginate(session, statement, transformer=_transform)
@router.get("/me/members/{member_id}", response_model=OrganizationMemberRead)
async def get_org_member(
member_id: UUID,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_member),
) -> OrganizationMemberRead:
member = await session.get(OrganizationMember, member_id)
if member is None or member.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if not is_org_admin(ctx.member) and member.user_id != ctx.member.user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
user = await session.get(User, member.user_id)
access_rows = list(
await session.exec(
select(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id) == member.id
)
)
)
model = _member_to_read(member, user)
model.board_access = [
OrganizationBoardAccessRead.model_validate(row, from_attributes=True) # type: ignore[name-defined]
for row in access_rows
]
return model
@router.patch("/me/members/{member_id}", response_model=OrganizationMemberRead)
async def update_org_member(
member_id: UUID,
payload: OrganizationMemberUpdate,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OrganizationMemberRead:
member = await session.get(OrganizationMember, member_id)
if member is None or member.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
updates = payload.model_dump(exclude_unset=True)
if "role" in updates and updates["role"] is not None:
member.role = normalize_role(updates["role"])
member.updated_at = utcnow()
session.add(member)
await session.commit()
await session.refresh(member)
user = await session.get(User, member.user_id)
return _member_to_read(member, user)
@router.put("/me/members/{member_id}/access", response_model=OrganizationMemberRead)
async def update_member_access(
member_id: UUID,
payload: OrganizationMemberAccessUpdate,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OrganizationMemberRead:
member = await session.get(OrganizationMember, member_id)
if member is None or member.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
board_ids = {entry.board_id for entry in payload.board_access}
if board_ids:
valid_board_ids = set(
await session.exec(
select(Board.id)
.where(col(Board.id).in_(board_ids))
.where(col(Board.organization_id) == ctx.organization.id)
)
)
if valid_board_ids != board_ids:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
await apply_member_access_update(session, member=member, update=payload)
await session.commit()
await session.refresh(member)
user = await session.get(User, member.user_id)
return _member_to_read(member, user)
@router.get("/me/invites", response_model=DefaultLimitOffsetPage[OrganizationInviteRead])
async def list_org_invites(
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> DefaultLimitOffsetPage[OrganizationInviteRead]:
statement = (
select(OrganizationInvite)
.where(col(OrganizationInvite.organization_id) == ctx.organization.id)
.where(col(OrganizationInvite.accepted_at).is_(None))
.order_by(col(OrganizationInvite.created_at).desc())
)
return await paginate(session, statement)
@router.post("/me/invites", response_model=OrganizationInviteRead)
async def create_org_invite(
payload: OrganizationInviteCreate,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OrganizationInviteRead:
email = normalize_invited_email(payload.invited_email)
if not email:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
existing_user = (
await session.exec(
select(User).where(func.lower(col(User.email)) == email)
)
).first()
if existing_user is not None:
existing_member = await get_member(
session,
user_id=existing_user.id,
organization_id=ctx.organization.id,
)
if existing_member is not None:
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
token = secrets.token_urlsafe(24)
invite = OrganizationInvite(
organization_id=ctx.organization.id,
invited_email=email,
token=token,
role=normalize_role(payload.role),
all_boards_read=payload.all_boards_read,
all_boards_write=payload.all_boards_write,
created_by_user_id=ctx.member.user_id,
created_at=utcnow(),
updated_at=utcnow(),
)
session.add(invite)
await session.flush()
board_ids = {entry.board_id for entry in payload.board_access}
if board_ids:
valid_board_ids = set(
await session.exec(
select(Board.id)
.where(col(Board.id).in_(board_ids))
.where(col(Board.organization_id) == ctx.organization.id)
)
)
if valid_board_ids != board_ids:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
await apply_invite_board_access(session, invite=invite, entries=payload.board_access)
await session.commit()
await session.refresh(invite)
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
@router.delete("/me/invites/{invite_id}", response_model=OrganizationInviteRead)
async def revoke_org_invite(
invite_id: UUID,
session: AsyncSession = Depends(get_session),
ctx: OrganizationContext = Depends(require_org_admin),
) -> OrganizationInviteRead:
invite = await session.get(OrganizationInvite, invite_id)
if invite is None or invite.organization_id != ctx.organization.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await session.execute(
OrganizationInviteBoardAccess.__table__.delete().where(
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
)
)
await session.delete(invite)
await session.commit()
return OrganizationInviteRead.model_validate(invite, from_attributes=True)
@router.post("/invites/accept", response_model=OrganizationMemberRead)
async def accept_org_invite(
payload: OrganizationInviteAccept,
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(get_auth_context),
) -> OrganizationMemberRead:
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
invite = (
await session.exec(
select(OrganizationInvite)
.where(col(OrganizationInvite.token) == payload.token)
.where(col(OrganizationInvite.accepted_at).is_(None))
)
).first()
if invite is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if invite.invited_email and auth.user.email:
if normalize_invited_email(invite.invited_email) != normalize_invited_email(auth.user.email):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
existing = await get_member(
session,
user_id=auth.user.id,
organization_id=invite.organization_id,
)
if existing is None:
member = await accept_invite(session, invite, auth.user)
else:
await apply_invite_to_member(session, member=existing, invite=invite)
invite.accepted_by_user_id = auth.user.id
invite.accepted_at = utcnow()
invite.updated_at = utcnow()
session.add(invite)
await session.commit()
member = existing
user = await session.get(User, member.user_id)
return _member_to_read(member, user)

View File

@@ -17,7 +17,8 @@ from sse_starlette.sse import EventSourceResponse
from app.api.deps import (
ActorContext,
get_board_or_404,
get_board_for_actor_read,
get_board_for_user_write,
get_task_or_404,
require_admin_auth,
require_admin_or_agent,
@@ -42,6 +43,7 @@ from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.organizations import require_board_access
from app.services.task_dependencies import (
blocked_by_dependency_ids,
dependency_ids_by_task_id,
@@ -442,7 +444,7 @@ async def _notify_lead_on_task_unassigned(
@router.get("/stream")
async def stream_tasks(
request: Request,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
actor: ActorContext = Depends(require_admin_or_agent),
since: str | None = Query(default=None),
) -> EventSourceResponse:
@@ -525,13 +527,10 @@ async def list_tasks(
status_filter: str | None = Query(default=None, alias="status"),
assigned_agent_id: UUID | None = None,
unassigned: bool | None = None,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_actor_read),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[TaskRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and actor.agent.board_id != board.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = select(Task).where(Task.board_id == board.id)
if status_filter:
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
@@ -586,7 +585,7 @@ async def list_tasks(
@router.post("", response_model=TaskRead, responses={409: {"model": BlockedTaskError}})
async def create_task(
payload: TaskCreate,
board: Board = Depends(get_board_or_404),
board: Board = Depends(get_board_for_user_write),
session: AsyncSession = Depends(get_session),
auth: AuthContext = Depends(require_admin_auth),
) -> TaskRead:
@@ -669,6 +668,11 @@ async def update_task(
detail="Task board_id is required.",
)
board_id = task.board_id
if actor.actor_type == "user" and actor.user is not None:
board = await session.get(Board, board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await require_board_access(session, user=actor.user, board=board, write=True)
previous_status = task.status
previous_assigned = task.assigned_agent_id
@@ -978,6 +982,14 @@ async def delete_task(
task: Task = Depends(get_task_or_404),
auth: AuthContext = Depends(require_admin_auth),
) -> OkResponse:
if task.board_id is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
board = await session.get(Board, task.board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
if auth.user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
await require_board_access(session, user=auth.user, board=board, write=True)
await session.execute(delete(ActivityEvent).where(col(ActivityEvent.task_id) == task.id))
await session.execute(delete(TaskFingerprint).where(col(TaskFingerprint.task_id) == task.id))
await session.execute(delete(Approval).where(col(Approval.task_id) == task.id))
@@ -998,11 +1010,7 @@ async def delete_task(
async def list_task_comments(
task: Task = Depends(get_task_or_404),
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> DefaultLimitOffsetPage[TaskCommentRead]:
if actor.actor_type == "agent" and actor.agent:
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
statement = (
select(ActivityEvent)
.where(col(ActivityEvent.task_id) == task.id)
@@ -1019,6 +1027,13 @@ async def create_task_comment(
session: AsyncSession = Depends(get_session),
actor: ActorContext = Depends(require_admin_or_agent),
) -> ActivityEvent:
if task.board_id is None:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY)
if actor.actor_type == "user" and actor.user is not None:
board = await session.get(Board, task.board_id)
if board is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
await require_board_access(session, user=actor.user, board=board, write=True)
if actor.actor_type == "agent" and actor.agent:
if actor.agent.is_board_lead and task.status != "review":
if not await _lead_was_mentioned(session, task, actor.agent) and not _lead_created_task(
@@ -1030,8 +1045,6 @@ async def create_task_comment(
"Board leads can only comment during review, when mentioned, or on tasks they created."
),
)
if actor.agent.board_id and task.board_id and actor.agent.board_id != task.board_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
event = ActivityEvent(
event_type="task.comment",
message=payload.message,

View File

@@ -97,6 +97,9 @@ async def get_auth_context(
clerk_user_id=clerk_user_id,
defaults=defaults,
)
from app.services.organizations import ensure_member_for_user
await ensure_member_for_user(session, user)
return AuthContext(
actor_type="user",
@@ -146,6 +149,9 @@ async def get_auth_context_optional(
clerk_user_id=clerk_user_id,
defaults=defaults,
)
from app.services.organizations import ensure_member_for_user
await ensure_member_for_user(session, user)
return AuthContext(
actor_type="user",

View File

@@ -20,6 +20,7 @@ from app.api.boards import router as boards_router
from app.api.gateway import router as gateway_router
from app.api.gateways import router as gateways_router
from app.api.metrics import router as metrics_router
from app.api.organizations import router as organizations_router
from app.api.souls_directory import router as souls_directory_router
from app.api.tasks import router as tasks_router
from app.api.users import router as users_router
@@ -75,6 +76,7 @@ api_v1.include_router(activity_router)
api_v1.include_router(gateway_router)
api_v1.include_router(gateways_router)
api_v1.include_router(metrics_router)
api_v1.include_router(organizations_router)
api_v1.include_router(souls_directory_router)
api_v1.include_router(board_groups_router)
api_v1.include_router(board_group_memory_router)

View File

@@ -7,6 +7,11 @@ from app.models.board_memory import BoardMemory
from app.models.board_onboarding import BoardOnboardingSession
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.task_dependencies import TaskDependency
from app.models.task_fingerprints import TaskFingerprint
from app.models.tasks import Task
@@ -22,6 +27,11 @@ __all__ = [
"BoardGroup",
"Board",
"Gateway",
"Organization",
"OrganizationMember",
"OrganizationBoardAccess",
"OrganizationInvite",
"OrganizationInviteBoardAccess",
"TaskDependency",
"Task",
"TaskFingerprint",

View File

@@ -13,6 +13,7 @@ class BoardGroup(TenantScoped, table=True):
__tablename__ = "board_groups"
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str
slug: str = Field(index=True)
description: str | None = None

View File

@@ -14,6 +14,7 @@ class Board(TenantScoped, table=True):
__tablename__ = "boards"
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str
slug: str = Field(index=True)
gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True)

View File

@@ -12,6 +12,7 @@ class Gateway(SQLModel, table=True):
__tablename__ = "gateways"
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
name: str
url: str
token: str | None = Field(default=None)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, SQLModel
from app.core.time import utcnow
class OrganizationBoardAccess(SQLModel, table=True):
__tablename__ = "organization_board_access"
__table_args__ = (
UniqueConstraint(
"organization_member_id",
"board_id",
name="uq_org_board_access_member_board",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_member_id: UUID = Field(
foreign_key="organization_members.id", index=True
)
board_id: UUID = Field(foreign_key="boards.id", index=True)
can_read: bool = Field(default=True)
can_write: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, SQLModel
from app.core.time import utcnow
class OrganizationInviteBoardAccess(SQLModel, table=True):
__tablename__ = "organization_invite_board_access"
__table_args__ = (
UniqueConstraint(
"organization_invite_id",
"board_id",
name="uq_org_invite_board_access_invite_board",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_invite_id: UUID = Field(
foreign_key="organization_invites.id", index=True
)
board_id: UUID = Field(foreign_key="boards.id", index=True)
can_read: bool = Field(default=True)
can_write: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, SQLModel
from app.core.time import utcnow
class OrganizationInvite(SQLModel, table=True):
__tablename__ = "organization_invites"
__table_args__ = (UniqueConstraint("token", name="uq_org_invites_token"),)
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
invited_email: str = Field(index=True)
token: str = Field(index=True)
role: str = Field(default="member", index=True)
all_boards_read: bool = Field(default=False)
all_boards_write: bool = Field(default=False)
created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
accepted_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True)
accepted_at: datetime | None = None
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,29 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, SQLModel
from app.core.time import utcnow
class OrganizationMember(SQLModel, table=True):
__tablename__ = "organization_members"
__table_args__ = (
UniqueConstraint(
"organization_id",
"user_id",
name="uq_organization_members_org_user",
),
)
id: UUID = Field(default_factory=uuid4, primary_key=True)
organization_id: UUID = Field(foreign_key="organizations.id", index=True)
user_id: UUID = Field(foreign_key="users.id", index=True)
role: str = Field(default="member", index=True)
all_boards_read: bool = Field(default=False)
all_boards_write: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID, uuid4
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, SQLModel
from app.core.time import utcnow
class Organization(SQLModel, table=True):
__tablename__ = "organizations"
__table_args__ = (UniqueConstraint("name", name="uq_organizations_name"),)
id: UUID = Field(default_factory=uuid4, primary_key=True)
name: str = Field(index=True)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)

View File

@@ -18,3 +18,6 @@ class User(SQLModel, table=True):
notes: str | None = None
context: str | None = None
is_super_admin: bool = Field(default=False)
active_organization_id: UUID | None = Field(
default=None, foreign_key="organizations.id", index=True
)

View File

@@ -12,6 +12,18 @@ from app.schemas.board_onboarding import (
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate
from app.schemas.metrics import DashboardMetrics
from app.schemas.organizations import (
OrganizationActiveUpdate,
OrganizationCreate,
OrganizationInviteAccept,
OrganizationInviteCreate,
OrganizationInviteRead,
OrganizationListItem,
OrganizationMemberAccessUpdate,
OrganizationMemberRead,
OrganizationMemberUpdate,
OrganizationRead,
)
from app.schemas.souls_directory import (
SoulsDirectoryMarkdownResponse,
SoulsDirectorySearchResponse,
@@ -43,6 +55,16 @@ __all__ = [
"GatewayRead",
"GatewayUpdate",
"DashboardMetrics",
"OrganizationActiveUpdate",
"OrganizationCreate",
"OrganizationInviteAccept",
"OrganizationInviteCreate",
"OrganizationInviteRead",
"OrganizationListItem",
"OrganizationMemberAccessUpdate",
"OrganizationMemberRead",
"OrganizationMemberUpdate",
"OrganizationRead",
"SoulsDirectoryMarkdownResponse",
"SoulsDirectorySearchResponse",
"SoulsDirectorySoulRef",

View File

@@ -24,5 +24,6 @@ class BoardGroupUpdate(SQLModel):
class BoardGroupRead(BoardGroupBase):
id: UUID
organization_id: UUID
created_at: datetime
updated_at: datetime

View File

@@ -54,5 +54,6 @@ class BoardUpdate(SQLModel):
class BoardRead(BoardBase):
id: UUID
organization_id: UUID
created_at: datetime
updated_at: datetime

View File

@@ -49,6 +49,7 @@ class GatewayUpdate(SQLModel):
class GatewayRead(GatewayBase):
id: UUID
organization_id: UUID
token: str | None = None
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlmodel import Field, SQLModel
class OrganizationRead(SQLModel):
id: UUID
name: str
created_at: datetime
updated_at: datetime
class OrganizationCreate(SQLModel):
name: str
class OrganizationActiveUpdate(SQLModel):
organization_id: UUID
class OrganizationListItem(SQLModel):
id: UUID
name: str
role: str
is_active: bool
class OrganizationUserRead(SQLModel):
id: UUID
email: str | None = None
name: str | None = None
preferred_name: str | None = None
class OrganizationMemberRead(SQLModel):
id: UUID
organization_id: UUID
user_id: UUID
role: str
all_boards_read: bool
all_boards_write: bool
created_at: datetime
updated_at: datetime
user: OrganizationUserRead | None = None
board_access: list[OrganizationBoardAccessRead] = Field(default_factory=list)
class OrganizationMemberUpdate(SQLModel):
role: str | None = None
class OrganizationBoardAccessSpec(SQLModel):
board_id: UUID
can_read: bool = True
can_write: bool = False
class OrganizationBoardAccessRead(SQLModel):
id: UUID
board_id: UUID
can_read: bool
can_write: bool
created_at: datetime
updated_at: datetime
class OrganizationMemberAccessUpdate(SQLModel):
all_boards_read: bool = False
all_boards_write: bool = False
board_access: list[OrganizationBoardAccessSpec] = Field(default_factory=list)
class OrganizationInviteCreate(SQLModel):
invited_email: str
role: str = "member"
all_boards_read: bool = False
all_boards_write: bool = False
board_access: list[OrganizationBoardAccessSpec] = Field(default_factory=list)
class OrganizationInviteRead(SQLModel):
id: UUID
organization_id: UUID
invited_email: str
role: str
all_boards_read: bool
all_boards_write: bool
token: str
created_by_user_id: UUID | None = None
accepted_by_user_id: UUID | None = None
accepted_at: datetime | None = None
created_at: datetime
updated_at: datetime
class OrganizationInviteAccept(SQLModel):
token: str

View File

@@ -0,0 +1,464 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Iterable
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import func, or_
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.time import utcnow
from app.models.boards import Board
from app.models.organization_board_access import OrganizationBoardAccess
from app.models.organization_invite_board_access import OrganizationInviteBoardAccess
from app.models.organization_invites import OrganizationInvite
from app.models.organization_members import OrganizationMember
from app.models.organizations import Organization
from app.models.users import User
from app.schemas.organizations import OrganizationBoardAccessSpec, OrganizationMemberAccessUpdate
DEFAULT_ORG_NAME = "Personal"
ADMIN_ROLES = {"owner", "admin"}
ROLE_RANK = {"member": 0, "admin": 1, "owner": 2}
@dataclass(frozen=True)
class OrganizationContext:
organization: Organization
member: OrganizationMember
def is_org_admin(member: OrganizationMember) -> bool:
return member.role in ADMIN_ROLES
async def get_default_org(session: AsyncSession) -> Organization | None:
statement = select(Organization).where(col(Organization.name) == DEFAULT_ORG_NAME)
return (await session.exec(statement)).first()
async def ensure_default_org(session: AsyncSession) -> Organization:
org = await get_default_org(session)
if org is not None:
return org
org = Organization(name=DEFAULT_ORG_NAME, created_at=utcnow(), updated_at=utcnow())
session.add(org)
await session.commit()
await session.refresh(org)
return org
async def get_member(
session: AsyncSession,
*,
user_id: UUID,
organization_id: UUID,
) -> OrganizationMember | None:
statement = select(OrganizationMember).where(
col(OrganizationMember.organization_id) == organization_id,
col(OrganizationMember.user_id) == user_id,
)
return (await session.exec(statement)).first()
async def get_first_membership(session: AsyncSession, user_id: UUID) -> OrganizationMember | None:
statement = (
select(OrganizationMember)
.where(col(OrganizationMember.user_id) == user_id)
.order_by(col(OrganizationMember.created_at).asc())
)
return (await session.exec(statement)).first()
async def set_active_organization(
session: AsyncSession,
*,
user: User,
organization_id: UUID,
) -> OrganizationMember:
member = await get_member(
session, user_id=user.id, organization_id=organization_id
)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access")
if user.active_organization_id != organization_id:
user.active_organization_id = organization_id
session.add(user)
await session.commit()
return member
async def get_active_membership(
session: AsyncSession,
user: User,
) -> OrganizationMember | None:
db_user = await session.get(User, user.id)
if db_user is None:
db_user = user
if db_user.active_organization_id:
member = await get_member(
session,
user_id=db_user.id,
organization_id=db_user.active_organization_id,
)
if member is not None:
user.active_organization_id = db_user.active_organization_id
return member
db_user.active_organization_id = None
session.add(db_user)
await session.commit()
member = await get_first_membership(session, db_user.id)
if member is None:
return None
await set_active_organization(
session,
user=db_user,
organization_id=member.organization_id,
)
user.active_organization_id = db_user.active_organization_id
return member
async def _find_pending_invite(
session: AsyncSession,
email: str,
) -> OrganizationInvite | None:
statement = (
select(OrganizationInvite)
.where(col(OrganizationInvite.accepted_at).is_(None))
.where(col(OrganizationInvite.invited_email) == email)
.order_by(col(OrganizationInvite.created_at).asc())
)
return (await session.exec(statement)).first()
async def accept_invite(
session: AsyncSession,
invite: OrganizationInvite,
user: User,
) -> OrganizationMember:
now = utcnow()
member = OrganizationMember(
organization_id=invite.organization_id,
user_id=user.id,
role=invite.role,
all_boards_read=invite.all_boards_read,
all_boards_write=invite.all_boards_write,
created_at=now,
updated_at=now,
)
session.add(member)
await session.flush()
if not (invite.all_boards_read or invite.all_boards_write):
access_rows = list(
await session.exec(
select(OrganizationInviteBoardAccess).where(
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
)
)
)
for row in access_rows:
session.add(
OrganizationBoardAccess(
organization_member_id=member.id,
board_id=row.board_id,
can_read=row.can_read,
can_write=row.can_write,
created_at=now,
updated_at=now,
)
)
invite.accepted_by_user_id = user.id
invite.accepted_at = now
invite.updated_at = now
session.add(invite)
if user.active_organization_id is None:
user.active_organization_id = invite.organization_id
session.add(user)
await session.commit()
await session.refresh(member)
return member
async def ensure_member_for_user(session: AsyncSession, user: User) -> OrganizationMember:
existing = await get_active_membership(session, user)
if existing is not None:
return existing
if user.email:
invite = await _find_pending_invite(session, user.email)
if invite is not None:
return await accept_invite(session, invite, user)
org = await ensure_default_org(session)
now = utcnow()
member_count = (
await session.exec(
select(func.count())
.where(col(OrganizationMember.organization_id) == org.id)
)
).one()
is_first = int(member_count or 0) == 0
member = OrganizationMember(
organization_id=org.id,
user_id=user.id,
role="owner" if is_first else "member",
all_boards_read=is_first,
all_boards_write=is_first,
created_at=now,
updated_at=now,
)
user.active_organization_id = org.id
session.add(user)
session.add(member)
await session.commit()
await session.refresh(member)
return member
def member_all_boards_read(member: OrganizationMember) -> bool:
return member.all_boards_read or member.all_boards_write
def member_all_boards_write(member: OrganizationMember) -> bool:
return member.all_boards_write
async def has_board_access(
session: AsyncSession,
*,
member: OrganizationMember,
board: Board,
write: bool,
) -> bool:
if member.organization_id != board.organization_id:
return False
if write:
if member_all_boards_write(member):
return True
else:
if member_all_boards_read(member):
return True
statement = select(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id) == member.id,
col(OrganizationBoardAccess.board_id) == board.id,
)
access = (await session.exec(statement)).first()
if access is None:
return False
if write:
return bool(access.can_write)
return bool(access.can_read or access.can_write)
async def require_board_access(
session: AsyncSession,
*,
user: User,
board: Board,
write: bool,
) -> OrganizationMember:
member = await get_member(session, user_id=user.id, organization_id=board.organization_id)
if member is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="No org access")
if not await has_board_access(session, member=member, board=board, write=write):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Board access denied")
return member
def board_access_filter(member: OrganizationMember, *, write: bool) -> object:
if write and member_all_boards_write(member):
return col(Board.organization_id) == member.organization_id
if not write and member_all_boards_read(member):
return col(Board.organization_id) == member.organization_id
access_stmt = select(OrganizationBoardAccess.board_id).where(
col(OrganizationBoardAccess.organization_member_id) == member.id
)
if write:
access_stmt = access_stmt.where(col(OrganizationBoardAccess.can_write).is_(True))
else:
access_stmt = access_stmt.where(
or_(
col(OrganizationBoardAccess.can_read).is_(True),
col(OrganizationBoardAccess.can_write).is_(True),
)
)
return col(Board.id).in_(access_stmt)
async def list_accessible_board_ids(
session: AsyncSession,
*,
member: OrganizationMember,
write: bool,
) -> list[UUID]:
if (write and member_all_boards_write(member)) or (
not write and member_all_boards_read(member)
):
ids = await session.exec(
select(Board.id).where(col(Board.organization_id) == member.organization_id)
)
return list(ids)
access_stmt = select(OrganizationBoardAccess.board_id).where(
col(OrganizationBoardAccess.organization_member_id) == member.id
)
if write:
access_stmt = access_stmt.where(col(OrganizationBoardAccess.can_write).is_(True))
else:
access_stmt = access_stmt.where(
or_(
col(OrganizationBoardAccess.can_read).is_(True),
col(OrganizationBoardAccess.can_write).is_(True),
)
)
board_ids = await session.exec(access_stmt)
return list(board_ids)
async def apply_member_access_update(
session: AsyncSession,
*,
member: OrganizationMember,
update: OrganizationMemberAccessUpdate,
) -> None:
now = utcnow()
member.all_boards_read = update.all_boards_read
member.all_boards_write = update.all_boards_write
member.updated_at = now
session.add(member)
await session.execute(
OrganizationBoardAccess.__table__.delete().where(
col(OrganizationBoardAccess.organization_member_id) == member.id
)
)
if update.all_boards_read or update.all_boards_write:
return
rows: list[OrganizationBoardAccess] = []
for entry in update.board_access:
rows.append(
OrganizationBoardAccess(
organization_member_id=member.id,
board_id=entry.board_id,
can_read=entry.can_read,
can_write=entry.can_write,
created_at=now,
updated_at=now,
)
)
session.add_all(rows)
async def apply_invite_board_access(
session: AsyncSession,
*,
invite: OrganizationInvite,
entries: Iterable[OrganizationBoardAccessSpec],
) -> None:
await session.execute(
OrganizationInviteBoardAccess.__table__.delete().where(
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
)
)
if invite.all_boards_read or invite.all_boards_write:
return
now = utcnow()
rows: list[OrganizationInviteBoardAccess] = []
for entry in entries:
rows.append(
OrganizationInviteBoardAccess(
organization_invite_id=invite.id,
board_id=entry.board_id,
can_read=entry.can_read,
can_write=entry.can_write,
created_at=now,
updated_at=now,
)
)
session.add_all(rows)
def normalize_invited_email(email: str) -> str:
return email.strip().lower()
def normalize_role(role: str) -> str:
return role.strip().lower() or "member"
def _role_rank(role: str | None) -> int:
if not role:
return 0
return ROLE_RANK.get(role, 0)
async def apply_invite_to_member(
session: AsyncSession,
*,
member: OrganizationMember,
invite: OrganizationInvite,
) -> None:
now = utcnow()
member_changed = False
invite_role = normalize_role(invite.role or "member")
if _role_rank(invite_role) > _role_rank(member.role):
member.role = invite_role
member_changed = True
if invite.all_boards_read or invite.all_boards_write:
member.all_boards_read = (
member.all_boards_read or invite.all_boards_read or invite.all_boards_write
)
member.all_boards_write = member.all_boards_write or invite.all_boards_write
member_changed = True
if member_changed:
member.updated_at = now
session.add(member)
return
access_rows = list(
await session.exec(
select(OrganizationInviteBoardAccess).where(
col(OrganizationInviteBoardAccess.organization_invite_id) == invite.id
)
)
)
for row in access_rows:
existing = (
await session.exec(
select(OrganizationBoardAccess).where(
col(OrganizationBoardAccess.organization_member_id) == member.id,
col(OrganizationBoardAccess.board_id) == row.board_id,
)
)
).first()
can_write = bool(row.can_write)
can_read = bool(row.can_read or row.can_write)
if existing is None:
session.add(
OrganizationBoardAccess(
organization_member_id=member.id,
board_id=row.board_id,
can_read=can_read,
can_write=can_write,
created_at=now,
updated_at=now,
)
)
else:
existing.can_read = bool(existing.can_read or can_read)
existing.can_write = bool(existing.can_write or can_write)
existing.updated_at = now
session.add(existing)
if member_changed:
member.updated_at = now
session.add(member)