diff --git a/surfsense_backend/app/gateway/auth_invariant.py b/surfsense_backend/app/gateway/auth_invariant.py index e72023ce1..008250957 100644 --- a/surfsense_backend/app/gateway/auth_invariant.py +++ b/surfsense_backend/app/gateway/auth_invariant.py @@ -5,6 +5,7 @@ from __future__ import annotations from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession +from app.auth.context import AuthContext from app.db import ExternalChatBinding, Permission, User from app.gateway.bindings import suspend_binding from app.observability.metrics import record_gateway_auth_invariant_failure @@ -39,11 +40,13 @@ async def assert_authorization_invariant( if user is None: await _fail(session, binding, "owner_missing") + auth = AuthContext.system(user, source="gateway") + 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( session, - user, + auth, binding.search_space_id, Permission.CHATS_CREATE.value, "External chat owner no longer has permission to chat in this search space", diff --git a/surfsense_backend/app/routes/agent_permissions_route.py b/surfsense_backend/app/routes/agent_permissions_route.py index 0c07eeb9c..521adfb03 100644 --- a/surfsense_backend/app/routes/agent_permissions_route.py +++ b/surfsense_backend/app/routes/agent_permissions_route.py @@ -30,6 +30,7 @@ from sqlalchemy import select from sqlalchemy.exc import IntegrityError 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.db import ( AgentPermissionRule, @@ -39,7 +40,7 @@ from app.db import ( User, get_async_session, ) -from app.users import current_active_user +from app.users import get_auth_context from app.utils.rbac import check_permission logger = logging.getLogger(__name__) @@ -133,15 +134,16 @@ def _to_read(row: AgentPermissionRule) -> AgentPermissionRuleRead: async def _ensure_search_space_membership_admin( - session: AsyncSession, user: User, search_space_id: int + session: AsyncSession, auth: AuthContext, search_space_id: int ) -> None: + user = auth.user """Curating agent rules == "settings" administration on the space.""" space = await session.get(SearchSpace, search_space_id) if space is None: raise HTTPException(status_code=404, detail="Search space not found.") await check_permission( session, - user, + auth, search_space_id, Permission.SETTINGS_UPDATE.value, "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( search_space_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + auth: AuthContext = Depends(get_auth_context), ) -> list[AgentPermissionRuleRead]: + user = auth.user _flag_guard() await _ensure_search_space_membership_admin(session, user, search_space_id) @@ -183,8 +186,9 @@ async def create_rule( search_space_id: int, payload: AgentPermissionRuleCreate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + auth: AuthContext = Depends(get_auth_context), ) -> AgentPermissionRuleRead: + user = auth.user _flag_guard() await _ensure_search_space_membership_admin(session, user, search_space_id) @@ -232,8 +236,9 @@ async def update_rule( rule_id: int, payload: AgentPermissionRuleUpdate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + auth: AuthContext = Depends(get_auth_context), ) -> AgentPermissionRuleRead: + user = auth.user _flag_guard() await _ensure_search_space_membership_admin(session, user, search_space_id) @@ -266,8 +271,9 @@ async def delete_rule( search_space_id: int, rule_id: int, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + auth: AuthContext = Depends(get_auth_context), ) -> None: + user = auth.user _flag_guard() await _ensure_search_space_membership_admin(session, user, search_space_id) diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 3b91e456d..3d50d589d 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -18,6 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload +from app.auth.context import AuthContext from app.db import ( Permission, SearchSpace, @@ -43,7 +44,7 @@ from app.schemas import ( RoleUpdate, UserSearchSpaceAccess, ) -from app.users import current_active_user +from app.users import get_auth_context from app.utils.rbac import ( check_permission, check_search_space_access, @@ -107,6 +108,8 @@ PERMISSION_DESCRIPTIONS = { "settings:view": "View search space settings", "settings:update": "Modify search space settings", "settings:delete": "Delete the entire search space", + # API access + "api_access:manage": "Enable or disable programmatic API access for a search space", # Automations "automations:create": "Create automations from chat or JSON", "automations:read": "View automations, their triggers, and run history", @@ -120,8 +123,9 @@ PERMISSION_DESCRIPTIONS = { @router.get("/permissions", response_model=PermissionsListResponse) 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. """ @@ -156,8 +160,9 @@ async def create_role( search_space_id: int, role_data: RoleCreate, 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. Requires ROLES_CREATE permission. @@ -165,7 +170,7 @@ async def create_role( try: await check_permission( session, - user, + auth, search_space_id, Permission.ROLES_CREATE.value, "You don't have permission to create roles", @@ -237,8 +242,9 @@ async def create_role( async def list_roles( search_space_id: int, 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. Requires ROLES_READ permission. @@ -246,7 +252,7 @@ async def list_roles( try: await check_permission( session, - user, + auth, search_space_id, Permission.ROLES_READ.value, "You don't have permission to view roles", @@ -275,8 +281,9 @@ async def get_role( search_space_id: int, role_id: int, 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. Requires ROLES_READ permission. @@ -284,7 +291,7 @@ async def get_role( try: await check_permission( session, - user, + auth, search_space_id, Permission.ROLES_READ.value, "You don't have permission to view roles", @@ -320,8 +327,9 @@ async def update_role( role_id: int, role_update: RoleUpdate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + auth: AuthContext = Depends(get_auth_context), ): + user = auth.user """ Update a role. Requires ROLES_UPDATE permission. @@ -330,7 +338,7 @@ async def update_role( try: await check_permission( session, - user, + auth, search_space_id, Permission.ROLES_UPDATE.value, "You don't have permission to update roles", @@ -417,8 +425,9 @@ async def delete_role( search_space_id: int, role_id: int, 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. Requires ROLES_DELETE permission. @@ -427,7 +436,7 @@ async def delete_role( try: await check_permission( session, - user, + auth, search_space_id, Permission.ROLES_DELETE.value, "You don't have permission to delete roles", @@ -474,8 +483,9 @@ async def delete_role( async def list_members( search_space_id: int, 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. Requires MEMBERS_VIEW permission. @@ -483,7 +493,7 @@ async def list_members( try: await check_permission( session, - user, + auth, search_space_id, Permission.MEMBERS_VIEW.value, "You don't have permission to view members", @@ -539,8 +549,9 @@ async def update_member_role( membership_id: int, membership_update: MembershipUpdate, 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. Requires MEMBERS_MANAGE_ROLES permission. @@ -549,7 +560,7 @@ async def update_member_role( try: await check_permission( session, - user, + auth, search_space_id, Permission.MEMBERS_MANAGE_ROLES.value, "You don't have permission to manage member roles", @@ -629,8 +640,9 @@ async def update_member_role( async def leave_search_space( search_space_id: int, 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). Owners cannot leave their search space. @@ -675,8 +687,9 @@ async def remove_member( search_space_id: int, membership_id: int, 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. Requires MEMBERS_REMOVE permission. @@ -685,7 +698,7 @@ async def remove_member( try: await check_permission( session, - user, + auth, search_space_id, Permission.MEMBERS_REMOVE.value, "You don't have permission to remove members", @@ -733,8 +746,9 @@ async def create_invite( search_space_id: int, invite_data: InviteCreate, 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. Requires MEMBERS_INVITE permission. @@ -742,7 +756,7 @@ async def create_invite( try: await check_permission( session, - user, + auth, search_space_id, Permission.MEMBERS_INVITE.value, "You don't have permission to create invites", @@ -798,8 +812,9 @@ async def create_invite( async def list_invites( search_space_id: int, 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. Requires MEMBERS_INVITE permission. @@ -807,7 +822,7 @@ async def list_invites( try: await check_permission( session, - user, + auth, search_space_id, Permission.MEMBERS_INVITE.value, "You don't have permission to view invites", @@ -837,8 +852,9 @@ async def update_invite( invite_id: int, invite_update: InviteUpdate, session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), + auth: AuthContext = Depends(get_auth_context), ): + user = auth.user """ Update an invite. Requires MEMBERS_INVITE permission. @@ -846,7 +862,7 @@ async def update_invite( try: await check_permission( session, - user, + auth, search_space_id, Permission.MEMBERS_INVITE.value, "You don't have permission to update invites", @@ -903,8 +919,9 @@ async def revoke_invite( search_space_id: int, invite_id: int, 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. Requires MEMBERS_INVITE permission. @@ -912,7 +929,7 @@ async def revoke_invite( try: await check_permission( session, - user, + auth, search_space_id, Permission.MEMBERS_INVITE.value, "You don't have permission to revoke invites", @@ -1022,8 +1039,9 @@ async def get_invite_info( async def accept_invite( request: InviteAcceptRequest, 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. """ @@ -1120,13 +1138,14 @@ async def accept_invite( async def get_my_access( search_space_id: int, 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. """ 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 result = await session.execute( diff --git a/surfsense_backend/app/utils/rbac.py b/surfsense_backend/app/utils/rbac.py index 6cb180d80..8777f09f6 100644 --- a/surfsense_backend/app/utils/rbac.py +++ b/surfsense_backend/app/utils/rbac.py @@ -11,12 +11,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload +from app.auth.context import AuthContext from app.db import ( Permission, SearchSpace, SearchSpaceMembership, SearchSpaceRole, - User, has_permission, ) @@ -80,9 +80,33 @@ async def get_user_permissions( 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( session: AsyncSession, - user: User, + auth: AuthContext, search_space_id: int, required_permission: str, error_message: str = "You don't have permission to perform this action", @@ -104,7 +128,7 @@ async def check_permission( Raises: 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: raise HTTPException( @@ -123,12 +147,14 @@ async def check_permission( if not has_permission(permissions, required_permission): raise HTTPException(status_code=403, detail=error_message) + await _enforce_api_access_gate(session, auth, search_space_id) + return membership async def check_search_space_access( session: AsyncSession, - user: User, + auth: AuthContext, search_space_id: int, ) -> SearchSpaceMembership: """ @@ -146,7 +172,7 @@ async def check_search_space_access( Raises: 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: raise HTTPException( @@ -154,6 +180,8 @@ async def check_search_space_access( detail="You don't have access to this search space", ) + await _enforce_api_access_gate(session, auth, search_space_id) + return membership @@ -179,7 +207,7 @@ async def is_search_space_owner( async def get_search_space_with_access_check( session: AsyncSession, - user: User, + auth: AuthContext, search_space_id: int, required_permission: str | None = None, ) -> tuple[SearchSpace, SearchSpaceMembership]: @@ -210,10 +238,12 @@ async def get_search_space_with_access_check( # Check access if required_permission: membership = await check_permission( - session, user, search_space_id, required_permission + session, auth, search_space_id, required_permission ) 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