Move access permissions to access module
This commit is contained in:
@@ -25,7 +25,11 @@ from govoplan_access.backend.security.sessions import (
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids
|
||||
from govoplan_access.backend.semantic import (
|
||||
collect_external_function_roles,
|
||||
collect_function_assignment_ids,
|
||||
collect_function_delegation_ids,
|
||||
)
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
@@ -38,7 +42,8 @@ from govoplan_access.backend.db.models import (
|
||||
User,
|
||||
UserGroupMembership,
|
||||
)
|
||||
from govoplan_core.security.permissions import effective_permission_count
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
||||
|
||||
|
||||
def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
@@ -132,12 +137,24 @@ def _group_summary(session: Session, group: Group, *, include_members: bool = Tr
|
||||
)
|
||||
|
||||
|
||||
def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = None) -> UserAdminItem:
|
||||
def _user_item(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
) -> UserAdminItem:
|
||||
account = session.get(Account, user.account_id)
|
||||
if account is None:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="User account is missing")
|
||||
groups = collect_user_groups(session, user)
|
||||
roles = collect_direct_user_roles(session, user)
|
||||
external_roles = collect_external_function_roles(session, user, idm_assignments) if idm_assignments else []
|
||||
effective_scopes = set(collect_user_scopes(session, user, include_system=False))
|
||||
for role in external_roles:
|
||||
effective_scopes.update(role.permissions or [])
|
||||
function_assignment_ids = collect_function_assignment_ids(session, user)
|
||||
function_assignment_ids.extend(item.id for item in idm_assignments)
|
||||
effective_owner_ids = owner_ids if owner_ids is not None else tenant_owner_user_ids(session, user.tenant_id)
|
||||
return UserAdminItem(
|
||||
id=user.id,
|
||||
@@ -151,9 +168,9 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
|
||||
last_login_at=account.last_login_at,
|
||||
groups=[_group_summary(session, group, include_members=False) for group in groups],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
function_assignment_ids=collect_function_assignment_ids(session, user),
|
||||
function_assignment_ids=sorted(dict.fromkeys(function_assignment_ids)),
|
||||
function_delegation_ids=collect_function_delegation_ids(session, user),
|
||||
effective_scopes=collect_user_scopes(session, user, include_system=False),
|
||||
effective_scopes=expand_scopes(effective_scopes),
|
||||
is_owner=user.id in effective_owner_ids,
|
||||
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
|
||||
created_at=user.created_at,
|
||||
|
||||
@@ -454,6 +454,64 @@ class UserAdminItem(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
AccessRoleSourceType = Literal["direct_role", "group_role", "legacy_function_role", "idm_function_role", "system_role"]
|
||||
|
||||
|
||||
class AccessRoleSourceItem(BaseModel):
|
||||
source_type: AccessRoleSourceType
|
||||
role_id: str
|
||||
role_slug: str
|
||||
role_name: str
|
||||
permissions: list[str] = Field(default_factory=list)
|
||||
tenant_id: str | None = None
|
||||
group_id: str | None = None
|
||||
group_name: str | None = None
|
||||
function_assignment_id: str | None = None
|
||||
function_id: str | None = None
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
organization_unit_name: str | None = None
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
source_module: str | None = None
|
||||
assignment_source: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
delegated_from_assignment_id: str | None = None
|
||||
delegation_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
|
||||
|
||||
class AccessScopeExplanationItem(BaseModel):
|
||||
scope: str
|
||||
sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class FunctionFactExplanationItem(BaseModel):
|
||||
source_module: str
|
||||
assignment_id: str
|
||||
tenant_id: str
|
||||
identity_id: str | None = None
|
||||
account_id: str | None = None
|
||||
function_id: str
|
||||
function_name: str | None = None
|
||||
organization_unit_id: str
|
||||
organization_unit_name: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
assignment_source: str
|
||||
status: str
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
role_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserAccessExplanationResponse(BaseModel):
|
||||
user: UserAdminItem
|
||||
role_sources: list[AccessRoleSourceItem] = Field(default_factory=list)
|
||||
scopes: list[AccessScopeExplanationItem] = Field(default_factory=list)
|
||||
function_facts: list[FunctionFactExplanationItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
users: list[UserAdminItem]
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ from govoplan_core.i18n import (
|
||||
tenant_enabled_language_codes,
|
||||
user_enabled_language_codes,
|
||||
)
|
||||
from govoplan_core.security.permissions import normalize_email, scopes_grant
|
||||
from govoplan_access.backend.permissions.catalog import normalize_email, scopes_grant
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_core.settings import settings
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
|
||||
@@ -120,16 +120,17 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
SystemAccountUpdateRequest,
|
||||
UserCreateRequest,
|
||||
UserCreateResponse,
|
||||
UserAccessExplanationResponse,
|
||||
UserAdminItem,
|
||||
UserListDeltaResponse,
|
||||
UserListResponse,
|
||||
UserUpdateRequest,
|
||||
)
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_access.backend.security.sessions import collect_user_scopes
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal, has_scope, require_any_scope, require_scope
|
||||
from govoplan_core.audit.logging import audit_event, audit_from_principal
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY, SqlAccessConfigurationProvider
|
||||
from govoplan_access.backend.explanation import build_user_access_explanation
|
||||
from govoplan_access.backend.runtime import get_registry
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
CONFIGURATION_PROVIDER_CAPABILITY,
|
||||
@@ -153,6 +154,7 @@ from govoplan_core.core.configuration_control import (
|
||||
record_configuration_change_applied,
|
||||
)
|
||||
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.core.change_sequence import (
|
||||
@@ -185,7 +187,11 @@ from govoplan_access.backend.db.models import (
|
||||
)
|
||||
from govoplan_access.backend.semantic import identity_id_for_account
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.security.permissions import ALL_PERMISSIONS, normalize_email, scopes_grant
|
||||
from govoplan_access.backend.permissions.catalog import (
|
||||
normalize_email,
|
||||
permission_catalog as access_permission_catalog,
|
||||
scopes_grant,
|
||||
)
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
@@ -488,6 +494,35 @@ def _organization_directory_or_error() -> OrganizationDirectory:
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_organization_directory() -> OrganizationDirectory | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_ORGANIZATION_DIRECTORY)
|
||||
if not isinstance(capability, OrganizationDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_idm_directory() -> IdmDirectory | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_IDM_DIRECTORY)
|
||||
if not isinstance(capability, IdmDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _idm_assignments_for_user(
|
||||
idm_directory: IdmDirectory | None,
|
||||
user: User,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
if idm_directory is None:
|
||||
return ()
|
||||
return tuple(idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id))
|
||||
|
||||
|
||||
def _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None:
|
||||
if source_module != ORGANIZATIONS_MODULE_ID:
|
||||
raise HTTPException(
|
||||
@@ -660,7 +695,7 @@ def permission_catalog(
|
||||
category=item.category,
|
||||
level=item.level,
|
||||
)
|
||||
for item in ALL_PERMISSIONS
|
||||
for item in access_permission_catalog(include_legacy=True)
|
||||
if item.level == "tenant" or has_scope(principal, "system:roles:read") or has_scope(principal, "system:access:read") or has_scope(principal, "system:governance:read")
|
||||
]
|
||||
return PermissionCatalogResponse(permissions=items)
|
||||
@@ -1850,8 +1885,9 @@ def revoke_function_delegation(
|
||||
def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDeltaResponse:
|
||||
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
idm_directory = _optional_idm_directory()
|
||||
return UserListDeltaResponse(
|
||||
users=[_user_item(session, user, owner_ids=owner_ids) for user in users],
|
||||
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users],
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
|
||||
has_more=False,
|
||||
@@ -1872,13 +1908,14 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
|
||||
)
|
||||
}
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
idm_directory = _optional_idm_directory()
|
||||
deleted = [
|
||||
_delta_deleted_item(entry)
|
||||
for entry in entries
|
||||
if entry.resource_type == "access_user" and entry.resource_id not in visible
|
||||
]
|
||||
return UserListDeltaResponse(
|
||||
users=[_user_item(session, user, owner_ids=owner_ids) for user in visible.values()],
|
||||
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in visible.values()],
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(session, tenant_id=tenant.id, collections=(ACCESS_USERS_COLLECTION,), entries=entries, has_more=has_more),
|
||||
has_more=has_more,
|
||||
@@ -1886,6 +1923,17 @@ def _users_delta_response(session: Session, tenant: Tenant, *, since: str, limit
|
||||
)
|
||||
|
||||
|
||||
def _user_item_for_response(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
) -> UserAdminItem:
|
||||
idm_assignments = _idm_assignments_for_user(idm_directory, user)
|
||||
return _user_item(session, user, owner_ids=owner_ids, idm_assignments=idm_assignments)
|
||||
|
||||
|
||||
@router.get("/users/delta", response_model=UserListDeltaResponse)
|
||||
def list_users_delta(
|
||||
tenant_id: str | None = Query(default=None),
|
||||
@@ -1909,7 +1957,35 @@ def list_users(
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
users = session.query(User).filter(User.tenant_id == tenant.id).order_by(User.display_name.asc(), User.email.asc()).all()
|
||||
owner_ids = tenant_owner_user_ids(session, tenant.id)
|
||||
return UserListResponse(users=[_user_item(session, user, owner_ids=owner_ids) for user in users])
|
||||
idm_directory = _optional_idm_directory()
|
||||
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users])
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
|
||||
def get_user_access_explanation(
|
||||
user_id: str,
|
||||
tenant_id: str | None = Query(default=None),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
idm_directory = _optional_idm_directory()
|
||||
explanation = build_user_access_explanation(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=_optional_organization_directory(),
|
||||
include_system=False,
|
||||
)
|
||||
return UserAccessExplanationResponse(
|
||||
user=_user_item_for_response(session, user, idm_directory=idm_directory),
|
||||
role_sources=[source.to_dict() for source in explanation.role_sources],
|
||||
scopes=[scope.to_dict() for scope in explanation.scopes],
|
||||
function_facts=[fact.to_dict() for fact in explanation.function_facts],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/users", response_model=UserCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||
@@ -1993,8 +2069,9 @@ def create_user(
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
idm_directory = _optional_idm_directory()
|
||||
return UserCreateResponse(
|
||||
user=_user_item(session, result.user),
|
||||
user=_user_item_for_response(session, result.user, idm_directory=idm_directory),
|
||||
account_created=result.account_created,
|
||||
temporary_password=result.temporary_password,
|
||||
)
|
||||
@@ -2075,7 +2152,7 @@ def update_user(
|
||||
details=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
session.commit()
|
||||
return _user_item(session, user)
|
||||
return _user_item_for_response(session, user, idm_directory=_optional_idm_directory())
|
||||
|
||||
|
||||
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse:
|
||||
@@ -3152,7 +3229,7 @@ def create_tenant_api_key(
|
||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id, User.is_active.is_(True)).one_or_none()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active user not found")
|
||||
user_scopes = collect_user_scopes(session, user, include_system=False)
|
||||
user_scopes = _user_item_for_response(session, user, idm_directory=_optional_idm_directory()).effective_scopes
|
||||
requested = payload.scopes or ["campaign:read"]
|
||||
invalid = [scope for scope in requested if scope.startswith("system:") or not scopes_grant(user_scopes, scope)]
|
||||
if invalid:
|
||||
|
||||
Reference in New Issue
Block a user