feat: secure resource explanation subjects
This commit is contained in:
@@ -541,6 +541,21 @@ class ResourceAccessExplanationResponse(BaseModel):
|
|||||||
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
provenance: list[AccessDecisionProvenanceItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
email: str | None = None
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectsResponse(BaseModel):
|
||||||
|
mode: Literal["current_user", "cross_user"]
|
||||||
|
can_select_other_users: bool
|
||||||
|
reason: str
|
||||||
|
source: str
|
||||||
|
required_scope: str | None = None
|
||||||
|
users: list[ResourceAccessExplanationSubjectItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class UserListResponse(PagedListResponse):
|
class UserListResponse(PagedListResponse):
|
||||||
users: list[UserAdminItem]
|
users: list[UserAdminItem]
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from govoplan_access.backend.admin.governance import (
|
from govoplan_access.backend.admin.governance import (
|
||||||
@@ -125,6 +126,8 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
|||||||
RoleSummary,
|
RoleSummary,
|
||||||
RoleUpdateRequest,
|
RoleUpdateRequest,
|
||||||
ResourceAccessExplanationResponse,
|
ResourceAccessExplanationResponse,
|
||||||
|
ResourceAccessExplanationSubjectItem,
|
||||||
|
ResourceAccessExplanationSubjectsResponse,
|
||||||
SystemAccountCreateRequest,
|
SystemAccountCreateRequest,
|
||||||
SystemAccountCreateResponse,
|
SystemAccountCreateResponse,
|
||||||
SystemAccountItem,
|
SystemAccountItem,
|
||||||
@@ -186,7 +189,15 @@ from govoplan_core.core.provider_governance import (
|
|||||||
ExternalProviderStateContext,
|
ExternalProviderStateContext,
|
||||||
collect_external_provider_states,
|
collect_external_provider_states,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_ACCESS_EXPLANATION,
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||||
|
AccessDecisionProvenance,
|
||||||
|
AccessExplanationService,
|
||||||
|
AccessExplanationSubjectDecision,
|
||||||
|
AccessExplanationSubjectPolicy,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory, OrganizationFunctionAssignmentRef
|
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.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||||
@@ -673,6 +684,40 @@ def _access_explanation_service_or_error() -> AccessExplanationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _access_explanation_subject_decision(
|
||||||
|
session: Session,
|
||||||
|
principal: ApiPrincipal,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AccessExplanationSubjectDecision:
|
||||||
|
registry = get_registry()
|
||||||
|
if registry is None or not registry.has_capability(
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
):
|
||||||
|
return AccessExplanationSubjectDecision(
|
||||||
|
allow_other_users=False,
|
||||||
|
reason="Access explanations are limited to the signed-in user because no subject policy is active.",
|
||||||
|
source="access.safe_default",
|
||||||
|
provenance={"tenant_id": tenant_id, "mode": "current_user"},
|
||||||
|
)
|
||||||
|
capability = registry.require_capability(
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
)
|
||||||
|
if not isinstance(capability, AccessExplanationSubjectPolicy):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=(
|
||||||
|
"Invalid capability: "
|
||||||
|
f"{CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return capability.decide_subject_selection(
|
||||||
|
session,
|
||||||
|
principal.principal,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _idm_assignments_for_user(
|
def _idm_assignments_for_user(
|
||||||
idm_directory: IdmDirectory | None,
|
idm_directory: IdmDirectory | None,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -2511,6 +2556,70 @@ def revoke_user_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/access/resource-explanation/subjects",
|
||||||
|
response_model=ResourceAccessExplanationSubjectsResponse,
|
||||||
|
)
|
||||||
|
def get_resource_access_explanation_subjects(
|
||||||
|
tenant_id: str | None = Query(default=None),
|
||||||
|
query: str | None = Query(default=None, max_length=200),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
principal: ApiPrincipal = Depends(
|
||||||
|
require_any_scope(
|
||||||
|
"admin:users:read",
|
||||||
|
"admin:roles:read",
|
||||||
|
"access:membership:read",
|
||||||
|
"access:role:read",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
) -> ResourceAccessExplanationSubjectsResponse:
|
||||||
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
users_query = session.query(User).filter(
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
if not decision.allow_other_users:
|
||||||
|
users_query = users_query.filter(User.id == principal.membership_id)
|
||||||
|
elif query and query.strip():
|
||||||
|
pattern = f"%{query.strip()}%"
|
||||||
|
users_query = users_query.filter(
|
||||||
|
or_(User.display_name.ilike(pattern), User.email.ilike(pattern))
|
||||||
|
)
|
||||||
|
users = users_query.order_by(User.display_name.asc(), User.email.asc()).limit(100).all()
|
||||||
|
if decision.allow_other_users and not query:
|
||||||
|
current_user = (
|
||||||
|
session.query(User)
|
||||||
|
.filter(
|
||||||
|
User.id == principal.membership_id,
|
||||||
|
User.tenant_id == tenant.id,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if current_user is not None and all(user.id != current_user.id for user in users):
|
||||||
|
users = [current_user, *users[:99]]
|
||||||
|
return ResourceAccessExplanationSubjectsResponse(
|
||||||
|
mode="cross_user" if decision.allow_other_users else "current_user",
|
||||||
|
can_select_other_users=decision.allow_other_users,
|
||||||
|
reason=decision.reason,
|
||||||
|
source=decision.source,
|
||||||
|
required_scope=decision.required_scope,
|
||||||
|
users=[
|
||||||
|
ResourceAccessExplanationSubjectItem(
|
||||||
|
id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
display_name=user.display_name,
|
||||||
|
)
|
||||||
|
for user in users
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
|
@router.get("/access/resource-explanation", response_model=ResourceAccessExplanationResponse)
|
||||||
def get_resource_access_explanation(
|
def get_resource_access_explanation(
|
||||||
user_id: str = Query(...),
|
user_id: str = Query(...),
|
||||||
@@ -2522,6 +2631,16 @@ def get_resource_access_explanation(
|
|||||||
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
|
principal: ApiPrincipal = Depends(require_any_scope("admin:users:read", "admin:roles:read", "access:membership:read", "access:role:read")),
|
||||||
):
|
):
|
||||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
tenant_id=tenant.id,
|
||||||
|
)
|
||||||
|
if user_id != principal.membership_id and not decision.allow_other_users:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail=decision.reason,
|
||||||
|
)
|
||||||
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
user = session.query(User).filter(User.id == user_id, User.tenant_id == tenant.id).one_or_none()
|
||||||
if user is None:
|
if user is None:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||||
@@ -2541,6 +2660,21 @@ def get_resource_access_explanation(
|
|||||||
resource_id=resource_id,
|
resource_id=resource_id,
|
||||||
action=action,
|
action=action,
|
||||||
)
|
)
|
||||||
|
if user_id != principal.membership_id:
|
||||||
|
audit_from_principal(
|
||||||
|
session,
|
||||||
|
principal,
|
||||||
|
action="access.resource_explanation.selected_user_viewed",
|
||||||
|
scope="tenant",
|
||||||
|
object_type=resource_type,
|
||||||
|
object_id=resource_id,
|
||||||
|
details={
|
||||||
|
"target_membership_id": user.id,
|
||||||
|
"requested_action": action,
|
||||||
|
"policy_source": decision.source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
return ResourceAccessExplanationResponse(
|
return ResourceAccessExplanationResponse(
|
||||||
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
||||||
resource_type=resource_type,
|
resource_type=resource_type,
|
||||||
|
|||||||
@@ -301,6 +301,57 @@ ADMIN_READ_SCOPES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||||
|
DocumentationTopic(
|
||||||
|
id="access.reference.resource-explanation-subjects",
|
||||||
|
title="Select a user for resource-access diagnostics",
|
||||||
|
summary=(
|
||||||
|
"Resource explanations default to the signed-in user and expose "
|
||||||
|
"other tenant users only when Policy permits the diagnostic."
|
||||||
|
),
|
||||||
|
body=(
|
||||||
|
"The shared Files and Campaign explanation dialog asks Access for "
|
||||||
|
"the permitted subject list. If Policy is unavailable or the actor "
|
||||||
|
"lacks policy:access_explanation:select_user, Access returns only "
|
||||||
|
"the signed-in active membership and no metadata for other users. "
|
||||||
|
"When Policy permits selection, the picker is limited to active "
|
||||||
|
"users in the current tenant. Every explanation run for another "
|
||||||
|
"user creates audit evidence with the target membership, resource, "
|
||||||
|
"requested action, and policy source. The explanation is diagnostic "
|
||||||
|
"and never grants resource access."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("tenant_admin", "access_admin", "security_reviewer"),
|
||||||
|
order=29,
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("access",),
|
||||||
|
any_scopes=(
|
||||||
|
"admin:users:read",
|
||||||
|
"admin:roles:read",
|
||||||
|
"access:membership:read",
|
||||||
|
"access:role:read",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Permitted explanation subjects API",
|
||||||
|
href="/api/v1/admin/access/resource-explanation/subjects",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Resource explanation API",
|
||||||
|
href="/api/v1/admin/access/resource-explanation",
|
||||||
|
kind="api",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
related_modules=("audit", "campaigns", "files", "policy"),
|
||||||
|
metadata={
|
||||||
|
"kind": "reference",
|
||||||
|
"help_contexts": ["access.resource-explanation.subject"],
|
||||||
|
},
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="access.operator.enroll-first-administrator",
|
id="access.operator.enroll-first-administrator",
|
||||||
title="Enroll the first production administrator",
|
title="Enroll the first production administrator",
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import (
|
||||||
|
CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS,
|
||||||
|
AccessExplanationSubjectDecision,
|
||||||
|
PrincipalRef,
|
||||||
|
)
|
||||||
|
from govoplan_access.backend.api.v1.routes import (
|
||||||
|
_access_explanation_subject_decision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _principal() -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="user-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SubjectPolicy:
|
||||||
|
def decide_subject_selection(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: PrincipalRef,
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
) -> AccessExplanationSubjectDecision:
|
||||||
|
del session, principal, tenant_id
|
||||||
|
return AccessExplanationSubjectDecision(
|
||||||
|
allow_other_users=True,
|
||||||
|
reason="Permitted by test policy.",
|
||||||
|
source="test.policy",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, provider: object | None = None) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return (
|
||||||
|
name == CAPABILITY_POLICY_ACCESS_EXPLANATION_SUBJECTS
|
||||||
|
and self.provider is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def require_capability(self, name: str) -> object:
|
||||||
|
if not self.has_capability(name):
|
||||||
|
raise KeyError(name)
|
||||||
|
return self.provider
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceAccessExplanationSubjectTests(unittest.TestCase):
|
||||||
|
def test_missing_policy_defaults_to_current_user(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=None,
|
||||||
|
):
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
_principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(decision.allow_other_users)
|
||||||
|
self.assertEqual("access.safe_default", decision.source)
|
||||||
|
|
||||||
|
def test_policy_capability_controls_cross_user_selection(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_access.backend.api.v1.routes.get_registry",
|
||||||
|
return_value=_Registry(_SubjectPolicy()),
|
||||||
|
):
|
||||||
|
decision = _access_explanation_subject_decision(
|
||||||
|
object(), # type: ignore[arg-type]
|
||||||
|
_principal(),
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(decision.allow_other_users)
|
||||||
|
self.assertEqual("test.policy", decision.source)
|
||||||
|
|
||||||
|
def test_route_contract_is_tenant_bounded_and_audited(self) -> None:
|
||||||
|
source = (
|
||||||
|
ROOT / "src/govoplan_access/backend/api/v1/routes.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn('User.tenant_id == tenant.id', source)
|
||||||
|
self.assertIn('User.id == principal.membership_id', source)
|
||||||
|
self.assertIn(
|
||||||
|
'action="access.resource_explanation.selected_user_viewed"',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user