refactor: route authorization through auth context

This commit is contained in:
Anish Sarkar 2026-06-19 20:27:28 +05:30
parent 630880bf7a
commit 7e8d26fa81
4 changed files with 105 additions and 47 deletions

View file

@ -5,6 +5,7 @@ from __future__ import annotations
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.db import ExternalChatBinding, Permission, User from app.db import ExternalChatBinding, Permission, User
from app.gateway.bindings import suspend_binding from app.gateway.bindings import suspend_binding
from app.observability.metrics import record_gateway_auth_invariant_failure from app.observability.metrics import record_gateway_auth_invariant_failure
@ -39,11 +40,13 @@ async def assert_authorization_invariant(
if user is None: if user is None:
await _fail(session, binding, "owner_missing") await _fail(session, binding, "owner_missing")
auth = AuthContext.system(user, source="gateway")
try: try:
await check_search_space_access(session, user, binding.search_space_id) await check_search_space_access(session, auth, binding.search_space_id)
await check_permission( await check_permission(
session, session,
user, auth,
binding.search_space_id, binding.search_space_id,
Permission.CHATS_CREATE.value, Permission.CHATS_CREATE.value,
"External chat owner no longer has permission to chat in this search space", "External chat owner no longer has permission to chat in this search space",

View file

@ -30,6 +30,7 @@ from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.context import AuthContext
from app.agents.chat.multi_agent_chat.shared.feature_flags import get_flags from app.agents.chat.multi_agent_chat.shared.feature_flags import get_flags
from app.db import ( from app.db import (
AgentPermissionRule, AgentPermissionRule,
@ -39,7 +40,7 @@ from app.db import (
User, User,
get_async_session, get_async_session,
) )
from app.users import current_active_user from app.users import get_auth_context
from app.utils.rbac import check_permission from app.utils.rbac import check_permission
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -133,15 +134,16 @@ def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead:
async def _ensure_search_space_membership_admin( async def _ensure_search_space_membership_admin(
session: AsyncSession, user: User, search_space_id: int session: AsyncSession, auth: AuthContext, search_space_id: int
) -> None: ) -> None:
user = auth.user
"""Curating agent rules == "settings" administration on the space.""" """Curating agent rules == "settings" administration on the space."""
space = await session.get(SearchSpace, search_space_id) space = await session.get(SearchSpace, search_space_id)
if space is None: if space is None:
raise HTTPException(status_code=404, detail="Search space not found.") raise HTTPException(status_code=404, detail="Search space not found.")
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.SETTINGS_UPDATE.value, Permission.SETTINGS_UPDATE.value,
"You don't have permission to manage agent permission rules in this space.", "You don't have permission to manage agent permission rules in this space.",
@ -160,8 +162,9 @@ async def _ensure_search_space_membership_admin(
async def list_rules( async def list_rules(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
) -> list[AgentPermissionRuleRead]: ) -> list[AgentPermissionRuleRead]:
user = auth.user
_flag_guard() _flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id) await _ensure_search_space_membership_admin(session, user, search_space_id)
@ -183,8 +186,9 @@ async def create_rule(
search_space_id: int, search_space_id: int,
payload: AgentPermissionRuleCreate, payload: AgentPermissionRuleCreate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
) -> AgentPermissionRuleRead: ) -> AgentPermissionRuleRead:
user = auth.user
_flag_guard() _flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id) await _ensure_search_space_membership_admin(session, user, search_space_id)
@ -232,8 +236,9 @@ async def update_rule(
rule_id: int, rule_id: int,
payload: AgentPermissionRuleUpdate, payload: AgentPermissionRuleUpdate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
) -> AgentPermissionRuleRead: ) -> AgentPermissionRuleRead:
user = auth.user
_flag_guard() _flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id) await _ensure_search_space_membership_admin(session, user, search_space_id)
@ -266,8 +271,9 @@ async def delete_rule(
search_space_id: int, search_space_id: int,
rule_id: int, rule_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
) -> None: ) -> None:
user = auth.user
_flag_guard() _flag_guard()
await _ensure_search_space_membership_admin(session, user, search_space_id) await _ensure_search_space_membership_admin(session, user, search_space_id)

View file

@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
from app.db import ( from app.db import (
Permission, Permission,
SearchSpace, SearchSpace,
@ -43,7 +44,7 @@ from app.schemas import (
RoleUpdate, RoleUpdate,
UserSearchSpaceAccess, UserSearchSpaceAccess,
) )
from app.users import current_active_user from app.users import get_auth_context
from app.utils.rbac import ( from app.utils.rbac import (
check_permission, check_permission,
check_search_space_access, check_search_space_access,
@ -107,6 +108,8 @@ PERMISSION_DESCRIPTIONS = {
"settings:view": "View search space settings", "settings:view": "View search space settings",
"settings:update": "Modify search space settings", "settings:update": "Modify search space settings",
"settings:delete": "Delete the entire search space", "settings:delete": "Delete the entire search space",
# API access
"api_access:manage": "Enable or disable programmatic API access for a search space",
# Automations # Automations
"automations:create": "Create automations from chat or JSON", "automations:create": "Create automations from chat or JSON",
"automations:read": "View automations, their triggers, and run history", "automations:read": "View automations, their triggers, and run history",
@ -120,8 +123,9 @@ PERMISSION_DESCRIPTIONS = {
@router.get("/permissions", response_model=PermissionsListResponse) @router.get("/permissions", response_model=PermissionsListResponse)
async def list_all_permissions( async def list_all_permissions(
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
List all available permissions that can be assigned to roles. List all available permissions that can be assigned to roles.
""" """
@ -156,8 +160,9 @@ async def create_role(
search_space_id: int, search_space_id: int,
role_data: RoleCreate, role_data: RoleCreate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Create a new custom role in a search space. Create a new custom role in a search space.
Requires ROLES_CREATE permission. Requires ROLES_CREATE permission.
@ -165,7 +170,7 @@ async def create_role(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.ROLES_CREATE.value, Permission.ROLES_CREATE.value,
"You don't have permission to create roles", "You don't have permission to create roles",
@ -237,8 +242,9 @@ async def create_role(
async def list_roles( async def list_roles(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
List all roles in a search space. List all roles in a search space.
Requires ROLES_READ permission. Requires ROLES_READ permission.
@ -246,7 +252,7 @@ async def list_roles(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.ROLES_READ.value, Permission.ROLES_READ.value,
"You don't have permission to view roles", "You don't have permission to view roles",
@ -275,8 +281,9 @@ async def get_role(
search_space_id: int, search_space_id: int,
role_id: int, role_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Get a specific role by ID. Get a specific role by ID.
Requires ROLES_READ permission. Requires ROLES_READ permission.
@ -284,7 +291,7 @@ async def get_role(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.ROLES_READ.value, Permission.ROLES_READ.value,
"You don't have permission to view roles", "You don't have permission to view roles",
@ -320,8 +327,9 @@ async def update_role(
role_id: int, role_id: int,
role_update: RoleUpdate, role_update: RoleUpdate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Update a role. Update a role.
Requires ROLES_UPDATE permission. Requires ROLES_UPDATE permission.
@ -330,7 +338,7 @@ async def update_role(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.ROLES_UPDATE.value, Permission.ROLES_UPDATE.value,
"You don't have permission to update roles", "You don't have permission to update roles",
@ -417,8 +425,9 @@ async def delete_role(
search_space_id: int, search_space_id: int,
role_id: int, role_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Delete a custom role. Delete a custom role.
Requires ROLES_DELETE permission. Requires ROLES_DELETE permission.
@ -427,7 +436,7 @@ async def delete_role(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.ROLES_DELETE.value, Permission.ROLES_DELETE.value,
"You don't have permission to delete roles", "You don't have permission to delete roles",
@ -474,8 +483,9 @@ async def delete_role(
async def list_members( async def list_members(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
List all members of a search space. List all members of a search space.
Requires MEMBERS_VIEW permission. Requires MEMBERS_VIEW permission.
@ -483,7 +493,7 @@ async def list_members(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.MEMBERS_VIEW.value, Permission.MEMBERS_VIEW.value,
"You don't have permission to view members", "You don't have permission to view members",
@ -539,8 +549,9 @@ async def update_member_role(
membership_id: int, membership_id: int,
membership_update: MembershipUpdate, membership_update: MembershipUpdate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Update a member's role. Update a member's role.
Requires MEMBERS_MANAGE_ROLES permission. Requires MEMBERS_MANAGE_ROLES permission.
@ -549,7 +560,7 @@ async def update_member_role(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.MEMBERS_MANAGE_ROLES.value, Permission.MEMBERS_MANAGE_ROLES.value,
"You don't have permission to manage member roles", "You don't have permission to manage member roles",
@ -629,8 +640,9 @@ async def update_member_role(
async def leave_search_space( async def leave_search_space(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Leave a search space (remove own membership). Leave a search space (remove own membership).
Owners cannot leave their search space. Owners cannot leave their search space.
@ -675,8 +687,9 @@ async def remove_member(
search_space_id: int, search_space_id: int,
membership_id: int, membership_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Remove a member from a search space. Remove a member from a search space.
Requires MEMBERS_REMOVE permission. Requires MEMBERS_REMOVE permission.
@ -685,7 +698,7 @@ async def remove_member(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.MEMBERS_REMOVE.value, Permission.MEMBERS_REMOVE.value,
"You don't have permission to remove members", "You don't have permission to remove members",
@ -733,8 +746,9 @@ async def create_invite(
search_space_id: int, search_space_id: int,
invite_data: InviteCreate, invite_data: InviteCreate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Create a new invite link for a search space. Create a new invite link for a search space.
Requires MEMBERS_INVITE permission. Requires MEMBERS_INVITE permission.
@ -742,7 +756,7 @@ async def create_invite(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.MEMBERS_INVITE.value, Permission.MEMBERS_INVITE.value,
"You don't have permission to create invites", "You don't have permission to create invites",
@ -798,8 +812,9 @@ async def create_invite(
async def list_invites( async def list_invites(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
List all invites for a search space. List all invites for a search space.
Requires MEMBERS_INVITE permission. Requires MEMBERS_INVITE permission.
@ -807,7 +822,7 @@ async def list_invites(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.MEMBERS_INVITE.value, Permission.MEMBERS_INVITE.value,
"You don't have permission to view invites", "You don't have permission to view invites",
@ -837,8 +852,9 @@ async def update_invite(
invite_id: int, invite_id: int,
invite_update: InviteUpdate, invite_update: InviteUpdate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Update an invite. Update an invite.
Requires MEMBERS_INVITE permission. Requires MEMBERS_INVITE permission.
@ -846,7 +862,7 @@ async def update_invite(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.MEMBERS_INVITE.value, Permission.MEMBERS_INVITE.value,
"You don't have permission to update invites", "You don't have permission to update invites",
@ -903,8 +919,9 @@ async def revoke_invite(
search_space_id: int, search_space_id: int,
invite_id: int, invite_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Revoke (delete) an invite. Revoke (delete) an invite.
Requires MEMBERS_INVITE permission. Requires MEMBERS_INVITE permission.
@ -912,7 +929,7 @@ async def revoke_invite(
try: try:
await check_permission( await check_permission(
session, session,
user, auth,
search_space_id, search_space_id,
Permission.MEMBERS_INVITE.value, Permission.MEMBERS_INVITE.value,
"You don't have permission to revoke invites", "You don't have permission to revoke invites",
@ -1022,8 +1039,9 @@ async def get_invite_info(
async def accept_invite( async def accept_invite(
request: InviteAcceptRequest, request: InviteAcceptRequest,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Accept an invite and join a search space. Accept an invite and join a search space.
""" """
@ -1120,13 +1138,14 @@ async def accept_invite(
async def get_my_access( async def get_my_access(
search_space_id: int, search_space_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), auth: AuthContext = Depends(get_auth_context),
): ):
user = auth.user
""" """
Get the current user's access info for a search space. Get the current user's access info for a search space.
""" """
try: try:
membership = await check_search_space_access(session, user, search_space_id) membership = await check_search_space_access(session, auth, search_space_id)
# Get search space name # Get search space name
result = await session.execute( result = await session.execute(

View file

@ -11,12 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from app.auth.context import AuthContext
from app.db import ( from app.db import (
Permission, Permission,
SearchSpace, SearchSpace,
SearchSpaceMembership, SearchSpaceMembership,
SearchSpaceRole, SearchSpaceRole,
User,
has_permission, has_permission,
) )
@ -80,9 +80,33 @@ async def get_user_permissions(
return [] return []
async def _enforce_api_access_gate(
session: AsyncSession,
auth: AuthContext,
search_space_id: int,
search_space: SearchSpace | None = None,
) -> SearchSpace:
if search_space is None:
result = await session.execute(
select(SearchSpace).filter(SearchSpace.id == search_space_id)
)
search_space = result.scalars().first()
if not search_space:
raise HTTPException(status_code=404, detail="Search space not found")
if auth.is_gated and not search_space.api_access_enabled:
raise HTTPException(
status_code=403,
detail="API access is not enabled for this search space.",
)
return search_space
async def check_permission( async def check_permission(
session: AsyncSession, session: AsyncSession,
user: User, auth: AuthContext,
search_space_id: int, search_space_id: int,
required_permission: str, required_permission: str,
error_message: str = "You don't have permission to perform this action", error_message: str = "You don't have permission to perform this action",
@ -104,7 +128,7 @@ async def check_permission(
Raises: Raises:
HTTPException: If user doesn't have access or permission HTTPException: If user doesn't have access or permission
""" """
membership = await get_user_membership(session, user.id, search_space_id) membership = await get_user_membership(session, auth.user.id, search_space_id)
if not membership: if not membership:
raise HTTPException( raise HTTPException(
@ -123,12 +147,14 @@ async def check_permission(
if not has_permission(permissions, required_permission): if not has_permission(permissions, required_permission):
raise HTTPException(status_code=403, detail=error_message) raise HTTPException(status_code=403, detail=error_message)
await _enforce_api_access_gate(session, auth, search_space_id)
return membership return membership
async def check_search_space_access( async def check_search_space_access(
session: AsyncSession, session: AsyncSession,
user: User, auth: AuthContext,
search_space_id: int, search_space_id: int,
) -> SearchSpaceMembership: ) -> SearchSpaceMembership:
""" """
@ -146,7 +172,7 @@ async def check_search_space_access(
Raises: Raises:
HTTPException: If user doesn't have access HTTPException: If user doesn't have access
""" """
membership = await get_user_membership(session, user.id, search_space_id) membership = await get_user_membership(session, auth.user.id, search_space_id)
if not membership: if not membership:
raise HTTPException( raise HTTPException(
@ -154,6 +180,8 @@ async def check_search_space_access(
detail="You don't have access to this search space", detail="You don't have access to this search space",
) )
await _enforce_api_access_gate(session, auth, search_space_id)
return membership return membership
@ -179,7 +207,7 @@ async def is_search_space_owner(
async def get_search_space_with_access_check( async def get_search_space_with_access_check(
session: AsyncSession, session: AsyncSession,
user: User, auth: AuthContext,
search_space_id: int, search_space_id: int,
required_permission: str | None = None, required_permission: str | None = None,
) -> tuple[SearchSpace, SearchSpaceMembership]: ) -> tuple[SearchSpace, SearchSpaceMembership]:
@ -210,10 +238,12 @@ async def get_search_space_with_access_check(
# Check access # Check access
if required_permission: if required_permission:
membership = await check_permission( membership = await check_permission(
session, user, search_space_id, required_permission session, auth, search_space_id, required_permission
) )
else: else:
membership = await check_search_space_access(session, user, search_space_id) membership = await check_search_space_access(session, auth, search_space_id)
await _enforce_api_access_gate(session, auth, search_space_id, search_space)
return search_space, membership return search_space, membership