feat: secure resource explanation subjects

This commit is contained in:
2026-08-20 06:16:43 +02:00
parent e4bae0121d
commit 566b3b83ad
4 changed files with 310 additions and 1 deletions
@@ -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]
+135 -1
View File
@@ -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,
+51
View File
@@ -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()