Sync GovOPlaN module state
This commit is contained in:
@@ -5,11 +5,21 @@ from collections.abc import Iterable, Mapping, Sequence
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Role, User
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, Group, Role, User
|
||||
from govoplan_core.core.access import AccessAdministration
|
||||
|
||||
|
||||
class SqlAccessAdministration(AccessAdministration):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
db = _session(session)
|
||||
return {
|
||||
"users": db.query(User).filter(User.tenant_id == tenant_id).count(),
|
||||
"active_users": db.query(User).filter(User.tenant_id == tenant_id, User.is_active.is_(True)).count(),
|
||||
"groups": db.query(Group).filter(Group.tenant_id == tenant_id).count(),
|
||||
"api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id).count(),
|
||||
"active_api_keys": db.query(ApiKey).filter(ApiKey.tenant_id == tenant_id, ApiKey.revoked_at.is_(None)).count(),
|
||||
}
|
||||
|
||||
def system_account_count(self, session: object) -> int:
|
||||
db = _session(session)
|
||||
return db.query(Account).count()
|
||||
@@ -50,6 +60,50 @@ class SqlAccessAdministration(AccessAdministration):
|
||||
)
|
||||
return tuple(user_id for (user_id,) in rows)
|
||||
|
||||
def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
user = _session(session).get(User, user_id)
|
||||
if user is None or user.tenant_id != tenant_id:
|
||||
return None
|
||||
return dict(user.settings or {})
|
||||
|
||||
def set_user_settings(
|
||||
self,
|
||||
session: object,
|
||||
user_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
db = _session(session)
|
||||
user = db.get(User, user_id)
|
||||
if user is None or user.tenant_id != tenant_id:
|
||||
return None
|
||||
user.settings = dict(settings)
|
||||
db.add(user)
|
||||
return dict(user.settings or {})
|
||||
|
||||
def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
group = _session(session).get(Group, group_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
return None
|
||||
return dict(group.settings or {})
|
||||
|
||||
def set_group_settings(
|
||||
self,
|
||||
session: object,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
db = _session(session)
|
||||
group = db.get(Group, group_id)
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
return None
|
||||
group.settings = dict(settings)
|
||||
db.add(group)
|
||||
return dict(group.settings or {})
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
|
||||
@@ -260,6 +260,44 @@ class FunctionListResponse(BaseModel):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
source_module: str
|
||||
function_id: str
|
||||
role_id: str
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListResponse(BaseModel):
|
||||
mappings: list[ExternalFunctionRoleMappingItem]
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingListDeltaResponse(ExternalFunctionRoleMappingListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = True
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
function_id: str
|
||||
role_id: str
|
||||
source_module: str = Field(default="organizations", max_length=50)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExternalFunctionRoleMappingUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
role_id: str | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class FunctionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from govoplan_core.security.permissions 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
|
||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||
from govoplan_access.backend.security.passwords import verify_password
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
collect_system_roles,
|
||||
@@ -46,7 +47,6 @@ from govoplan_access.backend.security.sessions import (
|
||||
collect_user_roles,
|
||||
collect_user_scopes,
|
||||
create_auth_session,
|
||||
switch_auth_session_tenant,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
@@ -413,12 +413,15 @@ def switch_tenant(
|
||||
if principal.auth_session is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="API keys cannot switch tenant context")
|
||||
try:
|
||||
membership = switch_auth_session_tenant(session, principal.auth_session, payload.tenant_id)
|
||||
switched = AccessTenantContextSwitcher().switch_tenant_context(session, principal=principal, tenant_id=payload.tenant_id)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
|
||||
tenant = session.get(Tenant, membership.tenant_id)
|
||||
tenant = session.get(Tenant, switched.tenant_id)
|
||||
membership = session.get(User, switched.membership_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
if membership is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant membership not found")
|
||||
session.commit()
|
||||
return _me_response(
|
||||
session,
|
||||
|
||||
@@ -63,6 +63,11 @@ from govoplan_access.backend.api.v1.admin_schemas import (
|
||||
GroupListResponse,
|
||||
GroupSummary,
|
||||
GroupUpdateRequest,
|
||||
ExternalFunctionRoleMappingCreateRequest,
|
||||
ExternalFunctionRoleMappingItem,
|
||||
ExternalFunctionRoleMappingListDeltaResponse,
|
||||
ExternalFunctionRoleMappingListResponse,
|
||||
ExternalFunctionRoleMappingUpdateRequest,
|
||||
FunctionAdminItem,
|
||||
FunctionAssignmentAdminItem,
|
||||
FunctionAssignmentCreateRequest,
|
||||
@@ -148,6 +153,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.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, ORGANIZATIONS_MODULE_ID, OrganizationDirectory
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
from govoplan_core.core.change_sequence import (
|
||||
decode_sequence_watermark,
|
||||
@@ -160,6 +166,7 @@ from govoplan_core.core.change_sequence import (
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
@@ -190,6 +197,7 @@ ACCESS_ROLES_COLLECTION = "access.admin.roles"
|
||||
ACCESS_IDENTITIES_COLLECTION = "access.admin.identities"
|
||||
ACCESS_ORGANIZATION_UNITS_COLLECTION = "access.admin.organization_units"
|
||||
ACCESS_FUNCTIONS_COLLECTION = "access.admin.functions"
|
||||
ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION = "access.admin.external_function_role_mappings"
|
||||
ACCESS_FUNCTION_ASSIGNMENTS_COLLECTION = "access.admin.function_assignments"
|
||||
ACCESS_FUNCTION_DELEGATIONS_COLLECTION = "access.admin.function_delegations"
|
||||
ACCESS_SYSTEM_ROLES_COLLECTION = "access.admin.system_roles"
|
||||
@@ -439,6 +447,60 @@ def _set_function_roles(session: Session, *, function: Function, role_ids: list[
|
||||
session.flush()
|
||||
|
||||
|
||||
def _external_function_role_mapping_item(item: ExternalFunctionRoleAssignment) -> ExternalFunctionRoleMappingItem:
|
||||
return ExternalFunctionRoleMappingItem(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
source_module=item.source_module,
|
||||
function_id=item.function_id,
|
||||
role_id=item.role_id,
|
||||
settings=item.settings or {},
|
||||
created_at=item.created_at,
|
||||
updated_at=item.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _validate_external_function_role(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
role_id: str,
|
||||
) -> Role:
|
||||
role = session.query(Role).filter(Role.tenant_id == tenant_id, Role.id == role_id, Role.is_assignable.is_(True)).one_or_none()
|
||||
if role is None:
|
||||
raise AdminValidationError("Selected role is invalid or not assignable.")
|
||||
return role
|
||||
|
||||
|
||||
def _organization_directory_or_error() -> OrganizationDirectory:
|
||||
registry = get_registry()
|
||||
if registry is None or not registry.has_capability(CAPABILITY_ORGANIZATION_DIRECTORY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"code": "organizations_required",
|
||||
"message": "External function role mappings require the Organizations module.",
|
||||
},
|
||||
)
|
||||
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 _validate_external_function_source(*, tenant_id: str, source_module: str, function_id: str) -> None:
|
||||
if source_module != ORGANIZATIONS_MODULE_ID:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail=f"Unsupported function source module: {source_module}",
|
||||
)
|
||||
function = _organization_directory_or_error().get_function(function_id)
|
||||
if function is None or function.tenant_id != tenant_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization function not found.")
|
||||
if function.status != "active":
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Organization function is not active.")
|
||||
|
||||
|
||||
def _function_assignment_item(item: FunctionAssignment) -> FunctionAssignmentAdminItem:
|
||||
return FunctionAssignmentAdminItem(
|
||||
id=item.id,
|
||||
@@ -1292,6 +1354,206 @@ def deactivate_function(
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/external-function-role-mappings", response_model=ExternalFunctionRoleMappingListResponse)
|
||||
def list_external_function_role_mappings(
|
||||
tenant_id: str | None = None,
|
||||
source_module: str | None = None,
|
||||
function_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
query = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
||||
if source_module:
|
||||
query = query.filter(ExternalFunctionRoleAssignment.source_module == source_module.strip())
|
||||
if function_id:
|
||||
query = query.filter(ExternalFunctionRoleAssignment.function_id == function_id.strip())
|
||||
items = query.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc()).all()
|
||||
return ExternalFunctionRoleMappingListResponse(mappings=[_external_function_role_mapping_item(item) for item in items])
|
||||
|
||||
|
||||
def _full_external_function_role_mappings_delta_response(session: Session, tenant: Tenant) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||
items = (
|
||||
session.query(ExternalFunctionRoleAssignment)
|
||||
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id)
|
||||
.order_by(ExternalFunctionRoleAssignment.source_module.asc(), ExternalFunctionRoleAssignment.function_id.asc())
|
||||
.all()
|
||||
)
|
||||
return ExternalFunctionRoleMappingListDeltaResponse(
|
||||
mappings=[_external_function_role_mapping_item(item) for item in items],
|
||||
deleted=[],
|
||||
watermark=_access_delta_watermark(session, tenant.id, (ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,)),
|
||||
has_more=False,
|
||||
full=True,
|
||||
)
|
||||
|
||||
|
||||
def _external_function_role_mappings_delta_response(
|
||||
session: Session,
|
||||
tenant: Tenant,
|
||||
*,
|
||||
since: str,
|
||||
limit: int,
|
||||
) -> ExternalFunctionRoleMappingListDeltaResponse:
|
||||
entries, has_more = _access_delta_entries(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
|
||||
since=since,
|
||||
limit=limit,
|
||||
)
|
||||
if entries is None:
|
||||
return _full_external_function_role_mappings_delta_response(session, tenant)
|
||||
changed_ids = _changed_ids(entries, "access_external_function_role_mapping")
|
||||
visible = {
|
||||
item.id: item
|
||||
for item in (
|
||||
session.query(ExternalFunctionRoleAssignment)
|
||||
.filter(ExternalFunctionRoleAssignment.tenant_id == tenant.id, ExternalFunctionRoleAssignment.id.in_(changed_ids))
|
||||
.all()
|
||||
if changed_ids
|
||||
else []
|
||||
)
|
||||
}
|
||||
deleted = [
|
||||
_delta_deleted_item(entry)
|
||||
for entry in entries
|
||||
if entry.resource_type == "access_external_function_role_mapping" and entry.resource_id not in visible
|
||||
]
|
||||
return ExternalFunctionRoleMappingListDeltaResponse(
|
||||
mappings=[_external_function_role_mapping_item(item) for item in visible.values()],
|
||||
deleted=deleted,
|
||||
watermark=_access_delta_response_watermark(
|
||||
session,
|
||||
tenant_id=tenant.id,
|
||||
collections=(ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,),
|
||||
entries=entries,
|
||||
has_more=has_more,
|
||||
),
|
||||
has_more=has_more,
|
||||
full=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/external-function-role-mappings/delta", response_model=ExternalFunctionRoleMappingListDeltaResponse)
|
||||
def list_external_function_role_mappings_delta(
|
||||
tenant_id: str | None = Query(default=None),
|
||||
since: str | None = None,
|
||||
limit: int = Query(default=500, ge=1, le=1000),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:read", "access:function:read", "access:role:read")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
if since is None:
|
||||
return _full_external_function_role_mappings_delta_response(session, tenant)
|
||||
return _external_function_role_mappings_delta_response(session, tenant, since=since, limit=limit)
|
||||
|
||||
|
||||
@router.post("/external-function-role-mappings", response_model=ExternalFunctionRoleMappingItem, status_code=status.HTTP_201_CREATED)
|
||||
def create_external_function_role_mapping(
|
||||
payload: ExternalFunctionRoleMappingCreateRequest,
|
||||
tenant_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
source_module = payload.source_module.strip() or ORGANIZATIONS_MODULE_ID
|
||||
function_id = payload.function_id.strip()
|
||||
if not function_id:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail="Function ID is required.")
|
||||
_validate_external_function_source(tenant_id=tenant.id, source_module=source_module, function_id=function_id)
|
||||
_validate_external_function_role(session, tenant_id=tenant.id, role_id=payload.role_id)
|
||||
item = ExternalFunctionRoleAssignment(
|
||||
tenant_id=tenant.id,
|
||||
source_module=source_module,
|
||||
function_id=function_id,
|
||||
role_id=payload.role_id,
|
||||
settings=payload.settings,
|
||||
)
|
||||
session.add(item)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This external function role mapping already exists.") from exc
|
||||
_record_access_change(
|
||||
session,
|
||||
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
|
||||
resource_type="access_external_function_role_mapping",
|
||||
resource_id=item.id,
|
||||
operation="created",
|
||||
tenant_id=tenant.id,
|
||||
principal=principal,
|
||||
payload={"source_module": source_module, "function_id": function_id, "role_id": payload.role_id},
|
||||
)
|
||||
session.commit()
|
||||
return _external_function_role_mapping_item(item)
|
||||
|
||||
|
||||
@router.patch("/external-function-role-mappings/{mapping_id}", response_model=ExternalFunctionRoleMappingItem)
|
||||
def update_external_function_role_mapping(
|
||||
mapping_id: str,
|
||||
payload: ExternalFunctionRoleMappingUpdateRequest,
|
||||
tenant_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
item = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.id == mapping_id, ExternalFunctionRoleAssignment.tenant_id == tenant.id).one_or_none()
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="External function role mapping not found")
|
||||
_validate_external_function_source(tenant_id=tenant.id, source_module=item.source_module, function_id=item.function_id)
|
||||
if payload.role_id is not None:
|
||||
_validate_external_function_role(session, tenant_id=tenant.id, role_id=payload.role_id)
|
||||
item.role_id = payload.role_id
|
||||
if payload.settings is not None:
|
||||
item.settings = payload.settings
|
||||
session.add(item)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="This external function role mapping already exists.") from exc
|
||||
_record_access_change(
|
||||
session,
|
||||
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
|
||||
resource_type="access_external_function_role_mapping",
|
||||
resource_id=item.id,
|
||||
operation="updated",
|
||||
tenant_id=tenant.id,
|
||||
principal=principal,
|
||||
payload={"source_module": item.source_module, "function_id": item.function_id, "role_id": item.role_id},
|
||||
)
|
||||
session.commit()
|
||||
return _external_function_role_mapping_item(item)
|
||||
|
||||
|
||||
@router.delete("/external-function-role-mappings/{mapping_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_external_function_role_mapping(
|
||||
mapping_id: str,
|
||||
tenant_id: str | None = None,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope("admin:roles:write", "access:function:write", "access:role:assign")),
|
||||
):
|
||||
tenant = _resolve_tenant(session, principal, tenant_id)
|
||||
item = session.query(ExternalFunctionRoleAssignment).filter(ExternalFunctionRoleAssignment.id == mapping_id, ExternalFunctionRoleAssignment.tenant_id == tenant.id).one_or_none()
|
||||
if item is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="External function role mapping not found")
|
||||
session.delete(item)
|
||||
_record_access_change(
|
||||
session,
|
||||
collection=ACCESS_EXTERNAL_FUNCTION_ROLE_MAPPINGS_COLLECTION,
|
||||
resource_type="access_external_function_role_mapping",
|
||||
resource_id=mapping_id,
|
||||
operation="deleted",
|
||||
tenant_id=tenant.id,
|
||||
principal=principal,
|
||||
payload={"source_module": item.source_module, "function_id": item.function_id, "role_id": item.role_id},
|
||||
)
|
||||
session.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/function-assignments", response_model=FunctionAssignmentListResponse)
|
||||
def list_function_assignments(
|
||||
tenant_id: str | None = None,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
@@ -14,12 +13,13 @@ from govoplan_core.core.access import (
|
||||
PrincipalRef,
|
||||
PrincipalResolver,
|
||||
)
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.core.modules import AccessDecision
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Tenant, User
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
|
||||
from govoplan_access.backend.semantic import collect_external_function_roles, collect_function_assignment_ids, collect_function_delegation_ids, identity_id_for_account
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
@@ -29,99 +29,10 @@ from govoplan_access.backend.security.sessions import (
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import intersect_api_key_scopes
|
||||
from govoplan_core.security.permissions import expand_scopes, intersect_api_key_scopes
|
||||
from govoplan_core.settings import settings
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ApiPrincipal:
|
||||
"""Compatibility wrapper around the platform Principal DTO.
|
||||
|
||||
Existing routers still expect ORM ``account`` and ``user`` objects. New
|
||||
platform/module code should use the stable ID fields or
|
||||
``to_platform_principal()`` instead.
|
||||
"""
|
||||
|
||||
principal: PrincipalRef
|
||||
account: Account
|
||||
user: User
|
||||
api_key: ApiKey | None = None
|
||||
auth_session: AuthSession | None = None
|
||||
permission_evaluator: PermissionEvaluator | None = None
|
||||
|
||||
@property
|
||||
def account_id(self) -> str:
|
||||
return self.principal.account_id
|
||||
|
||||
@property
|
||||
def membership_id(self) -> str | None:
|
||||
return self.principal.membership_id
|
||||
|
||||
@property
|
||||
def tenant_id(self) -> str:
|
||||
if self.principal.tenant_id is None:
|
||||
raise RuntimeError("Tenant principal has no active tenant id.")
|
||||
return self.principal.tenant_id
|
||||
|
||||
@property
|
||||
def scopes(self) -> frozenset[str]:
|
||||
return self.principal.scopes
|
||||
|
||||
@property
|
||||
def group_ids(self) -> frozenset[str]:
|
||||
return self.principal.group_ids
|
||||
|
||||
@property
|
||||
def role_ids(self) -> frozenset[str]:
|
||||
return self.principal.role_ids
|
||||
|
||||
@property
|
||||
def function_assignment_ids(self) -> frozenset[str]:
|
||||
return self.principal.function_assignment_ids
|
||||
|
||||
@property
|
||||
def delegation_ids(self) -> frozenset[str]:
|
||||
return self.principal.delegation_ids
|
||||
|
||||
@property
|
||||
def identity_id(self) -> str | None:
|
||||
return self.principal.identity_id
|
||||
|
||||
@property
|
||||
def acting_for_account_id(self) -> str | None:
|
||||
return self.principal.acting_for_account_id
|
||||
|
||||
@property
|
||||
def auth_method(self) -> str:
|
||||
return self.principal.auth_method
|
||||
|
||||
@property
|
||||
def api_key_id(self) -> str | None:
|
||||
return self.principal.api_key_id
|
||||
|
||||
@property
|
||||
def session_id(self) -> str | None:
|
||||
return self.principal.session_id
|
||||
|
||||
@property
|
||||
def email(self) -> str | None:
|
||||
return self.principal.email
|
||||
|
||||
@property
|
||||
def display_name(self) -> str | None:
|
||||
return self.principal.display_name
|
||||
|
||||
def has(self, required_scope: str) -> bool:
|
||||
if self.permission_evaluator is not None:
|
||||
return self.permission_evaluator.has_scope(self.principal, required_scope)
|
||||
# Keep legacy scope aliases alive while current routers still use the
|
||||
# pre-platform permission catalogue.
|
||||
return scopes_grant_compatible(self.scopes, required_scope)
|
||||
|
||||
def to_platform_principal(self) -> PrincipalRef:
|
||||
return self.principal
|
||||
|
||||
|
||||
def _extract_token(request: Request, authorization: str | None, x_api_key: str | None) -> tuple[str | None, str]:
|
||||
if x_api_key:
|
||||
return x_api_key.strip(), "api_key"
|
||||
@@ -151,7 +62,14 @@ def _build_principal_ref(
|
||||
auth_method: str,
|
||||
api_key: ApiKey | None = None,
|
||||
auth_session: AuthSession | None = None,
|
||||
idm_assignments: tuple[OrganizationFunctionAssignmentRef, ...] = (),
|
||||
extra_roles: tuple[Role, ...] = (),
|
||||
) -> PrincipalRef:
|
||||
function_assignment_ids = list(collect_function_assignment_ids(session, user))
|
||||
function_assignment_ids.extend(item.id for item in idm_assignments)
|
||||
roles = collect_user_roles(session, user)
|
||||
role_ids = [role.id for role in roles]
|
||||
role_ids.extend(role.id for role in extra_roles)
|
||||
return PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
@@ -159,8 +77,8 @@ def _build_principal_ref(
|
||||
identity_id=identity_id_for_account(session, account.id),
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=_principal_group_ids(session, user),
|
||||
role_ids=frozenset(role.id for role in collect_user_roles(session, user)),
|
||||
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)),
|
||||
role_ids=frozenset(sorted(dict.fromkeys(role_ids))),
|
||||
function_assignment_ids=frozenset(sorted(dict.fromkeys(function_assignment_ids))),
|
||||
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
|
||||
auth_method=auth_method, # type: ignore[arg-type]
|
||||
api_key_id=api_key.id if api_key else None,
|
||||
@@ -209,6 +127,7 @@ def _resolve_legacy_principal_ref(
|
||||
*,
|
||||
authorization: str | None,
|
||||
x_api_key: str | None,
|
||||
idm_directory: IdmDirectory | None = None,
|
||||
) -> PrincipalRef:
|
||||
token, source = _extract_token(request, authorization, x_api_key)
|
||||
if not token:
|
||||
@@ -227,7 +146,9 @@ def _resolve_legacy_principal_ref(
|
||||
or user.tenant_id != api_key.tenant_id
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive or inconsistent API-key principal")
|
||||
user_scopes = collect_user_scopes(session, user, include_system=False)
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=api_key.tenant_id)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments))
|
||||
user_scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=False), idm_roles)
|
||||
effective_scopes = intersect_api_key_scopes(user_scopes, api_key.scopes or [])
|
||||
session.commit()
|
||||
return _build_principal_ref(
|
||||
@@ -238,6 +159,8 @@ def _resolve_legacy_principal_ref(
|
||||
tenant_id=api_key.tenant_id,
|
||||
scopes=effective_scopes,
|
||||
auth_method="api_key",
|
||||
idm_assignments=idm_assignments,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
|
||||
auth_session = authenticate_session_token(session, token)
|
||||
@@ -257,7 +180,9 @@ def _resolve_legacy_principal_ref(
|
||||
cookie_token = request.cookies.get(settings.auth_csrf_cookie_name)
|
||||
if not header_token or not cookie_token or header_token != cookie_token or not verify_auth_session_csrf(auth_session, header_token):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid or missing CSRF token")
|
||||
scopes = collect_user_scopes(session, user, include_system=True)
|
||||
idm_assignments = _idm_assignments_for_account(idm_directory, account.id, tenant_id=user.tenant_id)
|
||||
idm_roles = tuple(collect_external_function_roles(session, user, idm_assignments))
|
||||
scopes = _scopes_with_extra_roles(collect_user_scopes(session, user, include_system=True), idm_roles)
|
||||
session.commit()
|
||||
return _build_principal_ref(
|
||||
session,
|
||||
@@ -267,11 +192,31 @@ def _resolve_legacy_principal_ref(
|
||||
tenant_id=user.tenant_id,
|
||||
scopes=scopes,
|
||||
auth_method="session",
|
||||
idm_assignments=idm_assignments,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key or session token")
|
||||
|
||||
|
||||
def _idm_assignments_for_account(
|
||||
idm_directory: IdmDirectory | None,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
|
||||
if idm_directory is None:
|
||||
return ()
|
||||
return tuple(idm_directory.organization_function_assignments_for_account(account_id, tenant_id=tenant_id))
|
||||
|
||||
|
||||
def _scopes_with_extra_roles(scopes: list[str], roles: tuple[Role, ...]) -> list[str]:
|
||||
merged = set(scopes)
|
||||
for role in roles:
|
||||
merged.update(role.permissions or [])
|
||||
return expand_scopes(merged)
|
||||
|
||||
|
||||
def _registry_from_request(request: Request) -> PlatformRegistry | None:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
return registry if isinstance(registry, PlatformRegistry) else None
|
||||
@@ -312,15 +257,30 @@ def _permission_evaluator_from_request(request: Request) -> PermissionEvaluator
|
||||
|
||||
|
||||
class LegacyPrincipalResolver:
|
||||
def __init__(self, idm_directory: IdmDirectory | None = None) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
|
||||
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
|
||||
if not isinstance(request, Request):
|
||||
raise TypeError("LegacyPrincipalResolver requires a FastAPI request")
|
||||
authorization = request.headers.get("authorization")
|
||||
x_api_key = request.headers.get("x-api-key")
|
||||
if isinstance(session, Session):
|
||||
return _resolve_legacy_principal_ref(request, session, authorization=authorization, x_api_key=x_api_key)
|
||||
return _resolve_legacy_principal_ref(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=self._idm_directory,
|
||||
)
|
||||
with get_database().session() as managed_session:
|
||||
return _resolve_legacy_principal_ref(request, managed_session, authorization=authorization, x_api_key=x_api_key)
|
||||
return _resolve_legacy_principal_ref(
|
||||
request,
|
||||
managed_session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
idm_directory=self._idm_directory,
|
||||
)
|
||||
|
||||
|
||||
class LegacyPermissionEvaluator:
|
||||
@@ -339,11 +299,12 @@ class LegacyPermissionEvaluator:
|
||||
)
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
def resolve_api_principal(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
session: Session,
|
||||
*,
|
||||
authorization: str | None = None,
|
||||
x_api_key: str | None = None,
|
||||
) -> ApiPrincipal:
|
||||
resolver = _principal_resolver_from_request(request)
|
||||
permission_evaluator = _permission_evaluator_from_request(request)
|
||||
@@ -364,6 +325,41 @@ def get_api_principal(
|
||||
return api_principal
|
||||
|
||||
|
||||
class AccessApiPrincipalProvider:
|
||||
def resolve_api_principal(
|
||||
self,
|
||||
request: object,
|
||||
session: object,
|
||||
*,
|
||||
authorization: str | None = None,
|
||||
x_api_key: str | None = None,
|
||||
) -> ApiPrincipal:
|
||||
if not isinstance(request, Request):
|
||||
raise TypeError("AccessApiPrincipalProvider requires a FastAPI request")
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("AccessApiPrincipalProvider requires a SQLAlchemy session")
|
||||
return resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
request: Request,
|
||||
session: Session = Depends(get_session),
|
||||
authorization: str | None = Header(default=None),
|
||||
x_api_key: str | None = Header(default=None, alias="X-API-Key"),
|
||||
) -> ApiPrincipal:
|
||||
return resolve_api_principal(
|
||||
request,
|
||||
session,
|
||||
authorization=authorization,
|
||||
x_api_key=x_api_key,
|
||||
)
|
||||
|
||||
|
||||
def _enforce_maintenance_mode(session: Session, principal: ApiPrincipal) -> None:
|
||||
mode = saved_maintenance_mode(session)
|
||||
if not mode.enabled or principal.has(MAINTENANCE_ACCESS_SCOPE):
|
||||
|
||||
26
src/govoplan_access/backend/auth/tenant_context.py
Normal file
26
src/govoplan_access/backend/auth/tenant_context.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.core.access import TenantContextSwitchRef
|
||||
|
||||
from govoplan_access.backend.db.models import AuthSession
|
||||
from govoplan_access.backend.security.sessions import switch_auth_session_tenant
|
||||
|
||||
|
||||
class AccessTenantContextSwitcher:
|
||||
def switch_tenant_context(self, session: object, *, principal: object, tenant_id: str) -> TenantContextSwitchRef:
|
||||
if not isinstance(session, Session):
|
||||
raise TypeError("AccessTenantContextSwitcher requires a SQLAlchemy session")
|
||||
if not isinstance(principal, ApiPrincipal):
|
||||
raise TypeError("AccessTenantContextSwitcher requires an API principal")
|
||||
if not isinstance(principal.auth_session, AuthSession):
|
||||
raise ValueError("API keys cannot switch tenant context")
|
||||
membership = switch_auth_session_tenant(session, principal.auth_session, tenant_id)
|
||||
return TenantContextSwitchRef(
|
||||
account_id=principal.account_id,
|
||||
membership_id=membership.id,
|
||||
tenant_id=membership.tenant_id,
|
||||
session_id=principal.session_id,
|
||||
)
|
||||
@@ -191,6 +191,26 @@ class FunctionRoleAssignment(AccessBase, TimestampMixin):
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class ExternalFunctionRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_external_function_role_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"source_module",
|
||||
"function_id",
|
||||
"role_id",
|
||||
name="uq_external_function_role_assignments",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
source_module: Mapped[str] = mapped_column(String(50), default="organizations", nullable=False, index=True)
|
||||
function_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_assignments"
|
||||
__table_args__ = (
|
||||
@@ -331,6 +351,7 @@ __all__ = [
|
||||
"FunctionAssignment",
|
||||
"FunctionDelegation",
|
||||
"FunctionRoleAssignment",
|
||||
"ExternalFunctionRoleAssignment",
|
||||
"Group",
|
||||
"GroupRoleAssignment",
|
||||
"Identity",
|
||||
|
||||
@@ -13,6 +13,7 @@ from govoplan_core.core.access import (
|
||||
OrganizationUnitRef,
|
||||
UserRef,
|
||||
)
|
||||
from govoplan_core.core.idm import IdmDirectory, OrganizationFunctionAssignmentRef as IdmFunctionAssignmentRef
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
@@ -114,7 +115,28 @@ def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
|
||||
)
|
||||
|
||||
|
||||
def _idm_function_assignment_ref(item: IdmFunctionAssignmentRef, *, account_id: str) -> FunctionAssignmentRef:
|
||||
return FunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.account_id or account_id,
|
||||
identity_id=item.identity_id,
|
||||
function_id=item.function_id,
|
||||
organization_unit_id=item.organization_unit_id,
|
||||
applies_to_subunits=item.applies_to_subunits,
|
||||
source=item.source, # type: ignore[arg-type]
|
||||
delegated_from_assignment_id=item.delegated_from_assignment_id,
|
||||
acting_for_account_id=item.acting_for_account_id,
|
||||
valid_from=item.valid_from,
|
||||
valid_until=item.valid_until,
|
||||
status=item.status, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessDirectory(AccessSemanticDirectory):
|
||||
def __init__(self, idm_directory: IdmDirectory | None = None) -> None:
|
||||
self._idm_directory = idm_directory
|
||||
|
||||
def get_account(self, account_id: str) -> AccountRef | None:
|
||||
with get_database().session() as session:
|
||||
account = session.get(Account, account_id)
|
||||
@@ -312,4 +334,14 @@ class SqlAccessDirectory(AccessSemanticDirectory):
|
||||
) -> tuple[FunctionAssignmentRef, ...]:
|
||||
with get_database().session() as session:
|
||||
items = active_function_assignments_for_account(session, account_id, tenant_id=tenant_id)
|
||||
return tuple(_function_assignment_ref(item) for item in items)
|
||||
assignments = [_function_assignment_ref(item) for item in items]
|
||||
if self._idm_directory is not None:
|
||||
assignments.extend(
|
||||
_idm_function_assignment_ref(item, account_id=account_id)
|
||||
for item in self._idm_directory.organization_function_assignments_for_account(
|
||||
account_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
)
|
||||
deduped = {item.id: item for item in assignments}
|
||||
return tuple(deduped.values())
|
||||
|
||||
@@ -13,9 +13,12 @@ from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
)
|
||||
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, IdmDirectory
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
@@ -318,14 +321,51 @@ ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.external-function-role-mappings",
|
||||
title="Function facts and access roles",
|
||||
summary="Access maps accepted organization function facts to roles and permissions. IDM assignment ownership remains separate from authorization.",
|
||||
body=(
|
||||
"When IDM is installed, Access can consume identity-to-organization-function assignments through the IDM directory capability. "
|
||||
"Organizations must be installed because Access validates mappings against the Organizations function directory instead of accepting arbitrary function identifiers. "
|
||||
"An assignment does not grant rights by itself. Access grants rights only when an explicit external function role mapping connects the organization function ID to an assignable tenant role. "
|
||||
"Tenant administrators manage these mappings under Admin > Function role mappings, and the same records are exposed through the admin API. "
|
||||
"This keeps the audit trail clear: IDM records who holds a function, while Access records which function facts produce role-derived permissions."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=32,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access", "organizations"),
|
||||
any_scopes=("access:function:read", "access:role:read", "admin:roles:read"),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Function role mappings", href="/admin?section=tenant-function-role-mappings", kind="runtime"),
|
||||
DocumentationLink(label="External function role mappings API", href="/api/v1/admin/external-function-role-mappings", kind="api"),
|
||||
DocumentationLink(label="IDM assignments", href="/idm", kind="runtime"),
|
||||
),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/admin",
|
||||
"api_path": "/api/v1/admin/external-function-role-mappings",
|
||||
"permission_scopes": ["access:function:write", "access:role:assign"],
|
||||
"related_topic_ids": [
|
||||
"idm.workflow.assign-function-to-identity",
|
||||
"docs.reference.organization-identity-idm-access-boundary",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _legacy_principal_resolver(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.auth.dependencies import LegacyPrincipalResolver
|
||||
|
||||
return LegacyPrincipalResolver()
|
||||
idm_directory = _optional_idm_directory(context)
|
||||
return LegacyPrincipalResolver(idm_directory=idm_directory)
|
||||
|
||||
|
||||
def _legacy_permission_evaluator(context: ModuleContext) -> object:
|
||||
@@ -335,18 +375,39 @@ def _legacy_permission_evaluator(context: ModuleContext) -> object:
|
||||
return LegacyPermissionEvaluator()
|
||||
|
||||
|
||||
def _access_directory(context: ModuleContext) -> object:
|
||||
def _api_principal_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.auth.dependencies import AccessApiPrincipalProvider
|
||||
|
||||
return AccessApiPrincipalProvider()
|
||||
|
||||
|
||||
def _tenant_context_switcher(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.auth.tenant_context import AccessTenantContextSwitcher
|
||||
|
||||
return AccessTenantContextSwitcher()
|
||||
|
||||
|
||||
def _access_directory(context: ModuleContext) -> object:
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
|
||||
return SqlAccessDirectory()
|
||||
return SqlAccessDirectory(idm_directory=_optional_idm_directory(context))
|
||||
|
||||
|
||||
def _access_semantic_directory(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
|
||||
return SqlAccessDirectory()
|
||||
return SqlAccessDirectory(idm_directory=_optional_idm_directory(context))
|
||||
|
||||
|
||||
def _optional_idm_directory(context: ModuleContext) -> IdmDirectory | None:
|
||||
if not context.registry.has_capability(CAPABILITY_IDM_DIRECTORY):
|
||||
return None
|
||||
capability = context.registry.require_capability(CAPABILITY_IDM_DIRECTORY)
|
||||
if not isinstance(capability, IdmDirectory):
|
||||
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}")
|
||||
return capability
|
||||
|
||||
|
||||
def _access_explanation_service(context: ModuleContext) -> object:
|
||||
@@ -404,7 +465,7 @@ manifest = ModuleManifest(
|
||||
id="access",
|
||||
name="Access",
|
||||
version="0.1.6",
|
||||
optional_dependencies=("identity", "organizations", "tenancy"),
|
||||
optional_dependencies=("identity", "organizations", "tenancy", "idm"),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
@@ -424,6 +485,7 @@ manifest = ModuleManifest(
|
||||
access_models.OrganizationUnit,
|
||||
access_models.Function,
|
||||
access_models.FunctionRoleAssignment,
|
||||
access_models.ExternalFunctionRoleAssignment,
|
||||
access_models.FunctionAssignment,
|
||||
access_models.FunctionDelegation,
|
||||
access_models.SystemRoleAssignment,
|
||||
@@ -443,8 +505,10 @@ manifest = ModuleManifest(
|
||||
nav_items=(NavItem(path="/admin", label="Admin", icon="admin", required_any=ADMIN_READ_SCOPES, order=900),),
|
||||
),
|
||||
capability_factories={
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER: _api_principal_provider,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER: _tenant_context_switcher,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
|
||||
CAPABILITY_ACCESS_DIRECTORY: _access_directory,
|
||||
|
||||
@@ -204,6 +204,38 @@ def upgrade() -> None:
|
||||
_create_index_if_missing(op.f("ix_access_function_role_assignments_role_id"), "access_function_role_assignments", ["role_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_role_assignments_tenant_id"), "access_function_role_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_external_function_role_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_external_function_role_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("source_module", sa.String(length=50), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("settings", sa.JSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_external_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
[scope_fk_target],
|
||||
name=op.f("fk_access_external_function_role_assignments_tenant_id_scopes"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_external_function_role_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "source_module", "function_id", "role_id", name="uq_external_function_role_assignments"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_function_id"), "access_external_function_role_assignments", ["function_id"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_role_id"), "access_external_function_role_assignments", ["role_id"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_source_module"), "access_external_function_role_assignments", ["source_module"])
|
||||
_create_index_if_missing(op.f("ix_access_external_function_role_assignments_tenant_id"), "access_external_function_role_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_assignments" not in tables:
|
||||
op.create_table(
|
||||
@@ -353,6 +385,15 @@ def downgrade() -> None:
|
||||
op.f("ix_access_function_assignments_account_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_external_function_role_assignments",
|
||||
(
|
||||
op.f("ix_access_external_function_role_assignments_tenant_id"),
|
||||
op.f("ix_access_external_function_role_assignments_source_module"),
|
||||
op.f("ix_access_external_function_role_assignments_role_id"),
|
||||
op.f("ix_access_external_function_role_assignments_function_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_function_role_assignments",
|
||||
(
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ExternalFunctionRoleAssignment,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
@@ -16,6 +17,7 @@ from govoplan_access.backend.db.models import (
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
@@ -154,3 +156,31 @@ def collect_function_roles(session: Session, user: User) -> list[Role]:
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def collect_external_function_roles(
|
||||
session: Session,
|
||||
user: User,
|
||||
assignments: Iterable[OrganizationFunctionAssignmentRef],
|
||||
*,
|
||||
source_module: str = "organizations",
|
||||
) -> list[Role]:
|
||||
function_ids = sorted({
|
||||
assignment.function_id
|
||||
for assignment in assignments
|
||||
if assignment.tenant_id == user.tenant_id and assignment.status == "active"
|
||||
})
|
||||
if not function_ids:
|
||||
return []
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(ExternalFunctionRoleAssignment, ExternalFunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
ExternalFunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
ExternalFunctionRoleAssignment.source_module == source_module,
|
||||
ExternalFunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles
|
||||
from govoplan_access.backend.db.models import Account, User, UserRoleAssignment
|
||||
from govoplan_access.backend.admin.service import ensure_default_roles, get_or_create_account
|
||||
from govoplan_access.backend.db.models import Account, SystemRoleAssignment, User, UserRoleAssignment
|
||||
from govoplan_access.backend.security.api_keys import create_api_key
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.admin.common import AdminValidationError
|
||||
from govoplan_core.core.access import TenantAccessProvisioner, TenantOwnerCandidateRef
|
||||
from govoplan_core.core.access import CreatedApiKeyRef, DevelopmentBootstrapRef, TenantAccessProvisioner, TenantOwnerCandidateRef, UserRef
|
||||
|
||||
|
||||
class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
||||
@@ -75,6 +77,97 @@ class LegacyTenantAccessProvisioner(TenantAccessProvisioner):
|
||||
db.flush()
|
||||
return membership.id
|
||||
|
||||
def ensure_development_admin(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
user_email: str,
|
||||
user_password: str,
|
||||
api_key_secret: str | None,
|
||||
scopes: Sequence[str],
|
||||
) -> DevelopmentBootstrapRef:
|
||||
db = _session(session)
|
||||
tenant_id = getattr(tenant, "id", None)
|
||||
if not tenant_id:
|
||||
raise AdminValidationError("Development bootstrap requires a persisted tenant.")
|
||||
|
||||
tenant_roles = ensure_default_roles(db, tenant) # type: ignore[arg-type]
|
||||
system_roles = ensure_default_roles(db, None)
|
||||
account, _, _ = get_or_create_account(
|
||||
db,
|
||||
email=user_email,
|
||||
display_name="Development Admin",
|
||||
password=user_password,
|
||||
password_reset_required=False,
|
||||
)
|
||||
if not account.password_hash:
|
||||
account.password_hash = hash_password(user_password)
|
||||
account.is_active = True
|
||||
db.add(account)
|
||||
|
||||
user = db.query(User).filter(User.tenant_id == tenant_id, User.account_id == account.id).one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
email=account.email,
|
||||
display_name="Development Admin",
|
||||
is_tenant_admin=True,
|
||||
auth_provider=account.auth_provider,
|
||||
password_hash=account.password_hash,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
else:
|
||||
user.email = account.email
|
||||
user.password_hash = account.password_hash
|
||||
user.is_active = True
|
||||
db.add(user)
|
||||
|
||||
owner_role = tenant_roles["owner"]
|
||||
existing_assignment = db.query(UserRoleAssignment).filter(
|
||||
UserRoleAssignment.tenant_id == tenant_id,
|
||||
UserRoleAssignment.user_id == user.id,
|
||||
UserRoleAssignment.role_id == owner_role.id,
|
||||
).one_or_none()
|
||||
if existing_assignment is None:
|
||||
db.add(UserRoleAssignment(tenant_id=tenant_id, user_id=user.id, role_id=owner_role.id))
|
||||
|
||||
system_owner = system_roles["system_owner"]
|
||||
existing_system_assignment = db.query(SystemRoleAssignment).filter(
|
||||
SystemRoleAssignment.account_id == account.id,
|
||||
SystemRoleAssignment.role_id == system_owner.id,
|
||||
).one_or_none()
|
||||
if existing_system_assignment is None:
|
||||
db.add(SystemRoleAssignment(account_id=account.id, role_id=system_owner.id))
|
||||
|
||||
created_api_key = None
|
||||
if api_key_secret:
|
||||
existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None]
|
||||
if not existing:
|
||||
api_key = create_api_key(
|
||||
db,
|
||||
user=user,
|
||||
name="Development API key",
|
||||
scopes=list(scopes),
|
||||
secret=api_key_secret,
|
||||
)
|
||||
created_api_key = CreatedApiKeyRef(id=api_key.model.id, secret=api_key.secret)
|
||||
|
||||
db.flush()
|
||||
return DevelopmentBootstrapRef(
|
||||
user=UserRef(
|
||||
id=user.id,
|
||||
account_id=account.id,
|
||||
tenant_id=tenant_id,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
status="active" if user.is_active else "inactive",
|
||||
),
|
||||
created_api_key=created_api_key,
|
||||
)
|
||||
|
||||
|
||||
def _session(session: object) -> Session:
|
||||
if not isinstance(session, Session):
|
||||
|
||||
Reference in New Issue
Block a user