Prefer canonical identity and organization directories
This commit is contained in:
@@ -43,6 +43,7 @@ from govoplan_access.backend.db.models import (
|
||||
UserGroupMembership,
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.organizations import OrganizationDirectory
|
||||
from govoplan_access.backend.permissions.catalog import effective_permission_count, expand_scopes
|
||||
|
||||
|
||||
@@ -143,13 +144,22 @@ def _user_item(
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> 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 []
|
||||
external_roles = (
|
||||
collect_external_function_roles(
|
||||
session,
|
||||
user,
|
||||
idm_assignments,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
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 [])
|
||||
|
||||
@@ -18,6 +18,8 @@ from govoplan_core.api.v1.schemas import (
|
||||
UserUiPreferences,
|
||||
)
|
||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.admin.settings import get_system_settings
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
@@ -176,6 +178,16 @@ def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[s
|
||||
}
|
||||
|
||||
|
||||
def _identity_directory_from_request(request: Request) -> IdentityDirectory | None:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry) or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
|
||||
account = (
|
||||
session.query(Account)
|
||||
@@ -215,6 +227,8 @@ def _me_response(
|
||||
service_account_id: str | None = None,
|
||||
include_system: bool = True,
|
||||
include_all_memberships: bool = True,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
identity_id: str | None = None,
|
||||
) -> MeResponse:
|
||||
tenant_roles = collect_user_roles(session, user)
|
||||
system_roles = collect_system_roles(session, account) if include_system else []
|
||||
@@ -248,7 +262,7 @@ def _me_response(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
identity_id=identity_id_for_account(session, account.id),
|
||||
identity_id=identity_id or identity_id_for_account(session, account.id, identity_directory=identity_directory),
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=frozenset(group.id for group in groups),
|
||||
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
|
||||
@@ -271,7 +285,8 @@ def _me_response(
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, session: Session = Depends(get_session)):
|
||||
account, user, tenant = _resolve_login_user(session, payload)
|
||||
me_payload = _me_response(session, account=account, user=user, tenant=tenant)
|
||||
identity_directory = _identity_directory_from_request(request)
|
||||
me_payload = _me_response(session, account=account, user=user, tenant=tenant, identity_directory=identity_directory)
|
||||
maintenance_mode = saved_maintenance_mode(session)
|
||||
if maintenance_mode.enabled and not scopes_grant(me_payload.scopes, MAINTENANCE_ACCESS_SCOPE):
|
||||
raise HTTPException(
|
||||
@@ -300,6 +315,7 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
|
||||
effective_scopes=me_payload.scopes,
|
||||
auth_method="session",
|
||||
session_id=created.model.id,
|
||||
identity_directory=identity_directory,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
@@ -321,6 +337,7 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
|
||||
service_account_id=principal.principal.service_account_id,
|
||||
include_system=principal.auth_session is not None,
|
||||
include_all_memberships=principal.auth_session is not None,
|
||||
identity_id=principal.principal.identity_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -401,6 +418,7 @@ def update_profile(
|
||||
session_id=principal.session_id,
|
||||
include_system=True,
|
||||
include_all_memberships=True,
|
||||
identity_id=principal.principal.identity_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -430,6 +448,7 @@ def switch_tenant(
|
||||
tenant=tenant,
|
||||
auth_method="session",
|
||||
session_id=principal.session_id,
|
||||
identity_id=principal.principal.identity_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ from govoplan_core.core.configuration_control import (
|
||||
)
|
||||
from govoplan_core.core.configuration_safety import configuration_safety_catalog, plan_configuration_change
|
||||
from govoplan_core.core.access import CAPABILITY_ACCESS_EXPLANATION, AccessExplanationService, AccessDecisionProvenance, PrincipalRef
|
||||
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.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
@@ -507,6 +508,16 @@ def _optional_organization_directory() -> OrganizationDirectory | None:
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_identity_directory() -> IdentityDirectory | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDENTITY_DIRECTORY):
|
||||
return None
|
||||
capability = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
|
||||
if not isinstance(capability, IdentityDirectory):
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _optional_idm_directory() -> IdmDirectory | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_IDM_DIRECTORY):
|
||||
@@ -527,6 +538,7 @@ def _access_explanation_service_or_error() -> AccessExplanationService:
|
||||
from govoplan_access.backend.explanation import SqlAccessExplanationService
|
||||
|
||||
return SqlAccessExplanationService(
|
||||
identity_directory=_optional_identity_directory(),
|
||||
idm_directory=_optional_idm_directory(),
|
||||
organization_directory=_optional_organization_directory(),
|
||||
)
|
||||
@@ -541,12 +553,19 @@ def _idm_assignments_for_user(
|
||||
return tuple(idm_directory.organization_function_assignments_for_account(user.account_id, tenant_id=user.tenant_id))
|
||||
|
||||
|
||||
def _principal_ref_for_user(session: Session, user: User, *, idm_directory: IdmDirectory | None = None) -> PrincipalRef:
|
||||
def _principal_ref_for_user(
|
||||
session: Session,
|
||||
user: User,
|
||||
*,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
identity_directory: IdentityDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | None = None,
|
||||
) -> PrincipalRef:
|
||||
account = session.get(Account, user.account_id)
|
||||
if account is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
idm_assignments = _idm_assignments_for_user(idm_directory, user)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments))
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments, organization_directory=organization_directory))
|
||||
scopes = set(collect_user_scopes(session, user, include_system=True))
|
||||
for role in idm_roles:
|
||||
scopes.update(role.permissions or [])
|
||||
@@ -558,7 +577,7 @@ def _principal_ref_for_user(session: Session, user: User, *, idm_directory: IdmD
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=user.tenant_id,
|
||||
identity_id=identity_id_for_account(session, account.id),
|
||||
identity_id=identity_id_for_account(session, account.id, identity_directory=identity_directory),
|
||||
scopes=frozenset(sorted(scopes)),
|
||||
group_ids=frozenset(group.id for group in collect_user_groups(session, user)),
|
||||
role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
|
||||
@@ -1937,8 +1956,9 @@ def _full_users_delta_response(session: Session, tenant: Tenant) -> UserListDelt
|
||||
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()
|
||||
organization_directory = _optional_organization_directory()
|
||||
return UserListDeltaResponse(
|
||||
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users],
|
||||
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users],
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_USERS_COLLECTION,)),
|
||||
has_more=False,
|
||||
@@ -1960,13 +1980,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()
|
||||
organization_directory = _optional_organization_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_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in visible.values()],
|
||||
users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_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,
|
||||
@@ -1980,9 +2001,16 @@ def _user_item_for_response(
|
||||
*,
|
||||
owner_ids: set[str] | None = None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
organization_directory: OrganizationDirectory | 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)
|
||||
return _user_item(
|
||||
session,
|
||||
user,
|
||||
owner_ids=owner_ids,
|
||||
idm_assignments=idm_assignments,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/delta", response_model=UserListDeltaResponse)
|
||||
@@ -2009,7 +2037,8 @@ def list_users(
|
||||
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 UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory) for user in users])
|
||||
organization_directory = _optional_organization_directory()
|
||||
return UserListResponse(users=[_user_item_for_response(session, user, owner_ids=owner_ids, idm_directory=idm_directory, organization_directory=organization_directory) for user in users])
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/access-explanation", response_model=UserAccessExplanationResponse)
|
||||
@@ -2024,15 +2053,16 @@ def get_user_access_explanation(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
idm_directory = _optional_idm_directory()
|
||||
organization_directory = _optional_organization_directory()
|
||||
explanation = build_user_access_explanation(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=_optional_organization_directory(),
|
||||
organization_directory=organization_directory,
|
||||
include_system=False,
|
||||
)
|
||||
return UserAccessExplanationResponse(
|
||||
user=_user_item_for_response(session, user, idm_directory=idm_directory),
|
||||
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_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],
|
||||
@@ -2054,7 +2084,15 @@ def get_resource_access_explanation(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
idm_directory = _optional_idm_directory()
|
||||
target_principal = _principal_ref_for_user(session, user, idm_directory=idm_directory)
|
||||
identity_directory = _optional_identity_directory()
|
||||
organization_directory = _optional_organization_directory()
|
||||
target_principal = _principal_ref_for_user(
|
||||
session,
|
||||
user,
|
||||
idm_directory=idm_directory,
|
||||
identity_directory=identity_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
provenance = _access_explanation_service_or_error().explain_resource_provenance(
|
||||
target_principal,
|
||||
resource_type=resource_type,
|
||||
@@ -2062,7 +2100,7 @@ def get_resource_access_explanation(
|
||||
action=action,
|
||||
)
|
||||
return ResourceAccessExplanationResponse(
|
||||
user=_user_item_for_response(session, user, idm_directory=idm_directory),
|
||||
user=_user_item_for_response(session, user, idm_directory=idm_directory, organization_directory=organization_directory),
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
action=action,
|
||||
@@ -2152,8 +2190,9 @@ def create_user(
|
||||
)
|
||||
session.commit()
|
||||
idm_directory = _optional_idm_directory()
|
||||
organization_directory = _optional_organization_directory()
|
||||
return UserCreateResponse(
|
||||
user=_user_item_for_response(session, result.user, idm_directory=idm_directory),
|
||||
user=_user_item_for_response(session, result.user, idm_directory=idm_directory, organization_directory=organization_directory),
|
||||
account_created=result.account_created,
|
||||
temporary_password=result.temporary_password,
|
||||
)
|
||||
@@ -2234,7 +2273,12 @@ def update_user(
|
||||
details=payload.model_dump(exclude_none=True),
|
||||
)
|
||||
session.commit()
|
||||
return _user_item_for_response(session, user, idm_directory=_optional_idm_directory())
|
||||
return _user_item_for_response(
|
||||
session,
|
||||
user,
|
||||
idm_directory=_optional_idm_directory(),
|
||||
organization_directory=_optional_organization_directory(),
|
||||
)
|
||||
|
||||
|
||||
def _full_groups_delta_response(session: Session, tenant: Tenant) -> GroupListDeltaResponse:
|
||||
@@ -3311,7 +3355,12 @@ 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 = _user_item_for_response(session, user, idm_directory=_optional_idm_directory()).effective_scopes
|
||||
user_scopes = _user_item_for_response(
|
||||
session,
|
||||
user,
|
||||
idm_directory=_optional_idm_directory(),
|
||||
organization_directory=_optional_organization_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