diff --git a/datajunction-server/datajunction_server/config.py b/datajunction-server/datajunction_server/config.py index 75102c3cc..6de67044f 100644 --- a/datajunction-server/datajunction_server/config.py +++ b/datajunction-server/datajunction_server/config.py @@ -213,6 +213,12 @@ class Settings(BaseSettings): # pragma: no cover # - "restrictive": Deny by default default_access_policy: str = "permissive" # or "restrictive" + # Optional role name whose scopes are evaluated as a fallback when no + # explicit grant matches. Lets a deployment express graceful defaults such + # as "everyone gets read on *" without flipping the whole policy to + # permissive. Applied before the default_access_policy fallback. + default_access_role: Optional[str] = None + # Require configured break-glass admins before serving requests. # Restrictive default access also enables this check automatically. # RBAC_ADMIN_USERS uses JSON list syntax, for example ["admin-user"]. diff --git a/datajunction-server/datajunction_server/internal/access/authorization/context.py b/datajunction-server/datajunction_server/internal/access/authorization/context.py index 468e6de15..1a17810c7 100644 --- a/datajunction-server/datajunction_server/internal/access/authorization/context.py +++ b/datajunction-server/datajunction_server/internal/access/authorization/context.py @@ -3,7 +3,7 @@ """ from fastapi import Depends -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Optional from sqlalchemy import select @@ -14,7 +14,7 @@ from datajunction_server.internal.access.group_membership import ( get_group_membership_service, ) -from datajunction_server.database.rbac import RoleAssignment, Role +from datajunction_server.database.rbac import RoleAssignment, Role, RoleScope from datajunction_server.database.user import User from datajunction_server.utils import ( @@ -47,6 +47,9 @@ class AuthContext: oauth_provider: Optional[str] role_assignments: List[RoleAssignment] # Direct + groups, flattened is_admin: bool = False + # Scopes from the configured default-access role, evaluated as a fallback + # alongside the user's own grants. + default_scopes: List[RoleScope] = field(default_factory=list) @classmethod async def from_user( @@ -71,6 +74,7 @@ async def from_user( session=session, user=user, ) + default_scopes = await cls.get_default_scopes(session=session) return cls( user_id=user.id, @@ -78,8 +82,27 @@ async def from_user( oauth_provider=user.oauth_provider, role_assignments=assignments, is_admin=bool(user.is_admin), + default_scopes=default_scopes, ) + @classmethod + async def get_default_scopes( + cls, + session: AsyncSession, + ) -> List[RoleScope]: + """ + Load the scopes of the configured default-access role, if any. + + Returns an empty list when no default role is configured or the named + role does not exist, so authorization simply falls through to the + default_access_policy. + """ + role_name = settings.default_access_role + if not role_name: + return [] + default_role = await Role.get_by_name(session, role_name) + return list(default_role.scopes) if default_role else [] + @classmethod async def get_effective_assignments( cls, diff --git a/datajunction-server/datajunction_server/internal/access/authorization/service.py b/datajunction-server/datajunction_server/internal/access/authorization/service.py index b20d48ed6..233e2aa78 100644 --- a/datajunction-server/datajunction_server/internal/access/authorization/service.py +++ b/datajunction-server/datajunction_server/internal/access/authorization/service.py @@ -6,7 +6,10 @@ from abc import ABC, abstractmethod from datetime import datetime, timezone from functools import lru_cache -from typing import List +from typing import List, TYPE_CHECKING + +if TYPE_CHECKING: + from datajunction_server.database.rbac import RoleScope from datajunction_server.models.access import ( @@ -160,7 +163,28 @@ def authorize( ) for request in requests ] - return [self._make_decision(auth_context, request) for request in requests] + candidate_scopes = self.candidate_scopes(auth_context) + return [self._make_decision(request, candidate_scopes) for request in requests] + + @classmethod + def candidate_scopes(cls, auth_context: AuthContext) -> List["RoleScope"]: + """ + Collect every scope that could grant a request for this context. + + This is the union of the principal's own (non-expired) role scopes and + the configured default-access role's scopes. Collecting all candidates + up front (rather than short-circuiting source by source) keeps the + decision a single resolve step, leaving room for future deny/precedence + rules without restructuring. + """ + scopes: List["RoleScope"] = [] + now = datetime.now(timezone.utc) + for assignment in auth_context.role_assignments: + if assignment.expires_at and assignment.expires_at < now: + continue + scopes.extend(assignment.role.scopes) + scopes.extend(auth_context.default_scopes) + return scopes def authorize_explicit_grants( self, @@ -177,21 +201,30 @@ def authorize_explicit_grants( def _make_decision( self, - auth_context: AuthContext, request: ResourceRequest, + candidate_scopes: List["RoleScope"], ) -> AccessDecision: """ Convert ResourceRequest to AccessDecision. + + Evaluates the candidate scopes (explicit grants + default-access role) + collected once per authorize() call and approves if any grants the + request. Otherwise falls back to the configured default_access_policy. """ - has_grant = self.has_permission( - assignments=auth_context.role_assignments, - action=request.verb, - resource_type=request.access_object.resource_type, - resource_name=request.access_object.name, + granted = any( + self._scope_grants_permission( + scope, + request.verb, + request.access_object.resource_type, + request.access_object.name, + ) + for scope in candidate_scopes ) + if granted: + return AccessDecision(request=request, approved=True) return AccessDecision( request=request, - approved=(has_grant or settings.default_access_policy == "permissive"), + approved=(settings.default_access_policy == "permissive"), ) def _make_explicit_grant_decision( diff --git a/datajunction-server/tests/internal/authorization_test.py b/datajunction-server/tests/internal/authorization_test.py index e3bcdd55a..d0091b3a7 100644 --- a/datajunction-server/tests/internal/authorization_test.py +++ b/datajunction-server/tests/internal/authorization_test.py @@ -318,7 +318,7 @@ def test_admin_authorizes_explicit_grants_via_bypass() -> None: )[0] assert decision.approved is True - assert decision.reason == "admin" + assert decision.reason == "admin_bypass" def _scope(action, scope_type, scope_value): @@ -905,6 +905,243 @@ async def test_auth_context_from_user_carries_is_admin( assert non_admin_context.is_admin is False +@pytest.mark.asyncio +class TestDefaultAccessRole: + """Tests for the configurable default-access role fallback.""" + + CONTEXT_SETTINGS = ( + "datajunction_server.internal.access.authorization.context.settings" + ) + SERVICE_SETTINGS = ( + "datajunction_server.internal.access.authorization.service.settings" + ) + + async def _make_role(self, session, default_user, name, action, scope_value): + role = Role(name=name, created_by_id=default_user.id) + session.add(role) + await session.flush() + session.add( + RoleScope( + role_id=role.id, + action=action, + scope_type=ResourceType.NAMESPACE, + scope_value=scope_value, + ), + ) + await session.commit() + return role + + async def test_default_role_grants_fallback_access( + self, + default_user: User, + session: AsyncSession, + mocker, + ): + """Default role scopes grant access when there is no explicit grant.""" + await self._make_role( + session, + default_user, + "global-viewer", + ResourceAction.READ, + "*", + ) + + ctx_settings = mocker.patch(self.CONTEXT_SETTINGS) + ctx_settings.default_access_role = "global-viewer" + svc_settings = mocker.patch(self.SERVICE_SETTINGS) + svc_settings.authorization_provider = "rbac" + svc_settings.default_access_policy = "restrictive" + + user = await get_user(username=default_user.username, session=session) + access_checker = AccessChecker( + auth_context=await AuthContext.from_user(user=user, session=session), + ) + access_checker.add_requests( + [ + ResourceRequest( + verb=ResourceAction.READ, + access_object=Resource( + name="finance.revenue", + resource_type=ResourceType.NAMESPACE, + ), + ), + ResourceRequest( + verb=ResourceAction.WRITE, + access_object=Resource( + name="finance.revenue", + resource_type=ResourceType.NAMESPACE, + ), + ), + ], + ) + results = await access_checker.check(on_denied=AccessDenialMode.RETURN) + assert results[0].approved is True # read granted by default role + assert results[1].approved is False # write not in default role, restrictive + + async def test_no_default_role_restrictive_denies( + self, + default_user: User, + session: AsyncSession, + mocker, + ): + """With no default role and restrictive policy, ungranted access is denied.""" + ctx_settings = mocker.patch(self.CONTEXT_SETTINGS) + ctx_settings.default_access_role = None + svc_settings = mocker.patch(self.SERVICE_SETTINGS) + svc_settings.authorization_provider = "rbac" + svc_settings.default_access_policy = "restrictive" + + user = await get_user(username=default_user.username, session=session) + access_checker = AccessChecker( + auth_context=await AuthContext.from_user(user=user, session=session), + ) + access_checker.add_request( + ResourceRequest( + verb=ResourceAction.READ, + access_object=Resource( + name="finance.revenue", + resource_type=ResourceType.NAMESPACE, + ), + ), + ) + results = await access_checker.check(on_denied=AccessDenialMode.RETURN) + assert results[0].approved is False + + async def test_default_role_unions_with_explicit_grants( + self, + default_user: User, + session: AsyncSession, + mocker, + ): + """Explicit grants and default-role scopes both apply.""" + # Default role: read on everything + await self._make_role( + session, + default_user, + "viewer", + ResourceAction.READ, + "*", + ) + # Explicit grant: write on finance.* + write_role = await self._make_role( + session, + default_user, + "finance-writer", + ResourceAction.WRITE, + "finance.*", + ) + session.add( + RoleAssignment( + principal_id=default_user.id, + role_id=write_role.id, + granted_by_id=default_user.id, + ), + ) + await session.commit() + + ctx_settings = mocker.patch(self.CONTEXT_SETTINGS) + ctx_settings.default_access_role = "viewer" + svc_settings = mocker.patch(self.SERVICE_SETTINGS) + svc_settings.authorization_provider = "rbac" + svc_settings.default_access_policy = "restrictive" + + user = await get_user(username=default_user.username, session=session) + access_checker = AccessChecker( + auth_context=await AuthContext.from_user(user=user, session=session), + ) + access_checker.add_requests( + [ + ResourceRequest( + verb=ResourceAction.WRITE, + access_object=Resource( + name="finance.revenue", + resource_type=ResourceType.NAMESPACE, + ), + ), + ResourceRequest( + verb=ResourceAction.WRITE, + access_object=Resource( + name="growth.signups", + resource_type=ResourceType.NAMESPACE, + ), + ), + ResourceRequest( + verb=ResourceAction.READ, + access_object=Resource( + name="growth.signups", + resource_type=ResourceType.NAMESPACE, + ), + ), + ], + ) + results = await access_checker.check(on_denied=AccessDenialMode.RETURN) + assert results[0].approved is True # explicit write on finance.* + assert results[1].approved is False # no write on growth.* + assert results[2].approved is True # read via default role + + async def test_missing_default_role_falls_through( + self, + default_user: User, + session: AsyncSession, + mocker, + ): + """A configured-but-nonexistent default role loads no scopes.""" + ctx_settings = mocker.patch(self.CONTEXT_SETTINGS) + ctx_settings.default_access_role = "does-not-exist" + + scopes = await AuthContext.get_default_scopes(session=session) + assert scopes == [] + + async def test_expired_assignment_excluded_from_candidate_scopes( + self, + default_user: User, + session: AsyncSession, + mocker, + ): + """An expired assignment contributes no scopes to the candidate set.""" + role = await self._make_role( + session, + default_user, + "temp-writer", + ResourceAction.WRITE, + "finance.*", + ) + session.add( + RoleAssignment( + principal_id=default_user.id, + role_id=role.id, + granted_by_id=default_user.id, + expires_at=datetime.now(timezone.utc) - timedelta(hours=1), + ), + ) + await session.commit() + + ctx_settings = mocker.patch(self.CONTEXT_SETTINGS) + ctx_settings.default_access_role = None + svc_settings = mocker.patch(self.SERVICE_SETTINGS) + svc_settings.authorization_provider = "rbac" + svc_settings.default_access_policy = "restrictive" + + user = await get_user(username=default_user.username, session=session) + auth_context = await AuthContext.from_user(user=user, session=session) + + # The expired assignment is skipped, so no scopes are collected. + assert RBACAuthorizationService.candidate_scopes(auth_context) == [] + + access_checker = AccessChecker(auth_context=auth_context) + access_checker.add_request( + ResourceRequest( + verb=ResourceAction.WRITE, + access_object=Resource( + name="finance.revenue", + resource_type=ResourceType.NAMESPACE, + ), + ), + ) + results = await access_checker.check(on_denied=AccessDenialMode.RETURN) + assert results[0].approved is False + + @pytest.mark.asyncio class TestGroupBasedPermissions: """Tests for group-based role assignments."""