Skip to content
Merged
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
5 changes: 5 additions & 0 deletions src/backend/bisheng/common/errcode/permission.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,8 @@ class PermissionMutationTooLargeError(BaseErrorCode):
class PermissionEnumerationIncompleteError(BaseErrorCode):
Code: int = 25014
Msg: str = "Permission object enumeration did not complete"


class SameLevelGrantRequiresManagePermissionError(BaseErrorCode):
Code: int = 25015
Msg: str = "Same-level grants require the manage_permission action"
87 changes: 87 additions & 0 deletions src/backend/bisheng/permission/application/catalog_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
PermissionProjectionFailedError,
PermissionPublishNotReadyError,
PermissionVersionConflictError,
SameLevelGrantRequiresManagePermissionError,
)
from bisheng.core.context.tenant import bypass_tenant_filter
from bisheng.core.database import get_async_db_session
Expand Down Expand Up @@ -59,10 +60,12 @@
derive_action_release,
)
from bisheng.permission.domain.services.catalog_service import (
CatalogActionChangeSummary,
CatalogCommitUnknownError,
CatalogDraftBuildInput,
CatalogDraftSnapshot,
CatalogImpactSummary,
CatalogModelChangeSummary,
CatalogPublishContext,
CatalogService,
CatalogTupleChange,
Expand All @@ -72,6 +75,7 @@
ModelReferenceSummary,
PermissionModelImpact,
PermissionModelRelease,
SameLevelGrantRequiresManagePermission,
derive_permission_models,
effective_model_action_codes,
)
Expand Down Expand Up @@ -1086,6 +1090,53 @@ async def analyze_draft(
revocation_count = sum(
len(before - after) * len(sources_by_grant.get(int(row.id), ())) for row, before, after in affected
)
affected_assignees_by_model: dict[str, int] = {}
for row, _, _ in affected:
affected_assignees_by_model[row.model_key] = affected_assignees_by_model.get(row.model_key, 0) + len(
sources_by_grant.get(int(row.id), ())
)
before_action_by_code = {action.code: action for action in before_actions.actions}
after_action_by_code = {action.code: action for action in after_actions.actions}
action_changes = tuple(
CatalogActionChangeSummary(
action_code=action_code,
action_name=(after_action_by_code.get(action_code) or before_action_by_code[action_code]).name,
before_level=(
before_action_by_code[action_code].level if action_code in before_action_by_code else None
),
after_level=(after_action_by_code[action_code].level if action_code in after_action_by_code else None),
before_active=(
before_action_by_code[action_code].active if action_code in before_action_by_code else False
),
after_active=(
after_action_by_code[action_code].active if action_code in after_action_by_code else False
),
)
for action_code in action_impact.changed_action_codes
)
model_changes = tuple(
CatalogModelChangeSummary(
model_key=model_key,
model_name=(after_by_key.get(model_key) or before_by_key[model_key]).name,
kind=(after_by_key.get(model_key) or before_by_key[model_key]).kind,
before_level=(before_by_key[model_key].derived_level if model_key in before_by_key else None),
after_level=(after_by_key[model_key].derived_level if model_key in after_by_key else None),
added_action_codes=tuple(
sorted(
set(after_by_key[model_key].action_codes if model_key in after_by_key else ())
- set(before_by_key[model_key].action_codes if model_key in before_by_key else ())
)
),
removed_action_codes=tuple(
sorted(
set(before_by_key[model_key].action_codes if model_key in before_by_key else ())
- set(after_by_key[model_key].action_codes if model_key in after_by_key else ())
)
),
affected_assignee_count=affected_assignees_by_model.get(model_key, 0),
)
for model_key in model_impact.changed_model_keys
)
source_signatures = {
int(row.id): tuple(
(
Expand Down Expand Up @@ -1120,6 +1171,8 @@ async def analyze_draft(
assignee_count=assignee_count,
expansion_count=expansion_count,
revocation_count=revocation_count,
action_changes=action_changes,
model_changes=model_changes,
blockers=(),
)

Expand Down Expand Up @@ -1516,6 +1569,11 @@ async def create_draft(
)
except (InvalidCatalogActionError, ImmutableStandardModelError):
raise
except SameLevelGrantRequiresManagePermission as exc:
raise SameLevelGrantRequiresManagePermissionError(
exception=exc,
msg=str(exc),
) from exc
except ValueError as exc:
raise InvalidCatalogActionError(
exception=exc,
Expand Down Expand Up @@ -1570,6 +1628,30 @@ async def _draft_payload(
"assignee_count": impact.assignee_count,
"expansion_count": impact.expansion_count,
"revocation_count": impact.revocation_count,
"action_changes": [
{
"action_code": change.action_code,
"action_name": change.action_name,
"before_level": change.before_level,
"after_level": change.after_level,
"before_active": change.before_active,
"after_active": change.after_active,
}
for change in impact.action_changes
],
"model_changes": [
{
"model_key": change.model_key,
"model_name": change.model_name,
"kind": change.kind,
"before_level": change.before_level,
"after_level": change.after_level,
"added_action_codes": list(change.added_action_codes),
"removed_action_codes": list(change.removed_action_codes),
"affected_assignee_count": change.affected_assignee_count,
}
for change in impact.model_changes
],
"blockers": sorted(set(draft.blockers) | set(impact.blockers)),
"expires_at": _as_utc(expires_at).isoformat(),
},
Expand Down Expand Up @@ -1743,6 +1825,11 @@ async def _apply_changes(
custom_models=custom_by_key.values(),
standard_allow_same_level=standard_policy,
)
except SameLevelGrantRequiresManagePermission as exc:
raise SameLevelGrantRequiresManagePermissionError(
exception=exc,
msg=str(exc),
) from exc
except ValueError as exc:
if touched_standard_keys:
raise ImmutableStandardModelError(
Expand Down
32 changes: 23 additions & 9 deletions src/backend/bisheng/permission/application/control_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import secrets
from dataclasses import dataclass
from typing import Any

from sqlalchemy import update
from sqlmodel import col, select
Expand Down Expand Up @@ -408,9 +407,7 @@ async def load_source_page(
# The parent's creator carries no authority here — this resource
# has its own protected creator row, and the inherited copy only
# showed up as a second, identical entry that cannot be acted on.
inherited_rows = [
(row, model_key) for row, model_key in inherited_rows if row.source_type != "CREATOR"
]
inherited_rows = [(row, model_key) for row, model_key in inherited_rows if row.source_type != "CREATOR"]
combined = sorted(
(
*((row, model_key, "LOCAL") for row, model_key in local_rows),
Expand Down Expand Up @@ -543,6 +540,7 @@ async def prepare_owner(
context: OwnerProjectionContext,
grant: GrantSnapshot | None,
source: GrantSourceRecord | None,
visibility: VisibilityProjectionCompilation | None,
*,
operation_id: int,
) -> None:
Expand Down Expand Up @@ -581,19 +579,26 @@ async def prepare_owner(
source=projection_source,
state="PENDING",
)
if visibility is not None:
await self._prepare_visible_sources(
session,
tenant_id=context.target.tenant_id,
visibility=visibility,
operation_id=operation_id,
)

async def finalize_owner(
self,
context: OwnerProjectionContext,
grant: GrantSnapshot | None,
outcome: Any,
visibility: VisibilityProjectionCompilation | None,
outcome: ProjectionOutcome,
) -> None:
del outcome
projection_grants = self._owner_projection_grants(
context,
grant,
)
if not projection_grants:
if not projection_grants and visibility is None:
return
async with get_async_db_session() as session:
async with session.begin():
Expand Down Expand Up @@ -623,6 +628,13 @@ async def finalize_owner(
)
.values(state="ACTIVE")
)
if visibility is not None:
await self._finalize_visible_sources(
session,
tenant_id=context.target.tenant_id,
visibility=visibility,
operation_id=outcome.operation_id,
)

async def mark_owner_compensation(
self,
Expand Down Expand Up @@ -1361,18 +1373,20 @@ async def prepare(
context,
grant,
source,
visibility,
*,
operation_id,
) -> None:
await self._state.prepare_owner(
context,
grant,
source,
visibility,
operation_id=operation_id,
)

async def finalize(self, context, grant, outcome) -> None:
await self._state.finalize_owner(context, grant, outcome)
async def finalize(self, context, grant, visibility, outcome) -> None:
await self._state.finalize_owner(context, grant, visibility, outcome)

async def mark_compensation_required(self, context, error) -> None:
await self._state.mark_owner_compensation(context, error)
Expand Down
22 changes: 22 additions & 0 deletions src/backend/bisheng/permission/domain/schemas/f048.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,35 @@ class CatalogDraftRequest(StrictRequestModel):
changes: tuple[CatalogChangeRequest, ...] = Field(min_length=1, max_length=50)


class CatalogActionChangeDTO(BaseModel):
action_code: str
action_name: str
before_level: PermissionActionLevel | None = None
after_level: PermissionActionLevel | None = None
before_active: bool
after_active: bool


class CatalogModelChangeDTO(BaseModel):
model_key: str
model_name: str
kind: Literal["STANDARD", "CUSTOM"]
before_level: PermissionActionLevel | None = None
after_level: PermissionActionLevel | None = None
added_action_codes: tuple[str, ...] = ()
removed_action_codes: tuple[str, ...] = ()
affected_assignee_count: int = Field(ge=0)


class CatalogImpactDTO(BaseModel):
checksum: str = Field(min_length=64, max_length=64)
resource_count: int = Field(ge=0)
grant_count: int = Field(ge=0)
assignee_count: int = Field(ge=0)
expansion_count: int = Field(ge=0)
revocation_count: int = Field(ge=0)
action_changes: tuple[CatalogActionChangeDTO, ...] = ()
model_changes: tuple[CatalogModelChangeDTO, ...] = ()
blockers: tuple[str, ...] = ()
expires_at: datetime

Expand Down
37 changes: 30 additions & 7 deletions src/backend/bisheng/permission/domain/services/catalog_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,32 @@ class CatalogCommitUnknownError(RuntimeError):
"""The active-pointer write may or may not have reached OpenFGA."""


@dataclass(frozen=True, slots=True)
class CatalogActionChangeSummary:
"""One operator-authored action change rendered in the impact review."""

action_code: str
action_name: str
before_level: int | None
after_level: int | None
before_active: bool
after_active: bool


@dataclass(frozen=True, slots=True)
class CatalogModelChangeSummary:
"""One derived model change and its existing authorization impact."""

model_key: str
model_name: str
kind: str
before_level: int | None
after_level: int | None
added_action_codes: tuple[str, ...]
removed_action_codes: tuple[str, ...]
affected_assignee_count: int = 0


@dataclass(frozen=True, slots=True)
class CatalogImpactSummary:
"""Cross-tenant impact aggregate bound to one complete draft."""
Expand All @@ -54,6 +80,8 @@ class CatalogImpactSummary:
assignee_count: int
expansion_count: int
revocation_count: int
action_changes: tuple[CatalogActionChangeSummary, ...] = ()
model_changes: tuple[CatalogModelChangeSummary, ...] = ()
blockers: tuple[str, ...] = ()


Expand Down Expand Up @@ -265,16 +293,11 @@ async def build_draft(
)
before_by_key = {model.model_key: model for model in build.before_models.models}
after_keys = {model.model_key for model in model_release.models}
deleted_models = tuple(
before_by_key[model_key]
for model_key in sorted(set(before_by_key) - after_keys)
)
deleted_models = tuple(before_by_key[model_key] for model_key in sorted(set(before_by_key) - after_keys))
for model in deleted_models:
references = build.model_reference_summaries.get(model.model_key)
if references is None:
raise PermissionModelStateConflictError(
msg=f"Model reference audit is missing: {model.model_key}"
)
raise PermissionModelStateConflictError(msg=f"Model reference audit is missing: {model.model_key}")
try:
ensure_model_deletable(model, references=references)
except ValueError as exc:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
STANDARD_MODEL_KEYS = frozenset(row[0] for row in STANDARD_MODEL_DEFINITIONS)


class SameLevelGrantRequiresManagePermission(ValueError):
"""A model cannot delegate its tier without permission management."""


@dataclass(frozen=True, slots=True)
class CustomModelSelection:
"""Administrator-owned explicit action selection."""
Expand Down Expand Up @@ -326,7 +330,9 @@ def with_allow_same_level(
if not isinstance(allow_same_level, bool):
raise ValueError("same-level policy must be boolean")
if allow_same_level and "manage_permission" not in model.action_codes:
raise ValueError(f"model {model.model_key} requires manage_permission to allow same level")
raise SameLevelGrantRequiresManagePermission(
f"model {model.model_key} requires manage_permission to allow same level"
)
return replace(model, allow_same_level=allow_same_level)


Expand Down
Loading
Loading