Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions datajunction-server/datajunction_server/api/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -490,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,6 +22,7 @@
ResourceRequest,
ResourceType,
RestrictiveScopeRule,
namespace_boundary_scope_targets,
parse_restrictive_scope_rule,
)
from datajunction_server.naming import parse_scope_pattern
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
16 changes: 4 additions & 12 deletions datajunction-server/datajunction_server/internal/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions datajunction-server/datajunction_server/models/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading
Loading