From 89cb13195a54d2c7fe14e839f15e0773eea8c7b3 Mon Sep 17 00:00:00 2001 From: Philip Weiss Date: Thu, 27 Aug 2026 20:27:56 -0700 Subject: [PATCH 1/2] Enforce mutation grants for governed namespaces Derive restrictive WRITE, DELETE, and MANAGE rules from persisted namespace boundaries so provisioning activates exclusive RBAC without a deployment allowlist. --- .../datajunction_server/api/namespaces.py | 6 +- .../internal/access/authorization/context.py | 23 ++ .../internal/access/authorization/service.py | 35 +- .../internal/namespaces.py | 16 +- .../datajunction_server/models/access.py | 11 + .../tests/api/namespaces_test.py | 111 ++++++ .../tests/internal/authorization_test.py | 321 ++++++++++++++++++ 7 files changed, 505 insertions(+), 18 deletions(-) diff --git a/datajunction-server/datajunction_server/api/namespaces.py b/datajunction-server/datajunction_server/api/namespaces.py index 1eeee1eb7..48b526a6a 100644 --- a/datajunction-server/datajunction_server/api/namespaces.py +++ b/datajunction-server/datajunction_server/api/namespaces.py @@ -46,7 +46,6 @@ hard_delete_namespace, mark_namespace_deactivated, mark_namespace_restored, - namespace_boundary_scope_targets, namespaces_to_authorize, provision_namespace_boundary, resolve_git_config, @@ -55,7 +54,10 @@ ) from datajunction_server.internal.nodes import activate_node, deactivate_node from datajunction_server.models import access -from datajunction_server.models.access import ResourceAction +from datajunction_server.models.access import ( + ResourceAction, + namespace_boundary_scope_targets, +) from datajunction_server.models.deployment import ( BulkNamespaceSourcesRequest, BulkNamespaceSourcesResponse, diff --git a/datajunction-server/datajunction_server/internal/access/authorization/context.py b/datajunction-server/datajunction_server/internal/access/authorization/context.py index 46531ea5f..bb184fe1d 100644 --- a/datajunction-server/datajunction_server/internal/access/authorization/context.py +++ b/datajunction-server/datajunction_server/internal/access/authorization/context.py @@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from datajunction_server.database.namespace import NodeNamespace from datajunction_server.database.rbac import Role, RoleAssignment, RoleScope from datajunction_server.database.user import User from datajunction_server.internal.access.group_membership import ( @@ -47,6 +48,7 @@ class AuthContext: # 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) + governed_boundaries: tuple[str, ...] = () @classmethod async def from_user( @@ -72,6 +74,7 @@ async def from_user( user=user, ) default_scopes = await cls.get_default_scopes(session=session) + governed_boundaries = await cls.get_governed_boundaries(session=session) return cls( user_id=user.id, @@ -80,8 +83,28 @@ async def from_user( role_assignments=assignments, is_admin=bool(user.is_admin), default_scopes=default_scopes, + governed_boundaries=governed_boundaries, ) + @classmethod + async def get_governed_boundaries( + cls, + session: AsyncSession, + ) -> tuple[str, ...]: + """ + Load every retained governed namespace boundary. + + Deactivated boundaries stay enforced because restoration preserves their + roles and assignments. Hard deletion removes the boundary row entirely. + """ + statement = ( + select(NodeNamespace.namespace) + .where(NodeNamespace.is_governed_boundary.is_(True)) + .order_by(NodeNamespace.namespace) + ) + result = await session.execute(statement) + return tuple(result.scalars().all()) + @classmethod async def get_default_scopes( cls, diff --git a/datajunction-server/datajunction_server/internal/access/authorization/service.py b/datajunction-server/datajunction_server/internal/access/authorization/service.py index dcfce41d2..871b588d7 100644 --- a/datajunction-server/datajunction_server/internal/access/authorization/service.py +++ b/datajunction-server/datajunction_server/internal/access/authorization/service.py @@ -4,6 +4,7 @@ import logging from abc import ABC, abstractmethod +from collections.abc import Sequence from datetime import UTC, datetime from functools import cache from typing import TYPE_CHECKING, ClassVar @@ -21,6 +22,7 @@ ResourceRequest, ResourceType, RestrictiveScopeRule, + namespace_boundary_scope_targets, parse_restrictive_scope_rule, ) from datajunction_server.naming import parse_scope_pattern @@ -35,6 +37,27 @@ settings = get_settings() +def governed_boundary_rules( + boundaries: Sequence[str], +) -> tuple[RestrictiveScopeRule, ...]: + """Build mutation rules for persisted governed namespace boundaries.""" + actions = ( + ResourceAction.WRITE, + ResourceAction.DELETE, + ResourceAction.MANAGE, + ) + return tuple( + RestrictiveScopeRule( + action=action, + scope_type=scope_type, + scope_value=scope_value, + ) + for namespace in boundaries + for action in actions + for scope_type, scope_value in namespace_boundary_scope_targets(namespace) + ) + + class AuthorizationService(ABC): """ Abstract base class for authorization strategies. @@ -166,7 +189,7 @@ def authorize( for request in requests ] explicit_scopes = self.explicit_scopes(auth_context) - restrictive_rules = self.restrictive_rules() + restrictive_rules = self.restrictive_rules(auth_context.governed_boundaries) return [ self._make_decision( request, @@ -484,12 +507,16 @@ def _resource_in_scope( return False @classmethod - def restrictive_rules(cls) -> list[RestrictiveScopeRule]: - """Parse configured restrictive policy rules.""" - return [ + def restrictive_rules( + cls, + governed_boundaries: Sequence[str] = (), + ) -> list[RestrictiveScopeRule]: + """Combine configured policy with database-backed boundary rules.""" + configured_rules = [ parse_restrictive_scope_rule(value) for value in getattr(settings, "restrictive_scopes", []) or [] ] + return [*configured_rules, *governed_boundary_rules(governed_boundaries)] @classmethod def _matching_restrictive_rule( diff --git a/datajunction-server/datajunction_server/internal/namespaces.py b/datajunction-server/datajunction_server/internal/namespaces.py index d3eb5f10b..e4fd2c593 100644 --- a/datajunction-server/datajunction_server/internal/namespaces.py +++ b/datajunction-server/datajunction_server/internal/namespaces.py @@ -42,7 +42,10 @@ lock_namespace_boundary_lifecycle, ) from datajunction_server.internal.nodes import get_single_cube_revision_metadata -from datajunction_server.models.access import ResourceAction, ResourceType +from datajunction_server.models.access import ( + ResourceAction, + namespace_boundary_scope_targets, +) from datajunction_server.models.deployment import ( CubeSpec, DeploymentSourceType, @@ -435,17 +438,6 @@ async def create_namespace( return parents -def namespace_boundary_scope_targets( - namespace: str, -) -> list[tuple[ResourceType, str]]: - """Return every scope governed by a namespace boundary.""" - return [ - (ResourceType.NAMESPACE, namespace), - (ResourceType.NAMESPACE, f"{namespace}.*"), - (ResourceType.NODE, f"{namespace}.*"), - ] - - def _namespace_boundary_scopes( namespace: str, action: ResourceAction, diff --git a/datajunction-server/datajunction_server/models/access.py b/datajunction-server/datajunction_server/models/access.py index cde1ce3a6..4a836f328 100644 --- a/datajunction-server/datajunction_server/models/access.py +++ b/datajunction-server/datajunction_server/models/access.py @@ -30,6 +30,17 @@ class ResourceAction(StrEnum): MANAGE = "manage" # Grant/revoke permissions (RBAC-specific) +def namespace_boundary_scope_targets( + namespace: str, +) -> tuple[tuple[ResourceType, str], ...]: + """Return every scope governed by a namespace boundary.""" + return ( + (ResourceType.NAMESPACE, namespace), + (ResourceType.NAMESPACE, f"{namespace}.*"), + (ResourceType.NODE, f"{namespace}.*"), + ) + + @dataclass(frozen=True) class RestrictiveScopeRule: """A configured action and resource scope that requires an explicit grant.""" diff --git a/datajunction-server/tests/api/namespaces_test.py b/datajunction-server/tests/api/namespaces_test.py index 36e8d8dde..fa69cf5bb 100644 --- a/datajunction-server/tests/api/namespaces_test.py +++ b/datajunction-server/tests/api/namespaces_test.py @@ -3,6 +3,7 @@ """ import asyncio +from datetime import timedelta from http import HTTPStatus from unittest import mock @@ -13,8 +14,10 @@ from datajunction_server.api.namespaces import provision_node_namespace from datajunction_server.database.namespace import NodeNamespace from datajunction_server.database.user import OAuthProvider, PrincipalKind, User +from datajunction_server.internal.access.authentication.tokens import create_token from datajunction_server.internal.access.authorization import ( AuthorizationService, + RBACAuthorizationService, ) from datajunction_server.internal.namespaces import ( _merge_columns_preserving_comments, @@ -96,6 +99,114 @@ async def test_provision_namespace_boundary( access_checker.check.assert_awaited_once() +async def test_provisioned_boundary_enforces_rbac_without_restrictive_config( + client: AsyncClient, + session: AsyncSession, + current_user: User, + settings_no_qs, + mocker, +): + service_settings = mocker.patch( + "datajunction_server.internal.access.authorization.service.settings", + ) + service_settings.default_access_policy = "permissive" + service_settings.restrictive_scopes = [] + context_settings = mocker.patch( + "datajunction_server.internal.access.authorization.context.settings", + ) + context_settings.default_access_role = None + mocker.patch(VALIDATOR_AUTH_SERVICE, lambda: RBACAuthorizationService()) + + current_user.is_admin = True + await session.commit() + + owner_username = "api-boundary-owner" + outsider_username = "api-boundary-outsider" + owner_group = "api-boundary-owners" + for username in (owner_username, outsider_username): + response = await client.post( + "/basic/user/", + data={ + "email": f"{username}@example.com", + "username": username, + "password": "test-password", + }, + ) + assert response.status_code == HTTPStatus.CREATED + + response = await client.post("/groups/", params={"username": owner_group}) + assert response.status_code == HTTPStatus.CREATED + response = await client.post( + f"/groups/{owner_group}/members/", + params={"member_username": owner_username}, + ) + assert response.status_code == HTTPStatus.CREATED + + response = await client.post( + "/service-accounts", + json={"name": "API boundary deployer"}, + ) + assert response.status_code == HTTPStatus.OK + deployer = response.json() + response = await client.post( + "/service-accounts/token", + data={ + "client_id": deployer["client_id"], + "client_secret": deployer["client_secret"], + }, + ) + assert response.status_code == HTTPStatus.OK + deployer_token = response.json()["token"] + + response = await client.post( + "/namespaces/example.metrics/provision", + json={ + "owner_group": owner_group, + "deployer_service_accounts": [deployer["client_id"]], + }, + ) + assert response.status_code == HTTPStatus.CREATED + boundary = await session.get(NodeNamespace, "example.metrics") + assert boundary is not None + assert boundary.is_governed_boundary is True + + def user_token(username: str) -> str: + return create_token( + {"username": username}, + secret=settings_no_qs.secret, + iss=settings_no_qs.url, + expires_delta=timedelta(hours=1), + ) + + client.headers["Authorization"] = f"Bearer {user_token(owner_username)}" + response = await client.post( + "/roles/", + json={ + "name": "api-boundary-writer", + "scopes": [ + { + "action": "write", + "scope_type": "node", + "scope_value": "example.metrics.*", + }, + ], + }, + ) + assert response.status_code == HTTPStatus.CREATED + + client.headers["Authorization"] = f"Bearer {deployer_token}" + response = await client.post("/namespaces/example.metrics.deployed/") + assert response.status_code == HTTPStatus.CREATED + + client.headers["Authorization"] = f"Bearer {user_token(outsider_username)}" + response = await client.post("/namespaces/example.metrics.denied/") + assert response.status_code == HTTPStatus.FORBIDDEN + response = await client.get("/namespaces/example.metrics/") + assert response.status_code == HTTPStatus.OK + response = await client.post("/namespaces/example.open/") + assert response.status_code == HTTPStatus.CREATED + + @pytest.mark.asyncio async def test_list_all_namespaces( module__client_with_all_examples: AsyncClient, diff --git a/datajunction-server/tests/internal/authorization_test.py b/datajunction-server/tests/internal/authorization_test.py index 79f0b1d3c..1a254fa68 100644 --- a/datajunction-server/tests/internal/authorization_test.py +++ b/datajunction-server/tests/internal/authorization_test.py @@ -11,6 +11,7 @@ from datajunction_server.config import Settings from datajunction_server.database.group_member import GroupMember +from datajunction_server.database.namespace import NodeNamespace from datajunction_server.database.rbac import Role, RoleAssignment, RoleScope from datajunction_server.database.user import PrincipalKind, User from datajunction_server.errors import DJAuthorizationException @@ -24,6 +25,9 @@ RBACAuthorizationService, get_authorization_service, ) +from datajunction_server.internal.access.authorization.service import ( + governed_boundary_rules, +) from datajunction_server.internal.access.group_membership import ( GroupMembershipService, ) @@ -1457,6 +1461,289 @@ def test_settings_parse_json_list_from_environment(self, monkeypatch): ] +class TestGovernedBoundaryScopes: + """Tests for restrictive mutation policy derived from namespace rows.""" + + SERVICE_SETTINGS = ( + "datajunction_server.internal.access.authorization.service.settings" + ) + BOUNDARY = "team.metrics" + + @staticmethod + def request( + action: ResourceAction, + resource_type: ResourceType, + name: str, + ) -> ResourceRequest: + return ResourceRequest( + verb=action, + access_object=Resource(name=name, resource_type=resource_type), + ) + + @classmethod + def context( + cls, + *, + boundaries: tuple[str, ...] | None = None, + explicit_scopes=None, + default_scopes=None, + is_admin: bool = False, + ) -> AuthContext: + assignments = [_assignment(explicit_scopes)] if explicit_scopes else [] + return AuthContext( + user_id=1, + username="boundary-user", + oauth_provider="basic", + role_assignments=assignments, + is_admin=is_admin, + default_scopes=default_scopes or [], + governed_boundaries=boundaries + if boundaries is not None + else (cls.BOUNDARY,), + ) + + def configure(self, mocker, rules=()): + service_settings = mocker.patch(self.SERVICE_SETTINGS) + service_settings.restrictive_scopes = rules + service_settings.default_access_policy = "permissive" + + def test_rules_match_boundary_scope_contract(self): + assert {str(rule) for rule in governed_boundary_rules([self.BOUNDARY])} == { + "write:namespace:team.metrics", + "write:namespace:team.metrics.*", + "write:node:team.metrics.*", + "delete:namespace:team.metrics", + "delete:namespace:team.metrics.*", + "delete:node:team.metrics.*", + "manage:namespace:team.metrics", + "manage:namespace:team.metrics.*", + "manage:node:team.metrics.*", + } + + @pytest.mark.parametrize( + "action", + [ResourceAction.WRITE, ResourceAction.DELETE, ResourceAction.MANAGE], + ) + @pytest.mark.parametrize( + "resource_type,name", + [ + (ResourceType.NAMESPACE, "team.metrics"), + (ResourceType.NAMESPACE, "team.metrics.daily"), + (ResourceType.NODE, "team.metrics.daily_revenue"), + ], + ) + def test_mutations_require_explicit_grants( + self, + mocker, + action, + resource_type, + name, + ): + self.configure(mocker) + + decision = RBACAuthorizationService().authorize( + self.context(), + [self.request(action, resource_type, name)], + )[0] + + assert decision.approved is False + assert decision.reason and decision.reason.startswith("restrictive_scope:") + + @pytest.mark.parametrize("action", [ResourceAction.READ, ResourceAction.EXECUTE]) + def test_read_and_execute_keep_permissive_policy(self, mocker, action): + self.configure(mocker) + + decision = RBACAuthorizationService().authorize( + self.context(), + [self.request(action, ResourceType.NODE, "team.metrics.revenue")], + )[0] + + assert decision.approved is True + assert decision.reason == "default_access_policy_permissive" + + @pytest.mark.parametrize( + "action", + [ResourceAction.WRITE, ResourceAction.DELETE, ResourceAction.MANAGE], + ) + def test_ungoverned_sibling_keeps_permissive_policy(self, mocker, action): + self.configure(mocker) + + decision = RBACAuthorizationService().authorize( + self.context(), + [self.request(action, ResourceType.NODE, "team.other.revenue")], + )[0] + + assert decision.approved is True + assert decision.reason == "default_access_policy_permissive" + + def test_direct_grant_allows_governed_mutation(self, mocker): + self.configure(mocker) + explicit_scope = _scope( + ResourceAction.WRITE, + ResourceType.NODE, + "team.metrics.*", + ) + + decision = RBACAuthorizationService().authorize( + self.context(explicit_scopes=[explicit_scope]), + [ + self.request( + ResourceAction.WRITE, + ResourceType.NODE, + "team.metrics.revenue", + ), + ], + )[0] + + assert decision.approved is True + assert decision.reason == "explicit_grant" + + def test_default_role_cannot_bypass_governed_boundary(self, mocker): + self.configure(mocker) + default_scope = _scope(ResourceAction.WRITE, ResourceType.NODE, "*") + + decision = RBACAuthorizationService().authorize( + self.context(default_scopes=[default_scope]), + [ + self.request( + ResourceAction.WRITE, + ResourceType.NODE, + "team.metrics.revenue", + ), + ], + )[0] + + assert decision.approved is False + assert decision.reason and decision.reason.startswith("restrictive_scope:") + + def test_admin_keeps_audited_boundary_bypass(self, mocker, caplog): + self.configure(mocker) + + with caplog.at_level(logging.WARNING, logger="datajunction.audit.rbac"): + decision = RBACAuthorizationService().authorize( + self.context(is_admin=True), + [ + self.request( + ResourceAction.MANAGE, + ResourceType.NAMESPACE, + self.BOUNDARY, + ), + ], + )[0] + + assert decision.approved is True + assert decision.reason == "admin_bypass" + assert "event=rbac_admin_bypass" in caplog.messages[0] + + def test_configured_and_governed_rules_combine(self, mocker): + self.configure(mocker, rules=["delete:node:legacy.secure.*"]) + requests = [ + self.request( + ResourceAction.WRITE, + ResourceType.NODE, + "team.metrics.revenue", + ), + self.request( + ResourceAction.DELETE, + ResourceType.NODE, + "legacy.secure.revenue", + ), + self.request(ResourceAction.WRITE, ResourceType.NODE, "open.revenue"), + ] + + decisions = RBACAuthorizationService().authorize(self.context(), requests) + + assert [decision.approved for decision in decisions] == [False, False, True] + assert decisions[1].reason == "restrictive_scope:delete:node:legacy.secure.*" + + def test_multiple_boundaries_are_independent(self, mocker): + self.configure(mocker) + context = self.context(boundaries=("finance.cubes", "team.metrics")) + requests = [ + self.request( + ResourceAction.WRITE, + ResourceType.NODE, + "team.metrics.revenue", + ), + self.request( + ResourceAction.WRITE, + ResourceType.NODE, + "finance.cubes.revenue", + ), + self.request( + ResourceAction.WRITE, + ResourceType.NODE, + "finance.other.revenue", + ), + ] + + decisions = RBACAuthorizationService().authorize(context, requests) + + assert [decision.approved for decision in decisions] == [False, False, True] + + +@pytest.mark.asyncio +async def test_governed_boundary_allows_group_grant( + session: AsyncSession, + default_user: User, + mocker, +): + boundary = NodeNamespace( + namespace="group_governed", + is_governed_boundary=True, + ) + group = User( + username="governed-writers", + kind=PrincipalKind.GROUP, + oauth_provider="basic", + ) + role = Role(name="governed-group-role", created_by_id=default_user.id) + session.add_all([boundary, group, role]) + await session.flush() + session.add_all( + [ + GroupMember(group_id=group.id, member_id=default_user.id), + RoleScope( + role_id=role.id, + action=ResourceAction.WRITE, + scope_type=ResourceType.NODE, + scope_value="group_governed.*", + ), + RoleAssignment( + principal_id=group.id, + role_id=role.id, + granted_by_id=default_user.id, + ), + ], + ) + await session.commit() + + context_settings = mocker.patch( + "datajunction_server.internal.access.authorization.context.settings", + ) + context_settings.default_access_role = None + service_settings = mocker.patch( + "datajunction_server.internal.access.authorization.service.settings", + ) + service_settings.restrictive_scopes = [] + service_settings.default_access_policy = "permissive" + + user = await get_user(username=default_user.username, session=session) + context = await AuthContext.from_user(session, user) + request = ResourceRequest( + verb=ResourceAction.WRITE, + access_object=Resource( + name="group_governed.revenue", + resource_type=ResourceType.NODE, + ), + ) + + decision = RBACAuthorizationService().authorize(context, [request])[0] + + assert decision.approved is True + assert decision.reason == "explicit_grant" + + @pytest.mark.asyncio class TestGroupBasedPermissions: """Tests for group-based role assignments.""" @@ -2141,6 +2428,40 @@ async def test_execute_implies_read( class TestAuthContext: """Tests for AuthContext and effective assignments.""" + async def test_from_user_loads_only_retained_governed_boundaries( + self, + default_user: User, + session: AsyncSession, + ): + session.add_all( + [ + NodeNamespace( + namespace="context_governed", + is_governed_boundary=True, + ), + NodeNamespace(namespace="context_governed.child"), + NodeNamespace( + namespace="context_missing.parent.boundary", + is_governed_boundary=True, + ), + NodeNamespace( + namespace="context_deactivated", + is_governed_boundary=True, + deactivated_at=datetime.now(UTC), + ), + NodeNamespace(namespace="context_ungoverned"), + ], + ) + await session.commit() + + context = await AuthContext.from_user(session, default_user) + + assert context.governed_boundaries == ( + "context_deactivated", + "context_governed", + "context_missing.parent.boundary", + ) + async def test_auth_context_from_user_direct_assignments_only( self, default_user: User, From 010832bcf299744c6cf59551c1acc7b9da171429 Mon Sep 17 00:00:00 2001 From: Philip Weiss Date: Thu, 27 Aug 2026 20:33:55 -0700 Subject: [PATCH 2/2] Protect governed boundary lifecycle from deployers Treat hard deletion of a governed root as a MANAGE operation so a deployer cannot remove the persisted policy marker and reopen the namespace to fallback access. --- .../datajunction_server/api/namespaces.py | 21 +++++--- .../tests/api/namespaces_test.py | 50 ++++++++++++++++++- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/datajunction-server/datajunction_server/api/namespaces.py b/datajunction-server/datajunction_server/api/namespaces.py index 48b526a6a..1e3c70fa4 100644 --- a/datajunction-server/datajunction_server/api/namespaces.py +++ b/datajunction-server/datajunction_server/api/namespaces.py @@ -492,19 +492,26 @@ async def hard_delete_node_namespace( is set to true. If cascade is set to false, we'll raise an error. This should be used with caution, as the impact may be large. """ - access_checker.add_namespace(namespace, ResourceAction.DELETE) - await access_checker.check(on_denied=AccessDenialMode.RAISE) - - # Only apply the default-branch guard when the namespace exists. Git config - # is inherited from ancestors, so a missing namespace under a git-backed root - # still resolves is_default_branch=True (no branch -> treated as default) and - # would wrongly 422 instead of falling through to the 404 path below. namespace_exists = await NodeNamespace.get( session, namespace, raise_if_not_exists=False, ) + # Hard-deleting a boundary removes its enforcement policy. Treat that + # lifecycle change as MANAGE while descendants remain DELETE operations. + action = ( + ResourceAction.MANAGE + if namespace_exists and namespace_exists.is_governed_boundary + else ResourceAction.DELETE + ) + access_checker.add_namespace(namespace, action) + await access_checker.check(on_denied=AccessDenialMode.RAISE) + + # Only apply the default-branch guard when the namespace exists. Git config + # is inherited from ancestors, so a missing namespace under a git-backed root + # still resolves is_default_branch=True (no branch -> treated as default) and + # would wrongly 422 instead of falling through to the 404 path below. git_info = await get_git_info_for_namespace(session, namespace) if ( namespace_exists diff --git a/datajunction-server/tests/api/namespaces_test.py b/datajunction-server/tests/api/namespaces_test.py index fa69cf5bb..ed8efc8ab 100644 --- a/datajunction-server/tests/api/namespaces_test.py +++ b/datajunction-server/tests/api/namespaces_test.py @@ -11,7 +11,10 @@ from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from datajunction_server.api.namespaces import provision_node_namespace +from datajunction_server.api.namespaces import ( + hard_delete_node_namespace, + provision_node_namespace, +) from datajunction_server.database.namespace import NodeNamespace from datajunction_server.database.user import OAuthProvider, PrincipalKind, User from datajunction_server.internal.access.authentication.tokens import create_token @@ -99,6 +102,49 @@ async def test_provision_namespace_boundary( access_checker.check.assert_awaited_once() +@pytest.mark.parametrize( + ("is_governed_boundary", "expected_action"), + [ + (True, ResourceAction.MANAGE), + (False, ResourceAction.DELETE), + ], +) +async def test_hard_delete_boundary_uses_policy_lifecycle_action( + is_governed_boundary, + expected_action, + mocker, +): + mocker.patch.object( + NodeNamespace, + "get", + new=mocker.AsyncMock( + return_value=mocker.Mock( + is_governed_boundary=is_governed_boundary, + ), + ), + ) + access_checker = mocker.MagicMock() + access_checker.check = mocker.AsyncMock( + side_effect=RuntimeError("stop after authorization"), + ) + + with pytest.raises(RuntimeError, match="stop after authorization"): + await hard_delete_node_namespace( + "policy_boundary", + session=mocker.MagicMock(), + current_user=mocker.MagicMock(), + save_history=mocker.AsyncMock(), + access_checker=access_checker, + query_service_client=mocker.MagicMock(), + request=mocker.MagicMock(), + ) + + access_checker.add_namespace.assert_called_once_with( + "policy_boundary", + expected_action, + ) + + async def test_provisioned_boundary_enforces_rbac_without_restrictive_config( client: AsyncClient, session: AsyncSession, @@ -195,6 +241,8 @@ def user_token(username: str) -> str: assert response.status_code == HTTPStatus.CREATED client.headers["Authorization"] = f"Bearer {deployer_token}" + response = await client.delete("/namespaces/example.metrics/hard/") + assert response.status_code == HTTPStatus.FORBIDDEN response = await client.post("/namespaces/example.metrics.deployed/") assert response.status_code == HTTPStatus.CREATED