feat: add access module boundary migrations
This commit is contained in:
@@ -20,6 +20,7 @@ from govoplan_access.backend.db.models import (
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import ensure_identity_for_account
|
||||
from govoplan_access.backend.security.passwords import hash_password
|
||||
from govoplan_core.security.permissions import (
|
||||
DEFAULT_SYSTEM_ROLES,
|
||||
@@ -98,6 +99,7 @@ def get_or_create_account(
|
||||
raise AdminConflictError(
|
||||
"The global account is disabled. A system administrator must reactivate it before adding a tenant membership."
|
||||
)
|
||||
ensure_identity_for_account(session, account)
|
||||
return account, False, None
|
||||
|
||||
temporary_password = password or generate_temporary_password()
|
||||
@@ -112,6 +114,7 @@ def get_or_create_account(
|
||||
)
|
||||
session.add(account)
|
||||
session.flush()
|
||||
ensure_identity_for_account(session, account)
|
||||
return account, True, temporary_password
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from govoplan_access.backend.security.sessions import (
|
||||
collect_user_groups,
|
||||
collect_user_scopes,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_function_assignment_ids, collect_function_delegation_ids
|
||||
from govoplan_access.backend.auth.dependencies import ApiPrincipal, has_scope
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
@@ -44,7 +45,7 @@ def _http_admin_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, AdminConflictError):
|
||||
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
|
||||
if isinstance(exc, AdminValidationError):
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc))
|
||||
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
|
||||
|
||||
@@ -150,6 +151,8 @@ def _user_item(session: Session, user: User, *, owner_ids: set[str] | None = Non
|
||||
last_login_at=account.last_login_at,
|
||||
groups=[_group_summary(session, group, include_members=False) for group in groups],
|
||||
roles=[_role_summary(session, role) for role in roles],
|
||||
function_assignment_ids=collect_function_assignment_ids(session, user),
|
||||
function_delegation_ids=collect_function_delegation_ids(session, user),
|
||||
effective_scopes=collect_user_scopes(session, user, include_system=False),
|
||||
is_owner=user.id in effective_owner_ids,
|
||||
is_last_active_owner=user.id in effective_owner_ids and len(effective_owner_ids) == 1,
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from govoplan_core.api.v1.schemas import DeltaDeletedItem
|
||||
|
||||
RETENTION_DAY_KEYS = (
|
||||
"raw_campaign_json_retention_days",
|
||||
"generated_eml_retention_days",
|
||||
@@ -128,6 +130,9 @@ class TenantSettingsItem(BaseModel):
|
||||
slug: str
|
||||
name: str
|
||||
default_locale: str = Field(default="en", min_length=1, max_length=20)
|
||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
system_enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -135,6 +140,7 @@ class TenantSettingsUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
default_locale: str = Field(min_length=1, max_length=20)
|
||||
enabled_language_codes: list[str] | None = None
|
||||
|
||||
|
||||
class RoleSummary(BaseModel):
|
||||
@@ -153,6 +159,227 @@ class RoleSummary(BaseModel):
|
||||
system_required: bool = False
|
||||
|
||||
|
||||
class IdentityAccountLinkItem(BaseModel):
|
||||
id: str
|
||||
account_id: str
|
||||
email: str | None = None
|
||||
display_name: str | None = None
|
||||
is_primary: bool = False
|
||||
source: str = "local"
|
||||
|
||||
|
||||
class IdentityAdminItem(BaseModel):
|
||||
id: str
|
||||
display_name: str | None = None
|
||||
external_subject: str | None = None
|
||||
source: str = "local"
|
||||
is_active: bool = True
|
||||
accounts: list[IdentityAccountLinkItem] = Field(default_factory=list)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityListResponse(BaseModel):
|
||||
identities: list[IdentityAdminItem]
|
||||
|
||||
|
||||
class IdentityCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
external_subject: str | None = Field(default=None, max_length=255)
|
||||
source: str = Field(default="local", max_length=50)
|
||||
account_ids: list[str] = Field(default_factory=list)
|
||||
primary_account_id: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class IdentityUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
external_subject: str | None = Field(default=None, max_length=255)
|
||||
source: str | None = Field(default=None, max_length=50)
|
||||
account_ids: list[str] | None = None
|
||||
primary_account_id: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class OrganizationUnitItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
parent_id: str | None = None
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OrganizationUnitListResponse(BaseModel):
|
||||
organization_units: list[OrganizationUnitItem]
|
||||
|
||||
|
||||
class OrganizationUnitCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class OrganizationUnitUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
slug: str | None = None
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
parent_id: str | None = None
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
organization_unit_id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionListResponse(BaseModel):
|
||||
functions: list[FunctionAdminItem]
|
||||
|
||||
|
||||
class FunctionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
organization_unit_id: str
|
||||
slug: str
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] = Field(default_factory=list)
|
||||
delegable: bool = False
|
||||
act_in_place_allowed: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
organization_unit_id: str | None = None
|
||||
slug: str | None = None
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
role_ids: list[str] | None = None
|
||||
delegable: bool | None = None
|
||||
act_in_place_allowed: bool | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionAssignmentAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
account_id: str
|
||||
identity_id: str | None = None
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
applies_to_subunits: bool = False
|
||||
source: str = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionAssignmentListResponse(BaseModel):
|
||||
assignments: list[FunctionAssignmentAdminItem]
|
||||
|
||||
|
||||
class FunctionAssignmentCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str | None = None
|
||||
identity_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: str = Field(default="direct", max_length=50)
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionAssignmentUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
identity_id: str | None = None
|
||||
organization_unit_id: str | None = None
|
||||
applies_to_subunits: bool | None = None
|
||||
source: str | None = Field(default=None, max_length=50)
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FunctionDelegationAdminItem(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
function_assignment_id: str
|
||||
delegator_account_id: str
|
||||
delegate_account_id: str
|
||||
mode: Literal["delegate", "act_in_place"] = "delegate"
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
is_active: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FunctionDelegationListResponse(BaseModel):
|
||||
delegations: list[FunctionDelegationAdminItem]
|
||||
|
||||
|
||||
class FunctionDelegationCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
function_assignment_id: str
|
||||
delegate_account_id: str
|
||||
mode: Literal["delegate", "act_in_place"] = "delegate"
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class FunctionDelegationUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
is_active: bool | None = None
|
||||
revoked: bool | None = None
|
||||
|
||||
|
||||
class GroupSummary(BaseModel):
|
||||
id: str
|
||||
slug: str = Field(min_length=1, max_length=100)
|
||||
@@ -180,6 +407,8 @@ class UserAdminItem(BaseModel):
|
||||
last_login_at: datetime | None = None
|
||||
groups: list[GroupSummary] = Field(default_factory=list)
|
||||
roles: list[RoleSummary] = Field(default_factory=list)
|
||||
function_assignment_ids: list[str] = Field(default_factory=list)
|
||||
function_delegation_ids: list[str] = Field(default_factory=list)
|
||||
effective_scopes: list[str] = Field(default_factory=list)
|
||||
is_owner: bool = False
|
||||
is_last_active_owner: bool = False
|
||||
@@ -191,6 +420,13 @@ class UserListResponse(BaseModel):
|
||||
users: list[UserAdminItem]
|
||||
|
||||
|
||||
class UserListDeltaResponse(UserListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -222,6 +458,13 @@ class GroupListResponse(BaseModel):
|
||||
groups: list[GroupSummary]
|
||||
|
||||
|
||||
class GroupListDeltaResponse(GroupListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class GroupCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -247,6 +490,13 @@ class RoleListResponse(BaseModel):
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
class RoleListDeltaResponse(RoleListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class RoleCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -280,6 +530,13 @@ class SystemAccountListResponse(BaseModel):
|
||||
roles: list[RoleSummary]
|
||||
|
||||
|
||||
class SystemAccountListDeltaResponse(SystemAccountListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class SystemAccountUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -396,12 +653,124 @@ class RetentionRunResponse(BaseModel):
|
||||
result: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationSafetyFieldItem(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
owner_module: str
|
||||
scope: Literal["system", "tenant", "user", "group", "campaign"]
|
||||
storage: str
|
||||
ui_managed: bool
|
||||
risk: Literal["low", "medium", "high", "destructive"]
|
||||
secret_handling: Literal["none", "reference_only", "env_only"] = "none"
|
||||
required_scopes: list[str] = Field(default_factory=list)
|
||||
dry_run_required: bool = False
|
||||
validation_required: bool = True
|
||||
policy_explanation_required: bool = False
|
||||
audit_event: str | None = None
|
||||
maintenance_required: bool = False
|
||||
two_person_approval_required: bool = False
|
||||
rollback_history_required: bool = False
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ConfigurationSafetyCatalogResponse(BaseModel):
|
||||
fields: list[ConfigurationSafetyFieldItem]
|
||||
|
||||
|
||||
class ConfigurationChangeSafetyPlanRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
value: Any = None
|
||||
dry_run: bool = False
|
||||
maintenance_mode: bool = False
|
||||
approval_count: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class ConfigurationChangeSafetyPlanResponse(BaseModel):
|
||||
plan: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationChangeRequestCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
key: str
|
||||
value: Any = None
|
||||
dry_run: bool = False
|
||||
target: dict[str, Any] = Field(default_factory=dict)
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ConfigurationChangeRequestApproveRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = Field(default=None, max_length=1000)
|
||||
|
||||
|
||||
class ConfigurationChangeRequestResponse(BaseModel):
|
||||
request: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationControlSnapshotResponse(BaseModel):
|
||||
requests: list[dict[str, Any]] = Field(default_factory=list)
|
||||
history: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationControlDeltaResponse(ConfigurationControlSnapshotResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class ConfigurationPackageCatalogResponse(BaseModel):
|
||||
validation: dict[str, Any]
|
||||
|
||||
|
||||
class ConfigurationPackageRunRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
package: dict[str, Any]
|
||||
tenant_id: str | None = None
|
||||
supplied_data: dict[str, Any] = Field(default_factory=dict)
|
||||
change_request_id: str | None = None
|
||||
|
||||
|
||||
class ConfigurationPackageDryRunResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
required_data: list[dict[str, Any]] = Field(default_factory=list)
|
||||
plan: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationPackageApplyResponse(BaseModel):
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
created_refs: dict[str, str] = Field(default_factory=dict)
|
||||
updated_refs: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ConfigurationPackageExportRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_id: str | None = None
|
||||
scopes: list[str] = Field(default_factory=list)
|
||||
module_ids: list[str] = Field(default_factory=list)
|
||||
object_refs: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ConfigurationPackageExportResponse(BaseModel):
|
||||
fragments: list[dict[str, Any]] = Field(default_factory=list)
|
||||
data_requirements: list[dict[str, Any]] = Field(default_factory=list)
|
||||
diagnostics: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SystemSettingsItem(BaseModel):
|
||||
default_locale: str = "en"
|
||||
allow_tenant_custom_groups: bool = True
|
||||
allow_tenant_custom_roles: bool = True
|
||||
allow_tenant_api_keys: bool = True
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem = Field(default_factory=PrivacyRetentionPolicyItem)
|
||||
available_languages: list[dict[str, Any]] = Field(default_factory=list)
|
||||
enabled_language_codes: list[str] = Field(default_factory=list)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -413,6 +782,8 @@ class SystemSettingsUpdateRequest(BaseModel):
|
||||
allow_tenant_custom_roles: bool
|
||||
allow_tenant_api_keys: bool
|
||||
privacy_retention_policy: PrivacyRetentionPolicyItem | None = None
|
||||
available_languages: list[dict[str, Any]] | None = None
|
||||
enabled_language_codes: list[str] | None = None
|
||||
|
||||
|
||||
class ApiKeyAdminItem(BaseModel):
|
||||
@@ -432,6 +803,13 @@ class ApiKeyListResponse(BaseModel):
|
||||
api_keys: list[ApiKeyAdminItem]
|
||||
|
||||
|
||||
class ApiKeyListDeltaResponse(ApiKeyListResponse):
|
||||
deleted: list[DeltaDeletedItem] = Field(default_factory=list)
|
||||
watermark: str | None = None
|
||||
has_more: bool = False
|
||||
full: bool = False
|
||||
|
||||
|
||||
class AdminApiKeyCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -463,3 +841,5 @@ class AuditAdminListResponse(BaseModel):
|
||||
page: int = 1
|
||||
page_size: int = 100
|
||||
pages: int = 1
|
||||
cursor: str | None = None
|
||||
next_cursor: str | None = None
|
||||
|
||||
@@ -8,21 +8,36 @@ from govoplan_core.api.v1.schemas import (
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
MeResponse,
|
||||
PrincipalContextInfo,
|
||||
ProfileUpdateRequest,
|
||||
RoleInfo,
|
||||
SwitchTenantRequest,
|
||||
TenantInfo,
|
||||
TenantMembershipInfo,
|
||||
UserInfo,
|
||||
UserUiPreferences,
|
||||
)
|
||||
from govoplan_core.core.access import AuthMethod, PrincipalRef
|
||||
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
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
from govoplan_access.backend.db.models import Account, Tenant, User
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_core.i18n import (
|
||||
i18n_settings,
|
||||
normalize_enabled_language_codes,
|
||||
normalize_language_code,
|
||||
preferred_language_code,
|
||||
system_enabled_language_codes,
|
||||
system_i18n_payload,
|
||||
tenant_enabled_language_codes,
|
||||
user_enabled_language_codes,
|
||||
)
|
||||
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.security.passwords import verify_password
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
collect_system_roles,
|
||||
@@ -68,17 +83,32 @@ def _clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(settings.auth_csrf_cookie_name, **kwargs)
|
||||
|
||||
|
||||
def _tenant_info(tenant: Tenant) -> TenantInfo:
|
||||
def _tenant_info(tenant: Tenant, *, enabled_language_codes: list[str] | None = None) -> TenantInfo:
|
||||
return TenantInfo(
|
||||
id=tenant.id,
|
||||
slug=tenant.slug,
|
||||
name=tenant.name,
|
||||
is_active=tenant.is_active,
|
||||
default_locale=tenant.default_locale,
|
||||
enabled_language_codes=enabled_language_codes or [],
|
||||
)
|
||||
|
||||
|
||||
def _user_info(user: User, account: Account) -> UserInfo:
|
||||
def _user_ui_preferences(settings_payload: object) -> UserUiPreferences:
|
||||
raw = settings_payload.get("ui") if isinstance(settings_payload, dict) else None
|
||||
try:
|
||||
return UserUiPreferences.model_validate(raw if isinstance(raw, dict) else {})
|
||||
except ValueError:
|
||||
return UserUiPreferences()
|
||||
|
||||
|
||||
def _user_info(
|
||||
user: User,
|
||||
account: Account,
|
||||
*,
|
||||
preferred_language: str | None = None,
|
||||
enabled_language_codes: list[str] | None = None,
|
||||
) -> UserInfo:
|
||||
return UserInfo(
|
||||
id=user.id,
|
||||
account_id=account.id,
|
||||
@@ -87,6 +117,9 @@ def _user_info(user: User, account: Account) -> UserInfo:
|
||||
tenant_display_name=user.display_name,
|
||||
is_tenant_admin=user.is_tenant_admin,
|
||||
password_reset_required=account.password_reset_required,
|
||||
preferred_language=preferred_language,
|
||||
enabled_language_codes=enabled_language_codes or [],
|
||||
ui_preferences=_user_ui_preferences(user.settings),
|
||||
)
|
||||
|
||||
|
||||
@@ -124,6 +157,25 @@ def _tenant_memberships(session: Session, account: Account) -> list[TenantMember
|
||||
return memberships
|
||||
|
||||
|
||||
def _language_context(session: Session, *, tenant: Tenant, user: User) -> dict[str, object]:
|
||||
system_settings = get_system_settings(session)
|
||||
system_payload = system_i18n_payload(system_settings)
|
||||
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
|
||||
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
|
||||
user_enabled = user_enabled_language_codes(user.settings, tenant_enabled)
|
||||
preferred = preferred_language_code(
|
||||
user.settings,
|
||||
user_enabled,
|
||||
default_locale=tenant.default_locale or system_payload.get("default_language"),
|
||||
)
|
||||
return {
|
||||
"available_languages": system_payload["available_languages"],
|
||||
"tenant_enabled_language_codes": tenant_enabled,
|
||||
"user_enabled_language_codes": user_enabled,
|
||||
"preferred_language": preferred,
|
||||
}
|
||||
|
||||
|
||||
def _resolve_login_user(session: Session, payload: LoginRequest) -> tuple[Account, User, Tenant]:
|
||||
account = (
|
||||
session.query(Account)
|
||||
@@ -157,13 +209,22 @@ def _me_response(
|
||||
user: User,
|
||||
tenant: Tenant,
|
||||
effective_scopes: list[str] | None = None,
|
||||
auth_method: AuthMethod = "session",
|
||||
api_key_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
service_account_id: str | None = None,
|
||||
include_system: bool = True,
|
||||
include_all_memberships: bool = True,
|
||||
) -> MeResponse:
|
||||
tenant_roles = collect_user_roles(session, user)
|
||||
system_roles = collect_system_roles(session, account) if include_system else []
|
||||
groups = collect_user_groups(session, user)
|
||||
active_tenant = _tenant_info(tenant)
|
||||
scopes = effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system)
|
||||
languages = _language_context(session, tenant=tenant, user=user)
|
||||
tenant_enabled = list(languages["tenant_enabled_language_codes"])
|
||||
user_enabled = list(languages["user_enabled_language_codes"])
|
||||
preferred_language = str(languages["preferred_language"])
|
||||
active_tenant = _tenant_info(tenant, enabled_language_codes=tenant_enabled)
|
||||
memberships = _tenant_memberships(session, account) if include_all_memberships else [
|
||||
TenantMembershipInfo(
|
||||
id=tenant.id,
|
||||
@@ -175,13 +236,35 @@ def _me_response(
|
||||
)
|
||||
]
|
||||
return MeResponse(
|
||||
user=_user_info(user, account),
|
||||
user=_user_info(user, account, preferred_language=preferred_language, enabled_language_codes=user_enabled),
|
||||
tenant=active_tenant,
|
||||
active_tenant=active_tenant,
|
||||
tenants=memberships,
|
||||
scopes=effective_scopes if effective_scopes is not None else collect_user_scopes(session, user, include_system=include_system),
|
||||
scopes=scopes,
|
||||
roles=_roles_info(tenant_roles) + _roles_info(system_roles, level="system"),
|
||||
groups=_groups_info(groups),
|
||||
principal=PrincipalContextInfo.model_validate(
|
||||
PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
identity_id=identity_id_for_account(session, account.id),
|
||||
scopes=frozenset(scopes),
|
||||
group_ids=frozenset(group.id for group in groups),
|
||||
role_ids=frozenset(role.id for role in tenant_roles + system_roles),
|
||||
function_assignment_ids=frozenset(collect_function_assignment_ids(session, user)),
|
||||
delegation_ids=frozenset(collect_function_delegation_ids(session, user)),
|
||||
auth_method=auth_method,
|
||||
api_key_id=api_key_id,
|
||||
session_id=session_id,
|
||||
service_account_id=service_account_id,
|
||||
email=account.email,
|
||||
display_name=account.display_name or user.display_name,
|
||||
).to_dict()
|
||||
),
|
||||
available_languages=languages["available_languages"],
|
||||
enabled_language_codes=tenant_enabled,
|
||||
default_language=preferred_language,
|
||||
)
|
||||
|
||||
|
||||
@@ -209,7 +292,15 @@ def login(payload: LoginRequest, request: Request, response: Response, session:
|
||||
return LoginResponse(
|
||||
access_token=created.token,
|
||||
expires_at=created.model.expires_at,
|
||||
**me_payload.model_dump(),
|
||||
**_me_response(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
effective_scopes=me_payload.scopes,
|
||||
auth_method="session",
|
||||
session_id=created.model.id,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@@ -224,6 +315,10 @@ def me(principal: ApiPrincipal = Depends(get_api_principal), session: Session =
|
||||
user=principal.user,
|
||||
tenant=tenant,
|
||||
effective_scopes=principal.scopes,
|
||||
auth_method=principal.auth_method,
|
||||
api_key_id=principal.api_key_id,
|
||||
session_id=principal.session_id,
|
||||
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,
|
||||
)
|
||||
@@ -244,6 +339,46 @@ def update_profile(
|
||||
if "tenant_display_name" in payload.model_fields_set:
|
||||
principal.user.display_name = payload.tenant_display_name.strip() if payload.tenant_display_name else None
|
||||
session.add(principal.user)
|
||||
next_settings = dict(principal.user.settings or {})
|
||||
settings_changed = False
|
||||
if {"preferred_language", "enabled_language_codes"}.intersection(payload.model_fields_set):
|
||||
tenant = session.get(Tenant, principal.tenant_id)
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Active tenant not found")
|
||||
system_settings = get_system_settings(session)
|
||||
system_enabled = system_enabled_language_codes(system_settings.settings, default_locale=system_settings.default_locale)
|
||||
tenant_enabled = tenant_enabled_language_codes(tenant.settings, system_enabled, default_locale=tenant.default_locale)
|
||||
next_i18n = i18n_settings(next_settings)
|
||||
if "preferred_language" in payload.model_fields_set:
|
||||
preferred = normalize_language_code(payload.preferred_language)
|
||||
if preferred:
|
||||
normalized_preferred = normalize_enabled_language_codes(
|
||||
[preferred],
|
||||
[{"code": code} for code in tenant_enabled],
|
||||
fallback_codes=tenant_enabled,
|
||||
)[0]
|
||||
next_i18n["preferred_language"] = normalized_preferred
|
||||
else:
|
||||
next_i18n.pop("preferred_language", None)
|
||||
if "enabled_language_codes" in payload.model_fields_set:
|
||||
next_i18n["enabled_language_codes"] = normalize_enabled_language_codes(
|
||||
payload.enabled_language_codes,
|
||||
[{"code": code} for code in tenant_enabled],
|
||||
fallback_codes=tenant_enabled,
|
||||
)
|
||||
next_settings["i18n"] = next_i18n
|
||||
settings_changed = True
|
||||
if "ui_preferences" in payload.model_fields_set:
|
||||
if payload.ui_preferences is None:
|
||||
next_settings["ui"] = UserUiPreferences().model_dump()
|
||||
else:
|
||||
next_ui = _user_ui_preferences(next_settings).model_dump()
|
||||
next_ui.update(payload.ui_preferences.model_dump(exclude_unset=True))
|
||||
next_settings["ui"] = UserUiPreferences.model_validate(next_ui).model_dump()
|
||||
settings_changed = True
|
||||
if settings_changed:
|
||||
principal.user.settings = next_settings
|
||||
session.add(principal.user)
|
||||
|
||||
audit_from_principal(
|
||||
session,
|
||||
@@ -262,6 +397,8 @@ def update_profile(
|
||||
account=principal.account,
|
||||
user=principal.user,
|
||||
tenant=tenant,
|
||||
auth_method=principal.auth_method,
|
||||
session_id=principal.session_id,
|
||||
include_system=True,
|
||||
include_all_memberships=True,
|
||||
)
|
||||
@@ -283,7 +420,14 @@ def switch_tenant(
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
session.commit()
|
||||
return _me_response(session, account=principal.account, user=membership, tenant=tenant)
|
||||
return _me_response(
|
||||
session,
|
||||
account=principal.account,
|
||||
user=membership,
|
||||
tenant=tenant,
|
||||
auth_method="session",
|
||||
session_id=principal.session_id,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,15 @@ 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, User
|
||||
from govoplan_access.backend.semantic import 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, collect_user_groups, collect_user_scopes, verify_auth_session_csrf
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
authenticate_session_token,
|
||||
collect_user_groups,
|
||||
collect_user_roles,
|
||||
collect_user_scopes,
|
||||
verify_auth_session_csrf,
|
||||
)
|
||||
from govoplan_tenancy.backend.db.models import Tenant
|
||||
from govoplan_core.security.module_permissions import scopes_grant_compatible
|
||||
from govoplan_core.security.permissions import intersect_api_key_scopes
|
||||
@@ -63,6 +70,26 @@ class ApiPrincipal:
|
||||
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
|
||||
@@ -128,8 +155,12 @@ def _build_principal_ref(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
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)),
|
||||
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,
|
||||
session_id=auth_session.id if auth_session else None,
|
||||
|
||||
376
src/govoplan_access/backend/configuration_provider.py
Normal file
376
src/govoplan_access/backend/configuration_provider.py
Normal file
@@ -0,0 +1,376 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.admin.service import slugify
|
||||
from govoplan_access.backend.db.models import Group, GroupRoleAssignment, Role
|
||||
from govoplan_core.core.configuration_packages import (
|
||||
ConfigurationApplyResult,
|
||||
ConfigurationDiagnostic,
|
||||
ConfigurationExportResult,
|
||||
ConfigurationExportSelection,
|
||||
ConfigurationPackageFragment,
|
||||
ConfigurationPlanItem,
|
||||
ConfigurationPreflightContext,
|
||||
ConfigurationPreflightResult,
|
||||
ConfigurationProvider,
|
||||
ConfigurationProviderDescription,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
|
||||
|
||||
ACCESS_CONFIGURATION_CAPABILITY = "access.configuration"
|
||||
|
||||
|
||||
class SqlAccessConfigurationProvider(ConfigurationProvider):
|
||||
module_id = "access"
|
||||
|
||||
def describe(self) -> ConfigurationProviderDescription:
|
||||
return ConfigurationProviderDescription(
|
||||
module_id=self.module_id,
|
||||
fragment_types=("roles", "groups", "group_role_assignments"),
|
||||
schema_refs={
|
||||
"roles": "govoplan/access/configuration/roles.v1",
|
||||
"groups": "govoplan/access/configuration/groups.v1",
|
||||
"group_role_assignments": "govoplan/access/configuration/group-role-assignments.v1",
|
||||
},
|
||||
exported_scopes=("system", "tenant"),
|
||||
)
|
||||
|
||||
def preflight(self, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
with get_database().session() as session:
|
||||
return _preflight_fragment(session, fragment, context)
|
||||
|
||||
def apply(self, fragment: ConfigurationPackageFragment, supplied_data: Mapping[str, Any], context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
del supplied_data
|
||||
with get_database().session() as session:
|
||||
result = _apply_fragment(session, fragment, context)
|
||||
if not any(item.severity == "blocker" for item in result.diagnostics):
|
||||
session.commit()
|
||||
return result
|
||||
|
||||
def export(self, selection: ConfigurationExportSelection, context: ConfigurationPreflightContext) -> ConfigurationExportResult:
|
||||
del context
|
||||
with get_database().session() as session:
|
||||
return _export_access_configuration(session, selection)
|
||||
|
||||
def health(self, import_result: ConfigurationApplyResult, context: ConfigurationPreflightContext) -> tuple[ConfigurationDiagnostic, ...]:
|
||||
del context
|
||||
if import_result.diagnostics:
|
||||
return tuple(item for item in import_result.diagnostics if item.severity == "blocker")
|
||||
return ()
|
||||
|
||||
|
||||
def _preflight_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
if fragment.fragment_type == "roles":
|
||||
return _preflight_roles(session, fragment, context)
|
||||
if fragment.fragment_type == "groups":
|
||||
return _preflight_groups(session, fragment, context)
|
||||
if fragment.fragment_type == "group_role_assignments":
|
||||
return _preflight_group_role_assignments(session, fragment, context)
|
||||
return ConfigurationPreflightResult(diagnostics=(_unsupported(fragment),))
|
||||
|
||||
|
||||
def _apply_fragment(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
if fragment.fragment_type == "roles":
|
||||
return _apply_roles(session, fragment, context)
|
||||
if fragment.fragment_type == "groups":
|
||||
return _apply_groups(session, fragment, context)
|
||||
if fragment.fragment_type == "group_role_assignments":
|
||||
return _apply_group_role_assignments(session, fragment, context)
|
||||
return ConfigurationApplyResult(diagnostics=(_unsupported(fragment),))
|
||||
|
||||
|
||||
def _preflight_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
level = str(item.get("level") or "tenant").strip().casefold()
|
||||
tenant_id = _tenant_id(context, item, level=level)
|
||||
if level == "tenant" and tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
plan.append(_plan("blocked", fragment, slug, "Tenant role needs a tenant_id."))
|
||||
continue
|
||||
existing = _role_by_slug(session, slug, tenant_id)
|
||||
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} role {slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_roles(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
updated: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
level = str(item.get("level") or "tenant").strip().casefold()
|
||||
tenant_id = _tenant_id(context, item, level=level)
|
||||
if level == "tenant" and tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
continue
|
||||
role = _role_by_slug(session, slug, tenant_id)
|
||||
target = updated if role else created
|
||||
if role is None:
|
||||
role = Role(tenant_id=tenant_id, slug=slug, name=_required(item, "name"), permissions=[])
|
||||
session.add(role)
|
||||
session.flush()
|
||||
role.name = str(item.get("name") or role.name).strip()
|
||||
role.description = _optional(item, "description")
|
||||
role.permissions = _string_list(item.get("permissions"))
|
||||
role.is_assignable = _bool(item.get("is_assignable"), default=True)
|
||||
role.system_required = _bool(item.get("required"), default=role.system_required)
|
||||
target[slug] = f"role:{role.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
|
||||
|
||||
|
||||
def _preflight_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
plan.append(_plan("blocked", fragment, slug, "Group needs a tenant_id."))
|
||||
continue
|
||||
existing = _group_by_slug(session, slug, tenant_id)
|
||||
plan.append(_plan("update" if existing else "create", fragment, slug, f"{'Update' if existing else 'Create'} group {slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_groups(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
updated: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
slug = slugify(_required(item, "slug"))
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, slug))
|
||||
continue
|
||||
group = _group_by_slug(session, slug, tenant_id)
|
||||
target = updated if group else created
|
||||
if group is None:
|
||||
group = Group(tenant_id=tenant_id, slug=slug, name=_required(item, "name"))
|
||||
session.add(group)
|
||||
session.flush()
|
||||
group.name = str(item.get("name") or group.name).strip()
|
||||
group.description = _optional(item, "description")
|
||||
group.is_active = _bool(item.get("is_active"), default=True)
|
||||
group.system_required = _bool(item.get("required"), default=group.system_required)
|
||||
target[slug] = f"group:{group.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created, updated_refs=updated)
|
||||
|
||||
|
||||
def _preflight_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationPreflightResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
plan: list[ConfigurationPlanItem] = []
|
||||
for item in _payload_items(fragment):
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
group_slug = slugify(_required(item, "group"))
|
||||
role_slug = slugify(_required(item, "role"))
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, f"{group_slug}:{role_slug}"))
|
||||
plan.append(_plan("blocked", fragment, f"{group_slug}:{role_slug}", "Group-role assignment needs a tenant_id."))
|
||||
continue
|
||||
group = _group_by_slug(session, group_slug, tenant_id)
|
||||
role = _role_by_slug(session, role_slug, tenant_id)
|
||||
if group is None or role is None:
|
||||
missing = ", ".join(ref for ref, exists in ((f"group:{group_slug}", group is not None), (f"role:{role_slug}", role is not None)) if not exists)
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="info",
|
||||
code="access_reference_pending",
|
||||
message=f"Access assignment references are not present yet and may be created by earlier package fragments: {missing}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=f"{group_slug}:{role_slug}",
|
||||
resolution="Keep role and group fragments before assignment fragments in the package.",
|
||||
))
|
||||
plan.append(_plan("bind", fragment, f"{group_slug}:{role_slug}", f"Bind {group_slug} to {role_slug} after referenced objects exist."))
|
||||
continue
|
||||
exists = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).count()
|
||||
plan.append(_plan("skip" if exists else "bind", fragment, f"{group_slug}:{role_slug}", f"{'Keep' if exists else 'Bind'} {group_slug} to {role_slug}."))
|
||||
return ConfigurationPreflightResult(diagnostics=tuple(diagnostics), plan=tuple(plan))
|
||||
|
||||
|
||||
def _apply_group_role_assignments(session: Session, fragment: ConfigurationPackageFragment, context: ConfigurationPreflightContext) -> ConfigurationApplyResult:
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
created: dict[str, str] = {}
|
||||
for item in _payload_items(fragment):
|
||||
tenant_id = _tenant_id(context, item, level="tenant")
|
||||
group_slug = slugify(_required(item, "group"))
|
||||
role_slug = slugify(_required(item, "role"))
|
||||
object_ref = f"{group_slug}:{role_slug}"
|
||||
if tenant_id is None:
|
||||
diagnostics.append(_tenant_required(fragment, object_ref))
|
||||
continue
|
||||
group = _group_by_slug(session, group_slug, tenant_id)
|
||||
role = _role_by_slug(session, role_slug, tenant_id)
|
||||
if group is None:
|
||||
diagnostics.append(_missing_ref(fragment, f"group:{group_slug}"))
|
||||
if role is None:
|
||||
diagnostics.append(_missing_ref(fragment, f"role:{role_slug}"))
|
||||
if group is None or role is None:
|
||||
continue
|
||||
assignment = session.query(GroupRoleAssignment).filter(GroupRoleAssignment.tenant_id == tenant_id, GroupRoleAssignment.group_id == group.id, GroupRoleAssignment.role_id == role.id).one_or_none()
|
||||
if assignment is None:
|
||||
assignment = GroupRoleAssignment(tenant_id=tenant_id, group_id=group.id, role_id=role.id)
|
||||
session.add(assignment)
|
||||
session.flush()
|
||||
created[object_ref] = f"group_role_assignment:{assignment.id}"
|
||||
return ConfigurationApplyResult(diagnostics=tuple(diagnostics), created_refs=created)
|
||||
|
||||
|
||||
def _export_access_configuration(session: Session, selection: ConfigurationExportSelection) -> ConfigurationExportResult:
|
||||
tenant_id = selection.tenant_id
|
||||
diagnostics: list[ConfigurationDiagnostic] = []
|
||||
fragments: list[ConfigurationPackageFragment] = []
|
||||
if tenant_id is None and "system" not in selection.scopes:
|
||||
diagnostics.append(ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="tenant_required",
|
||||
message="Access configuration export needs selection.tenant_id unless exporting system scope.",
|
||||
module_id="access",
|
||||
resolution="Choose a tenant before exporting tenant roles and groups.",
|
||||
))
|
||||
return ConfigurationExportResult(diagnostics=tuple(diagnostics))
|
||||
|
||||
if tenant_id is not None:
|
||||
roles = session.query(Role).filter(Role.tenant_id == tenant_id).order_by(Role.slug.asc()).all()
|
||||
groups = session.query(Group).filter(Group.tenant_id == tenant_id).order_by(Group.slug.asc()).all()
|
||||
assignments = (
|
||||
session.query(GroupRoleAssignment, Group, Role)
|
||||
.join(Group, Group.id == GroupRoleAssignment.group_id)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(GroupRoleAssignment.tenant_id == tenant_id)
|
||||
.order_by(Group.slug.asc(), Role.slug.asc())
|
||||
.all()
|
||||
)
|
||||
fragments.extend((
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role) for role in roles]}),
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="groups", payload={"items": [_group_payload(group) for group in groups]}),
|
||||
ConfigurationPackageFragment(module_id="access", fragment_type="group_role_assignments", payload={"items": [{"group": group.slug, "role": role.slug} for _assignment, group, role in assignments]}),
|
||||
))
|
||||
if "system" in selection.scopes:
|
||||
system_roles = session.query(Role).filter(Role.tenant_id.is_(None)).order_by(Role.slug.asc()).all()
|
||||
fragments.append(ConfigurationPackageFragment(module_id="access", fragment_type="roles", payload={"items": [_role_payload(role, level="system") for role in system_roles]}))
|
||||
return ConfigurationExportResult(fragments=tuple(fragments), diagnostics=tuple(diagnostics))
|
||||
|
||||
|
||||
def _payload_items(fragment: ConfigurationPackageFragment) -> list[Mapping[str, Any]]:
|
||||
raw_items = fragment.payload.get("items")
|
||||
if raw_items is None:
|
||||
raw_items = fragment.payload.get(fragment.fragment_type)
|
||||
if raw_items is None and fragment.payload:
|
||||
raw_items = [fragment.payload]
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError(f"Access configuration fragment {fragment.fragment_type!r} requires an items list.")
|
||||
return [item for item in raw_items if isinstance(item, Mapping)]
|
||||
|
||||
|
||||
def _tenant_id(context: ConfigurationPreflightContext, item: Mapping[str, Any], *, level: str) -> str | None:
|
||||
if level == "system":
|
||||
return None
|
||||
value = item.get("tenant_id") or context.tenant_id
|
||||
return str(value).strip() if value is not None and str(value).strip() else None
|
||||
|
||||
|
||||
def _role_by_slug(session: Session, slug: str, tenant_id: str | None) -> Role | None:
|
||||
query = session.query(Role).filter(Role.slug == slug)
|
||||
query = query.filter(Role.tenant_id.is_(None)) if tenant_id is None else query.filter(Role.tenant_id == tenant_id)
|
||||
return query.one_or_none()
|
||||
|
||||
|
||||
def _group_by_slug(session: Session, slug: str, tenant_id: str) -> Group | None:
|
||||
return session.query(Group).filter(Group.tenant_id == tenant_id, Group.slug == slug).one_or_none()
|
||||
|
||||
|
||||
def _role_payload(role: Role, *, level: str = "tenant") -> dict[str, object]:
|
||||
return {
|
||||
"slug": role.slug,
|
||||
"name": role.name,
|
||||
"description": role.description,
|
||||
"permissions": list(role.permissions or []),
|
||||
"level": "system" if role.tenant_id is None else level,
|
||||
"is_assignable": role.is_assignable,
|
||||
"required": role.system_required,
|
||||
}
|
||||
|
||||
|
||||
def _group_payload(group: Group) -> dict[str, object]:
|
||||
return {
|
||||
"slug": group.slug,
|
||||
"name": group.name,
|
||||
"description": group.description,
|
||||
"is_active": group.is_active,
|
||||
"required": group.system_required,
|
||||
}
|
||||
|
||||
|
||||
def _required(item: Mapping[str, Any], key: str) -> str:
|
||||
value = item.get(key)
|
||||
if value is None or not str(value).strip():
|
||||
raise ValueError(f"Access configuration item requires {key!r}.")
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _optional(item: Mapping[str, Any], key: str) -> str | None:
|
||||
value = item.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _string_list(value: object) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Access configuration permissions must be a list.")
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _bool(value: object, *, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().casefold() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _plan(action: str, fragment: ConfigurationPackageFragment, object_ref: str, summary: str) -> ConfigurationPlanItem:
|
||||
return ConfigurationPlanItem(action=action, module_id=fragment.module_id, fragment_type=fragment.fragment_type, fragment_id=object_ref, summary=summary) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _tenant_required(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="tenant_required",
|
||||
message="Access tenant configuration requires a tenant_id.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Run the package for a selected tenant or set tenant_id on the fragment item.",
|
||||
)
|
||||
|
||||
|
||||
def _missing_ref(fragment: ConfigurationPackageFragment, object_ref: str) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="access_reference_missing",
|
||||
message=f"Access configuration references missing object {object_ref!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=object_ref,
|
||||
resolution="Create referenced groups and roles earlier in the package plan.",
|
||||
)
|
||||
|
||||
|
||||
def _unsupported(fragment: ConfigurationPackageFragment) -> ConfigurationDiagnostic:
|
||||
return ConfigurationDiagnostic(
|
||||
severity="blocker",
|
||||
code="fragment_type_unsupported",
|
||||
message=f"Access configuration does not support fragment type {fragment.fragment_type!r}.",
|
||||
module_id=fragment.module_id,
|
||||
object_ref=fragment.fragment_id or fragment.fragment_type,
|
||||
)
|
||||
@@ -18,7 +18,7 @@ def new_uuid() -> str:
|
||||
class Account(AccessBase, TimestampMixin):
|
||||
"""Global login identity shared by one or more tenant memberships."""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
__tablename__ = "access_accounts"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
@@ -31,22 +31,70 @@ class Account(AccessBase, TimestampMixin):
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
memberships: Mapped[list[User]] = relationship(back_populates="account")
|
||||
identity_links: Mapped[list[IdentityAccountLink]] = relationship(
|
||||
back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
auth_sessions: Mapped[list[AuthSession]] = relationship(back_populates="account", cascade="all, delete-orphan")
|
||||
system_role_assignments: Mapped[list[SystemRoleAssignment]] = relationship(
|
||||
back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Identity(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identities"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
external_subject: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
account_links: Mapped[list[IdentityAccountLink]] = relationship(
|
||||
back_populates="identity", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class IdentityAccountLink(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_identity_account_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
|
||||
Index(
|
||||
"uq_identity_account_links_primary_account",
|
||||
"account_id",
|
||||
unique=True,
|
||||
sqlite_where=text("is_primary = 1"),
|
||||
postgresql_where=text("is_primary IS TRUE"),
|
||||
),
|
||||
Index(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"identity_id",
|
||||
unique=True,
|
||||
sqlite_where=text("is_primary = 1"),
|
||||
postgresql_where=text("is_primary IS TRUE"),
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
identity_id: Mapped[str] = mapped_column(ForeignKey("access_identities.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), default="local", nullable=False)
|
||||
|
||||
identity: Mapped[Identity] = relationship(back_populates="account_links")
|
||||
account: Mapped[Account] = relationship(back_populates="identity_links")
|
||||
|
||||
|
||||
class User(AccessBase, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
__tablename__ = "access_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
UniqueConstraint("tenant_id", "account_id", name="uq_users_tenant_account"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
@@ -64,11 +112,11 @@ class User(AccessBase, TimestampMixin):
|
||||
|
||||
|
||||
class Group(AccessBase, TimestampMixin):
|
||||
__tablename__ = "groups"
|
||||
__tablename__ = "access_groups"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -80,7 +128,7 @@ class Group(AccessBase, TimestampMixin):
|
||||
|
||||
|
||||
class Role(AccessBase, TimestampMixin):
|
||||
__tablename__ = "roles"
|
||||
__tablename__ = "access_roles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),
|
||||
Index(
|
||||
@@ -93,7 +141,7 @@ class Role(AccessBase, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -104,54 +152,148 @@ class Role(AccessBase, TimestampMixin):
|
||||
system_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
|
||||
class OrganizationUnit(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_organization_units"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
parent_id: Mapped[str | None] = mapped_column(ForeignKey("access_organization_units.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class Function(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_functions"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
slug: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
delegable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
act_in_place_allowed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_function_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class FunctionAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_assignments"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"account_id",
|
||||
"function_id",
|
||||
"organization_unit_id",
|
||||
name="uq_function_assignments_account_scope",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
identity_id: Mapped[str | None] = mapped_column(ForeignKey("access_identities.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
function_id: Mapped[str] = mapped_column(ForeignKey("access_functions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
organization_unit_id: Mapped[str] = mapped_column(ForeignKey("access_organization_units.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
applies_to_subunits: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
source: Mapped[str] = mapped_column(String(50), default="direct", nullable=False)
|
||||
delegated_from_assignment_id: Mapped[str | None] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
acting_for_account_id: Mapped[str | None] = mapped_column(ForeignKey("access_accounts.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class FunctionDelegation(AccessBase, TimestampMixin):
|
||||
__tablename__ = "access_function_delegations"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"function_assignment_id",
|
||||
"delegate_account_id",
|
||||
"mode",
|
||||
name="uq_function_delegations_assignment_delegate_mode",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
function_assignment_id: Mapped[str] = mapped_column(ForeignKey("access_function_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
delegator_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
delegate_account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
mode: Mapped[str] = mapped_column(String(30), default="delegate", nullable=False)
|
||||
reason: Mapped[str | None] = mapped_column(Text)
|
||||
valid_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
valid_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class SystemRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "system_role_assignments"
|
||||
__tablename__ = "access_system_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("account_id", "role_id", name="uq_system_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
account: Mapped[Account] = relationship(back_populates="system_role_assignments")
|
||||
role: Mapped[Role] = relationship()
|
||||
|
||||
|
||||
class UserGroupMembership(AccessBase, TimestampMixin):
|
||||
__tablename__ = "user_group_memberships"
|
||||
__tablename__ = "access_user_group_memberships"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "group_id", name="uq_user_group_memberships"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class UserRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "user_role_assignments"
|
||||
__tablename__ = "access_user_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "user_id", "role_id", name="uq_user_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class GroupRoleAssignment(AccessBase, TimestampMixin):
|
||||
__tablename__ = "group_role_assignments"
|
||||
__tablename__ = "access_group_role_assignments"
|
||||
__table_args__ = (UniqueConstraint("tenant_id", "group_id", "role_id", name="uq_group_role_assignments"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
group_id: Mapped[str] = mapped_column(ForeignKey("access_groups.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
role_id: Mapped[str] = mapped_column(ForeignKey("access_roles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
|
||||
|
||||
class ApiKey(AccessBase, TimestampMixin):
|
||||
__tablename__ = "api_keys"
|
||||
__tablename__ = "access_api_keys"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
|
||||
key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
@@ -164,12 +306,12 @@ class ApiKey(AccessBase, TimestampMixin):
|
||||
|
||||
|
||||
class AuthSession(AccessBase, TimestampMixin):
|
||||
__tablename__ = "auth_sessions"
|
||||
__tablename__ = "access_auth_sessions"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tenant_id: Mapped[str] = mapped_column(ForeignKey("tenancy_tenants.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("access_users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
account_id: Mapped[str] = mapped_column(ForeignKey("access_accounts.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
|
||||
csrf_token_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
@@ -186,8 +328,15 @@ __all__ = [
|
||||
"Account",
|
||||
"ApiKey",
|
||||
"AuthSession",
|
||||
"Function",
|
||||
"FunctionAssignment",
|
||||
"FunctionDelegation",
|
||||
"FunctionRoleAssignment",
|
||||
"Group",
|
||||
"GroupRoleAssignment",
|
||||
"Identity",
|
||||
"IdentityAccountLink",
|
||||
"OrganizationUnit",
|
||||
"Role",
|
||||
"SystemRoleAssignment",
|
||||
"Tenant",
|
||||
|
||||
@@ -2,9 +2,30 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from govoplan_core.core.access import AccessDirectory, AccessSubjectRef, AccountRef, GroupRef, UserRef
|
||||
from govoplan_access.backend.db.models import Account, Group, User
|
||||
from govoplan_core.core.access import (
|
||||
AccessSemanticDirectory,
|
||||
AccessSubjectRef,
|
||||
AccountRef,
|
||||
FunctionAssignmentRef,
|
||||
FunctionRef,
|
||||
GroupRef,
|
||||
IdentityRef,
|
||||
OrganizationUnitRef,
|
||||
UserRef,
|
||||
)
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
OrganizationUnit,
|
||||
User,
|
||||
)
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_access.backend.semantic import active_function_assignments_for_account
|
||||
|
||||
|
||||
def _status(active: bool) -> str:
|
||||
@@ -40,7 +61,60 @@ def _group_ref(group: Group) -> GroupRef:
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessDirectory(AccessDirectory):
|
||||
def _identity_ref(identity: Identity, account_links: list[IdentityAccountLink]) -> IdentityRef:
|
||||
primary_account_id = next((link.account_id for link in account_links if link.is_primary), None)
|
||||
return IdentityRef(
|
||||
id=identity.id,
|
||||
display_name=identity.display_name,
|
||||
primary_account_id=primary_account_id,
|
||||
account_ids=tuple(link.account_id for link in account_links),
|
||||
status=_status(identity.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _organization_unit_ref(item: OrganizationUnit) -> OrganizationUnitRef:
|
||||
return OrganizationUnitRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
name=item.name,
|
||||
parent_id=item.parent_id,
|
||||
status=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_ref(function: Function, role_ids: Iterable[str]) -> FunctionRef:
|
||||
return FunctionRef(
|
||||
id=function.id,
|
||||
tenant_id=function.tenant_id,
|
||||
organization_unit_id=function.organization_unit_id,
|
||||
slug=function.slug,
|
||||
name=function.name,
|
||||
role_ids=tuple(role_ids),
|
||||
delegable=function.delegable,
|
||||
act_in_place_allowed=function.act_in_place_allowed,
|
||||
status=_status(function.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def _function_assignment_ref(item: FunctionAssignment) -> FunctionAssignmentRef:
|
||||
return FunctionAssignmentRef(
|
||||
id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
account_id=item.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=_status(item.is_active), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
class SqlAccessDirectory(AccessSemanticDirectory):
|
||||
def get_account(self, account_id: str) -> AccountRef | None:
|
||||
with get_database().session() as session:
|
||||
account = session.get(Account, account_id)
|
||||
@@ -113,6 +187,9 @@ class SqlAccessDirectory(AccessDirectory):
|
||||
return tuple(_group_ref(group) for group in groups)
|
||||
|
||||
def display_label(self, subject: AccessSubjectRef) -> str | None:
|
||||
if subject.kind == "identity":
|
||||
identity = self.get_identity(subject.id)
|
||||
return identity.display_name if identity else subject.label
|
||||
if subject.kind == "account":
|
||||
account = self.get_account(subject.id)
|
||||
return account.display_name or account.email if account else subject.label
|
||||
@@ -122,4 +199,117 @@ class SqlAccessDirectory(AccessDirectory):
|
||||
if subject.kind == "group":
|
||||
group = self.get_group(subject.id)
|
||||
return group.name if group else subject.label
|
||||
if subject.kind == "organization_unit":
|
||||
item = self.get_organization_unit(subject.id)
|
||||
return item.name if item else subject.label
|
||||
if subject.kind == "function":
|
||||
item = self.get_function(subject.id)
|
||||
return item.name if item else subject.label
|
||||
return subject.label or subject.id
|
||||
|
||||
def get_identity(self, identity_id: str) -> IdentityRef | None:
|
||||
with get_database().session() as session:
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
return None
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity.id)
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.created_at.asc())
|
||||
.all()
|
||||
)
|
||||
return _identity_ref(identity, links)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str) -> tuple[AccountRef, ...]:
|
||||
with get_database().session() as session:
|
||||
accounts = (
|
||||
session.query(Account)
|
||||
.join(IdentityAccountLink, IdentityAccountLink.account_id == Account.id)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id)
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), Account.email.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_account_ref(account) for account in accounts)
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(OrganizationUnit, organization_unit_id)
|
||||
return _organization_unit_ref(item) if item is not None else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str) -> tuple[OrganizationUnitRef, ...]:
|
||||
with get_database().session() as session:
|
||||
items = (
|
||||
session.query(OrganizationUnit)
|
||||
.filter(OrganizationUnit.tenant_id == tenant_id)
|
||||
.order_by(OrganizationUnit.name.asc())
|
||||
.all()
|
||||
)
|
||||
return tuple(_organization_unit_ref(item) for item in items)
|
||||
|
||||
def get_function(self, function_id: str) -> FunctionRef | None:
|
||||
with get_database().session() as session:
|
||||
function = session.get(Function, function_id)
|
||||
if function is None:
|
||||
return None
|
||||
role_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id == function.id)
|
||||
.order_by(FunctionRoleAssignment.created_at.asc())
|
||||
.all()
|
||||
]
|
||||
return _function_ref(function, role_ids)
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
) -> tuple[FunctionRef, ...]:
|
||||
with get_database().session() as session:
|
||||
unit_ids = {organization_unit_id}
|
||||
if include_subunits:
|
||||
pending = [organization_unit_id]
|
||||
while pending:
|
||||
parent_id = pending.pop()
|
||||
children = [
|
||||
row[0]
|
||||
for row in session.query(OrganizationUnit.id)
|
||||
.filter(OrganizationUnit.parent_id == parent_id)
|
||||
.all()
|
||||
]
|
||||
for child_id in children:
|
||||
if child_id not in unit_ids:
|
||||
unit_ids.add(child_id)
|
||||
pending.append(child_id)
|
||||
functions = (
|
||||
session.query(Function)
|
||||
.filter(Function.organization_unit_id.in_(unit_ids))
|
||||
.order_by(Function.name.asc())
|
||||
.all()
|
||||
)
|
||||
role_rows = (
|
||||
session.query(FunctionRoleAssignment.function_id, FunctionRoleAssignment.role_id)
|
||||
.filter(FunctionRoleAssignment.function_id.in_([item.id for item in functions]))
|
||||
.all()
|
||||
if functions else []
|
||||
)
|
||||
role_ids_by_function: dict[str, list[str]] = {}
|
||||
for function_id, role_id in role_rows:
|
||||
role_ids_by_function.setdefault(function_id, []).append(role_id)
|
||||
return tuple(_function_ref(item, role_ids_by_function.get(item.id, [])) for item in functions)
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> FunctionAssignmentRef | None:
|
||||
with get_database().session() as session:
|
||||
item = session.get(FunctionAssignment, assignment_id)
|
||||
return _function_assignment_ref(item) if item is not None else None
|
||||
|
||||
def function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> 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)
|
||||
|
||||
256
src/govoplan_access/backend/explanation.py
Normal file
256
src/govoplan_access/backend/explanation.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
FunctionRoleAssignment,
|
||||
Group,
|
||||
GroupRoleAssignment,
|
||||
Role,
|
||||
SystemRoleAssignment,
|
||||
User,
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import (
|
||||
active_function_assignments_for_account,
|
||||
active_function_delegations_for_account,
|
||||
identity_id_for_account,
|
||||
)
|
||||
from govoplan_core.core.access import AccessDecisionProvenance, AccessExplanationService, PrincipalRef
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.security.permissions import scopes_grant
|
||||
|
||||
|
||||
class SqlAccessExplanationService(AccessExplanationService):
|
||||
def explain_scope_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
required_scope: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
with get_database().session() as session:
|
||||
return tuple(_scope_provenance(session, principal, required_scope))
|
||||
|
||||
def explain_resource_provenance(
|
||||
self,
|
||||
principal: PrincipalRef,
|
||||
*,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
action: str,
|
||||
) -> tuple[AccessDecisionProvenance, ...]:
|
||||
del resource_type, resource_id
|
||||
return self.explain_scope_provenance(principal, action)
|
||||
|
||||
|
||||
def _scope_provenance(session: Session, principal: PrincipalRef, required_scope: str) -> list[AccessDecisionProvenance]:
|
||||
items: list[AccessDecisionProvenance] = []
|
||||
account = session.get(Account, principal.account_id)
|
||||
identity_id = principal.identity_id or identity_id_for_account(session, principal.account_id)
|
||||
if identity_id:
|
||||
items.append(AccessDecisionProvenance(kind="identity", id=identity_id, source="identity_account_link"))
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="account",
|
||||
id=principal.account_id,
|
||||
label=(account.display_name or account.email) if account else None,
|
||||
source=principal.auth_method,
|
||||
)
|
||||
)
|
||||
user = session.get(User, principal.membership_id) if principal.membership_id else None
|
||||
if user is not None:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="tenant_membership",
|
||||
id=user.id,
|
||||
label=user.display_name or user.email,
|
||||
tenant_id=user.tenant_id,
|
||||
source="membership",
|
||||
)
|
||||
)
|
||||
_append_direct_role_provenance(session, items, user, required_scope)
|
||||
_append_group_role_provenance(session, items, user, required_scope)
|
||||
_append_function_role_provenance(session, items, user, required_scope)
|
||||
if account is not None:
|
||||
_append_system_role_provenance(session, items, account, required_scope)
|
||||
items.append(AccessDecisionProvenance(kind="right", id=required_scope, label=required_scope))
|
||||
return items
|
||||
|
||||
|
||||
def _append_direct_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
user: User,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(UserRoleAssignment, UserRoleAssignment.role_id == Role.id)
|
||||
.filter(UserRoleAssignment.tenant_id == user.tenant_id, UserRoleAssignment.user_id == user.id)
|
||||
.all()
|
||||
)
|
||||
for role in roles:
|
||||
if scopes_grant(role.permissions or [], required_scope):
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
tenant_id=user.tenant_id,
|
||||
source="direct_user_role",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_group_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
user: User,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
rows = (
|
||||
session.query(Group, Role)
|
||||
.join(UserGroupMembership, UserGroupMembership.group_id == Group.id)
|
||||
.join(GroupRoleAssignment, GroupRoleAssignment.group_id == Group.id)
|
||||
.join(Role, Role.id == GroupRoleAssignment.role_id)
|
||||
.filter(
|
||||
UserGroupMembership.tenant_id == user.tenant_id,
|
||||
UserGroupMembership.user_id == user.id,
|
||||
GroupRoleAssignment.tenant_id == user.tenant_id,
|
||||
Group.is_active.is_(True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for group, role in rows:
|
||||
if scopes_grant(role.permissions or [], required_scope):
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="group",
|
||||
id=group.id,
|
||||
label=group.name,
|
||||
tenant_id=user.tenant_id,
|
||||
source="group_membership",
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
tenant_id=user.tenant_id,
|
||||
source="group_role",
|
||||
details={"group_id": group.id},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_function_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
user: User,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
direct_assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
for assignment in direct_assignments:
|
||||
_append_function_assignment_provenance(session, items, assignment, required_scope, source="function_assignment")
|
||||
|
||||
delegations = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
|
||||
for delegation in delegations:
|
||||
assignment = session.get(FunctionAssignment, delegation.function_assignment_id)
|
||||
if assignment is None:
|
||||
continue
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="delegation",
|
||||
id=delegation.id,
|
||||
label=delegation.mode,
|
||||
tenant_id=delegation.tenant_id,
|
||||
source="function_delegation",
|
||||
details={
|
||||
"function_assignment_id": delegation.function_assignment_id,
|
||||
"delegator_account_id": delegation.delegator_account_id,
|
||||
"delegate_account_id": delegation.delegate_account_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
_append_function_assignment_provenance(session, items, assignment, required_scope, source="delegated_function")
|
||||
|
||||
|
||||
def _append_function_assignment_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
assignment: FunctionAssignment,
|
||||
required_scope: str,
|
||||
*,
|
||||
source: str,
|
||||
) -> None:
|
||||
function = session.get(Function, assignment.function_id)
|
||||
if function is None:
|
||||
return
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(FunctionRoleAssignment.tenant_id == assignment.tenant_id, FunctionRoleAssignment.function_id == function.id)
|
||||
.all()
|
||||
)
|
||||
matching = [role for role in roles if scopes_grant(role.permissions or [], required_scope)]
|
||||
if not matching:
|
||||
return
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="organization_unit",
|
||||
id=assignment.organization_unit_id,
|
||||
tenant_id=assignment.tenant_id,
|
||||
source=source,
|
||||
details={"applies_to_subunits": assignment.applies_to_subunits},
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="function",
|
||||
id=assignment.id,
|
||||
label=function.name,
|
||||
tenant_id=assignment.tenant_id,
|
||||
source=source,
|
||||
details={"function_id": function.id, "organization_unit_id": assignment.organization_unit_id},
|
||||
)
|
||||
)
|
||||
for role in matching:
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
tenant_id=assignment.tenant_id,
|
||||
source=source,
|
||||
details={"function_assignment_id": assignment.id, "function_id": function.id},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _append_system_role_provenance(
|
||||
session: Session,
|
||||
items: list[AccessDecisionProvenance],
|
||||
account: Account,
|
||||
required_scope: str,
|
||||
) -> None:
|
||||
roles = (
|
||||
session.query(Role)
|
||||
.join(SystemRoleAssignment, SystemRoleAssignment.role_id == Role.id)
|
||||
.filter(SystemRoleAssignment.account_id == account.id, Role.tenant_id.is_(None))
|
||||
.all()
|
||||
)
|
||||
for role in roles:
|
||||
if scopes_grant(role.permissions or [], required_scope):
|
||||
items.append(
|
||||
AccessDecisionProvenance(
|
||||
kind="role",
|
||||
id=role.id,
|
||||
label=role.name,
|
||||
source="system_role",
|
||||
)
|
||||
)
|
||||
@@ -1,17 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from govoplan_access.backend.db.base import AccessBase
|
||||
from govoplan_access.backend.db import models as access_models # noqa: F401 - populate access metadata
|
||||
from govoplan_core.core.access import (
|
||||
CAPABILITY_ACCESS_ADMINISTRATION,
|
||||
CAPABILITY_ACCESS_DIRECTORY,
|
||||
CAPABILITY_ACCESS_EXPLANATION,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY,
|
||||
)
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationSpec, ModuleContext, ModuleManifest, NavItem, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationCondition,
|
||||
DocumentationLink,
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
FrontendRoute,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_access.backend.configuration_provider import ACCESS_CONFIGURATION_CAPABILITY
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str, category: str, level: str) -> PermissionDefinition:
|
||||
@@ -44,6 +61,10 @@ ACCESS_PERMISSIONS: tuple[PermissionDefinition, ...] = (
|
||||
_permission("access:role:read", "View roles", "Inspect tenant and system role definitions.", "Tenant access", "tenant"),
|
||||
_permission("access:role:write", "Manage roles", "Create and update assignable roles.", "Tenant access", "tenant"),
|
||||
_permission("access:role:assign", "Assign roles", "Bind roles to memberships, groups, accounts or services.", "Tenant access", "tenant"),
|
||||
_permission("access:function:read", "View functions", "Inspect organization-bound functions and assignments.", "Tenant access", "tenant"),
|
||||
_permission("access:function:write", "Manage functions", "Create and update organization units, functions, and role mappings.", "Tenant access", "tenant"),
|
||||
_permission("access:function:assign", "Assign functions", "Assign organization-bound functions to accounts.", "Tenant access", "tenant"),
|
||||
_permission("access:function:delegate", "Delegate functions", "Create and revoke permitted function delegations.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:read", "View API keys", "List API keys without revealing secrets.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:create", "Create API keys", "Create tenant API keys within delegation limits.", "Tenant access", "tenant"),
|
||||
_permission("access:api_key:revoke", "Revoke API keys", "Revoke tenant API keys.", "Tenant access", "tenant"),
|
||||
@@ -103,6 +124,10 @@ ACCESS_ROLE_TEMPLATES: tuple[RoleTemplate, ...] = (
|
||||
"access:group:manage_members",
|
||||
"access:role:read",
|
||||
"access:role:assign",
|
||||
"access:function:read",
|
||||
"access:function:write",
|
||||
"access:function:assign",
|
||||
"access:function:delegate",
|
||||
"access:api_key:read",
|
||||
"access:api_key:create",
|
||||
"access:api_key:revoke",
|
||||
@@ -128,6 +153,169 @@ ADMIN_READ_SCOPES = (
|
||||
"access:tenant:read",
|
||||
"access:account:read",
|
||||
"access:governance:read",
|
||||
"access:function:read",
|
||||
)
|
||||
|
||||
ACCESS_DOCUMENTATION: tuple[DocumentationTopic, ...] = (
|
||||
DocumentationTopic(
|
||||
id="access.workflow.grant-user-access",
|
||||
title="Grant a person access",
|
||||
summary="Use the access administration screens to create or update a tenant membership, place the person in groups, and assign only the roles they need.",
|
||||
body="The common path is to find or create the person, review their existing membership, then use groups and roles to grant access. If a role or group is not available, the active governance rules or your own delegation limit may block the change.",
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin"),
|
||||
order=30,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"admin:users:read",
|
||||
"admin:groups:read",
|
||||
"admin:roles:read",
|
||||
"access:membership:read",
|
||||
"access:group:read",
|
||||
"access:role:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
|
||||
DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"),
|
||||
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
||||
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
|
||||
),
|
||||
configuration_keys=("access_governance",),
|
||||
metadata={
|
||||
"kind": "workflow",
|
||||
"outcome": "A person can sign in to the tenant and receives the intended access through groups and roles.",
|
||||
"prerequisites": [
|
||||
"You can open Admin.",
|
||||
"You may read users, groups, and roles.",
|
||||
"Write or assignment actions require matching management permissions.",
|
||||
],
|
||||
"steps": [
|
||||
"Open Admin and go to Users.",
|
||||
"Find the existing person or create a membership with their email address and display name.",
|
||||
"Review current groups and direct roles before changing anything.",
|
||||
"Add the person to the smallest group that grants the needed shared access.",
|
||||
"Assign direct roles only when a group does not match the case.",
|
||||
"Save and review any blocker message before asking a system or tenant owner for help.",
|
||||
],
|
||||
"result": "The membership has the intended effective permissions and no broader roles than necessary.",
|
||||
"verification": "Open the user again and compare groups, direct roles, and effective permissions with the request.",
|
||||
"related_field_ids": [
|
||||
"access.user.email",
|
||||
"access.user.display_name",
|
||||
"access.user.groups",
|
||||
"access.user.roles",
|
||||
],
|
||||
"related_topic_ids": [
|
||||
"access.reference.admin-access-fields",
|
||||
"docs.pattern.field-help",
|
||||
],
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="access.reference.admin-access-fields",
|
||||
title="Access administration fields",
|
||||
summary="The access administration screens show tenant memberships, groups, roles, and API keys. Admin docs map the visible fields to API payloads and permission scopes.",
|
||||
body="Users need the visible labels and a short explanation. Admins also need the backing route, API field, permission, and governance note so they can diagnose unavailable actions.",
|
||||
layer="configured",
|
||||
documentation_types=("admin", "user"),
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=31,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("access",),
|
||||
any_scopes=(
|
||||
"admin:users:read",
|
||||
"admin:groups:read",
|
||||
"admin:roles:read",
|
||||
"admin:api_keys:read",
|
||||
"access:membership:read",
|
||||
"access:group:read",
|
||||
"access:role:read",
|
||||
"access:api_key:read",
|
||||
),
|
||||
),
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(label="Access administration", href="/admin", kind="runtime"),
|
||||
DocumentationLink(label="Users API", href="/api/v1/admin/users", kind="api"),
|
||||
DocumentationLink(label="Groups API", href="/api/v1/admin/groups", kind="api"),
|
||||
DocumentationLink(label="Roles API", href="/api/v1/admin/roles", kind="api"),
|
||||
DocumentationLink(label="API keys API", href="/api/v1/admin/api-keys", kind="api"),
|
||||
),
|
||||
configuration_keys=("access_governance",),
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/admin",
|
||||
"screen": "Admin",
|
||||
"section": "Users, groups, roles, and API keys",
|
||||
"fields": [
|
||||
{
|
||||
"field_id": "access.user.email",
|
||||
"label": "Email",
|
||||
"user_description": "The address used to identify the person when they sign in.",
|
||||
"admin_description": "Stored on the account and membership payloads. It must be normalized and unique for the relevant login account.",
|
||||
"api_path": "/api/v1/admin/users",
|
||||
"api_field": "email",
|
||||
"permission_scope": "access:membership:create",
|
||||
"validation": "Must be a valid email address.",
|
||||
"provenance": "Tenant membership creation or account lookup.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.user.display_name",
|
||||
"label": "Display name",
|
||||
"user_description": "The readable name shown in user lists and review screens.",
|
||||
"admin_description": "Maps to display_name on user and account responses where available.",
|
||||
"api_path": "/api/v1/admin/users",
|
||||
"api_field": "display_name",
|
||||
"permission_scope": "access:membership:update",
|
||||
"validation": "Human-readable text; keep it recognizable for administrators.",
|
||||
"provenance": "Tenant membership profile.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.user.groups",
|
||||
"label": "Groups",
|
||||
"user_description": "Shared access bundles that can add roles for many people at once.",
|
||||
"admin_description": "Maps to group_ids when updating a user or group membership.",
|
||||
"api_path": "/api/v1/admin/users/{user_id}",
|
||||
"api_field": "group_ids",
|
||||
"permission_scope": "access:group:manage_members",
|
||||
"validation": "Groups must belong to the same tenant.",
|
||||
"provenance": "User-group membership rows.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.user.roles",
|
||||
"label": "Roles",
|
||||
"user_description": "Direct access grants assigned to one person or inherited from groups.",
|
||||
"admin_description": "Maps to role_ids on user and group role update requests.",
|
||||
"api_path": "/api/v1/admin/users/{user_id}",
|
||||
"api_field": "role_ids",
|
||||
"permission_scope": "access:role:assign",
|
||||
"validation": "Roles must be assignable and cannot exceed the actor's delegation limit.",
|
||||
"provenance": "Direct user roles plus group role inheritance.",
|
||||
},
|
||||
{
|
||||
"field_id": "access.api_key.scopes",
|
||||
"label": "Scopes",
|
||||
"user_description": "The actions an API key may perform.",
|
||||
"admin_description": "Maps to scopes when creating an API key and is intersected with the owner's current permissions.",
|
||||
"api_path": "/api/v1/admin/api-keys",
|
||||
"api_field": "scopes",
|
||||
"permission_scope": "access:api_key:create",
|
||||
"validation": "Use the narrowest scopes possible.",
|
||||
"provenance": "API key grant plus owner delegation.",
|
||||
},
|
||||
],
|
||||
"related_topic_ids": [
|
||||
"access.workflow.grant-user-access",
|
||||
"docs.pattern.field-help",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -152,6 +340,20 @@ def _access_directory(context: ModuleContext) -> object:
|
||||
return SqlAccessDirectory()
|
||||
|
||||
|
||||
def _access_semantic_directory(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.directory import SqlAccessDirectory
|
||||
|
||||
return SqlAccessDirectory()
|
||||
|
||||
|
||||
def _access_explanation_service(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.explanation import SqlAccessExplanationService
|
||||
|
||||
return SqlAccessExplanationService()
|
||||
|
||||
|
||||
def _tenant_provisioner(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.tenancy.provisioning import LegacyTenantAccessProvisioner
|
||||
@@ -173,6 +375,13 @@ def _governance_materializer(context: ModuleContext) -> object:
|
||||
return SqlAccessGovernanceMaterializer()
|
||||
|
||||
|
||||
def _configuration_provider(context: ModuleContext) -> object:
|
||||
del context
|
||||
from govoplan_access.backend.configuration_provider import SqlAccessConfigurationProvider
|
||||
|
||||
return SqlAccessConfigurationProvider()
|
||||
|
||||
|
||||
def _route_factory(context: ModuleContext):
|
||||
from fastapi import APIRouter
|
||||
|
||||
@@ -194,16 +403,28 @@ manifest = ModuleManifest(
|
||||
name="Access",
|
||||
version="0.1.6",
|
||||
dependencies=("tenancy",),
|
||||
optional_dependencies=("identity", "organizations"),
|
||||
permissions=ACCESS_PERMISSIONS,
|
||||
role_templates=ACCESS_ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
migration_spec=MigrationSpec(module_id="access", metadata=AccessBase.metadata),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="access",
|
||||
metadata=AccessBase.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
),
|
||||
uninstall_guard_providers=(
|
||||
persistent_table_uninstall_guard(
|
||||
access_models.Account,
|
||||
access_models.Identity,
|
||||
access_models.IdentityAccountLink,
|
||||
access_models.User,
|
||||
access_models.Group,
|
||||
access_models.Role,
|
||||
access_models.OrganizationUnit,
|
||||
access_models.Function,
|
||||
access_models.FunctionRoleAssignment,
|
||||
access_models.FunctionAssignment,
|
||||
access_models.FunctionDelegation,
|
||||
access_models.SystemRoleAssignment,
|
||||
access_models.UserGroupMembership,
|
||||
access_models.UserRoleAssignment,
|
||||
@@ -224,10 +445,14 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_ACCESS_PRINCIPAL_RESOLVER: _legacy_principal_resolver,
|
||||
CAPABILITY_ACCESS_PERMISSION_EVALUATOR: _legacy_permission_evaluator,
|
||||
CAPABILITY_ACCESS_DIRECTORY: _access_directory,
|
||||
CAPABILITY_ACCESS_SEMANTIC_DIRECTORY: _access_semantic_directory,
|
||||
CAPABILITY_ACCESS_EXPLANATION: _access_explanation_service,
|
||||
CAPABILITY_ACCESS_TENANT_PROVISIONER: _tenant_provisioner,
|
||||
CAPABILITY_ACCESS_ADMINISTRATION: _access_administration,
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER: _governance_materializer,
|
||||
ACCESS_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||
},
|
||||
documentation=ACCESS_DOCUMENTATION,
|
||||
)
|
||||
|
||||
|
||||
|
||||
1
src/govoplan_access/backend/migrations/__init__.py
Normal file
1
src/govoplan_access/backend/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""access semantic directory
|
||||
|
||||
Revision ID: 4a5b6c7d8e9f
|
||||
Revises: 3f4a5b6c7d8e
|
||||
Create Date: 2026-07-10 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "4a5b6c7d8e9f"
|
||||
down_revision = "3f4a5b6c7d8e"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _tables() -> set[str]:
|
||||
return set(sa.inspect(op.get_bind()).get_table_names())
|
||||
|
||||
|
||||
def _indexes(table_name: str) -> set[str]:
|
||||
return {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table_name)}
|
||||
|
||||
|
||||
def _create_index_if_missing(name: str, table_name: str, columns: list[str], *, unique: bool = False, **kwargs) -> None:
|
||||
if table_name in _tables() and name not in _indexes(table_name):
|
||||
op.create_index(name, table_name, columns, unique=unique, **kwargs)
|
||||
|
||||
|
||||
def _drop_index_if_exists(name: str, table_name: str) -> None:
|
||||
if table_name in _tables() and name in _indexes(table_name):
|
||||
op.drop_index(name, table_name=table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
tables = _tables()
|
||||
|
||||
if "access_identities" not in tables:
|
||||
op.create_table(
|
||||
"access_identities",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("display_name", sa.String(length=255), nullable=True),
|
||||
sa.Column("external_subject", sa.String(length=255), nullable=True),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), 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.PrimaryKeyConstraint("id", name=op.f("pk_access_identities")),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_identities_external_subject"), "access_identities", ["external_subject"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_identity_account_links" not in tables:
|
||||
op.create_table(
|
||||
"access_identity_account_links",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_identity_account_links_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["access_identities.id"],
|
||||
name=op.f("fk_access_identity_account_links_identity_id_access_identities"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_identity_account_links")),
|
||||
sa.UniqueConstraint("identity_id", "account_id", name="uq_identity_account_links_identity_account"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_identity_account_links_account_id"), "access_identity_account_links", ["account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_identity_account_links_identity_id"), "access_identity_account_links", ["identity_id"])
|
||||
_create_index_if_missing(
|
||||
"uq_identity_account_links_primary_account",
|
||||
"access_identity_account_links",
|
||||
["account_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("is_primary = 1"),
|
||||
postgresql_where=sa.text("is_primary IS TRUE"),
|
||||
)
|
||||
_create_index_if_missing(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"access_identity_account_links",
|
||||
["identity_id"],
|
||||
unique=True,
|
||||
sqlite_where=sa.text("is_primary = 1"),
|
||||
postgresql_where=sa.text("is_primary IS TRUE"),
|
||||
)
|
||||
|
||||
tables = _tables()
|
||||
if "access_organization_units" not in tables:
|
||||
op.create_table(
|
||||
"access_organization_units",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("parent_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), 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(
|
||||
["parent_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_organization_units_parent_id_access_organization_units"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenancy_tenants.id"],
|
||||
name=op.f("fk_access_organization_units_tenant_id_tenancy_tenants"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_organization_units")),
|
||||
sa.UniqueConstraint("tenant_id", "slug", name="uq_organization_units_tenant_slug"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_organization_units_parent_id"), "access_organization_units", ["parent_id"])
|
||||
_create_index_if_missing(op.f("ix_access_organization_units_tenant_id"), "access_organization_units", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_functions" not in tables:
|
||||
op.create_table(
|
||||
"access_functions",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("slug", sa.String(length=100), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("delegable", sa.Boolean(), nullable=False),
|
||||
sa.Column("act_in_place_allowed", sa.Boolean(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), 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(
|
||||
["organization_unit_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_functions_organization_unit_id_access_organization_units"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenancy_tenants.id"],
|
||||
name=op.f("fk_access_functions_tenant_id_tenancy_tenants"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_functions")),
|
||||
sa.UniqueConstraint("tenant_id", "organization_unit_id", "slug", name="uq_functions_tenant_ou_slug"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_functions_organization_unit_id"), "access_functions", ["organization_unit_id"])
|
||||
_create_index_if_missing(op.f("ix_access_functions_tenant_id"), "access_functions", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_role_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_function_role_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("role_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["access_functions.id"],
|
||||
name=op.f("fk_access_function_role_assignments_function_id_access_functions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"],
|
||||
["access_roles.id"],
|
||||
name=op.f("fk_access_function_role_assignments_role_id_access_roles"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenancy_tenants.id"],
|
||||
name=op.f("fk_access_function_role_assignments_tenant_id_tenancy_tenants"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_role_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "function_id", "role_id", name="uq_function_role_assignments"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_role_assignments_function_id"), "access_function_role_assignments", ["function_id"])
|
||||
_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_function_assignments" not in tables:
|
||||
op.create_table(
|
||||
"access_function_assignments",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("identity_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("function_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("organization_unit_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("delegated_from_assignment_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("acting_for_account_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), 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(
|
||||
["account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_assignments_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["acting_for_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_assignments_acting_for_account_id_access_accounts"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegated_from_assignment_id"],
|
||||
["access_function_assignments.id"],
|
||||
name=op.f("fk_access_function_assignments_delegated_from_assignment_id_access_function_assignments"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_id"],
|
||||
["access_functions.id"],
|
||||
name=op.f("fk_access_function_assignments_function_id_access_functions"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["identity_id"],
|
||||
["access_identities.id"],
|
||||
name=op.f("fk_access_function_assignments_identity_id_access_identities"),
|
||||
ondelete="SET NULL",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_unit_id"],
|
||||
["access_organization_units.id"],
|
||||
name=op.f("fk_access_function_assignments_organization_unit_id_access_organization_units"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenancy_tenants.id"],
|
||||
name=op.f("fk_access_function_assignments_tenant_id_tenancy_tenants"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_assignments")),
|
||||
sa.UniqueConstraint("tenant_id", "account_id", "function_id", "organization_unit_id", name="uq_function_assignments_account_scope"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_account_id"), "access_function_assignments", ["account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_acting_for_account_id"), "access_function_assignments", ["acting_for_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_delegated_from_assignment_id"), "access_function_assignments", ["delegated_from_assignment_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_function_id"), "access_function_assignments", ["function_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_identity_id"), "access_function_assignments", ["identity_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_organization_unit_id"), "access_function_assignments", ["organization_unit_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_assignments_tenant_id"), "access_function_assignments", ["tenant_id"])
|
||||
|
||||
tables = _tables()
|
||||
if "access_function_delegations" not in tables:
|
||||
op.create_table(
|
||||
"access_function_delegations",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("function_assignment_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("delegator_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("delegate_account_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("mode", sa.String(length=30), nullable=False),
|
||||
sa.Column("reason", sa.Text(), nullable=True),
|
||||
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), 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(
|
||||
["delegate_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_delegations_delegate_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["delegator_account_id"],
|
||||
["access_accounts.id"],
|
||||
name=op.f("fk_access_function_delegations_delegator_account_id_access_accounts"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["function_assignment_id"],
|
||||
["access_function_assignments.id"],
|
||||
name=op.f("fk_access_function_delegations_function_assignment_id_access_function_assignments"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id"],
|
||||
["tenancy_tenants.id"],
|
||||
name=op.f("fk_access_function_delegations_tenant_id_tenancy_tenants"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_access_function_delegations")),
|
||||
sa.UniqueConstraint("tenant_id", "function_assignment_id", "delegate_account_id", "mode", name="uq_function_delegations_assignment_delegate_mode"),
|
||||
)
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_delegate_account_id"), "access_function_delegations", ["delegate_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_delegator_account_id"), "access_function_delegations", ["delegator_account_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_function_assignment_id"), "access_function_delegations", ["function_assignment_id"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_revoked_at"), "access_function_delegations", ["revoked_at"])
|
||||
_create_index_if_missing(op.f("ix_access_function_delegations_tenant_id"), "access_function_delegations", ["tenant_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table_name, indexes in (
|
||||
(
|
||||
"access_function_delegations",
|
||||
(
|
||||
op.f("ix_access_function_delegations_tenant_id"),
|
||||
op.f("ix_access_function_delegations_revoked_at"),
|
||||
op.f("ix_access_function_delegations_function_assignment_id"),
|
||||
op.f("ix_access_function_delegations_delegator_account_id"),
|
||||
op.f("ix_access_function_delegations_delegate_account_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_function_assignments",
|
||||
(
|
||||
op.f("ix_access_function_assignments_tenant_id"),
|
||||
op.f("ix_access_function_assignments_organization_unit_id"),
|
||||
op.f("ix_access_function_assignments_identity_id"),
|
||||
op.f("ix_access_function_assignments_function_id"),
|
||||
op.f("ix_access_function_assignments_delegated_from_assignment_id"),
|
||||
op.f("ix_access_function_assignments_acting_for_account_id"),
|
||||
op.f("ix_access_function_assignments_account_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_function_role_assignments",
|
||||
(
|
||||
op.f("ix_access_function_role_assignments_tenant_id"),
|
||||
op.f("ix_access_function_role_assignments_role_id"),
|
||||
op.f("ix_access_function_role_assignments_function_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_functions",
|
||||
(
|
||||
op.f("ix_access_functions_tenant_id"),
|
||||
op.f("ix_access_functions_organization_unit_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_organization_units",
|
||||
(
|
||||
op.f("ix_access_organization_units_tenant_id"),
|
||||
op.f("ix_access_organization_units_parent_id"),
|
||||
),
|
||||
),
|
||||
(
|
||||
"access_identity_account_links",
|
||||
(
|
||||
"uq_identity_account_links_primary_identity",
|
||||
"uq_identity_account_links_primary_account",
|
||||
op.f("ix_access_identity_account_links_identity_id"),
|
||||
op.f("ix_access_identity_account_links_account_id"),
|
||||
),
|
||||
),
|
||||
("access_identities", (op.f("ix_access_identities_external_subject"),)),
|
||||
):
|
||||
for index_name in indexes:
|
||||
_drop_index_if_exists(index_name, table_name)
|
||||
|
||||
for table_name in (
|
||||
"access_function_delegations",
|
||||
"access_function_assignments",
|
||||
"access_function_role_assignments",
|
||||
"access_functions",
|
||||
"access_organization_units",
|
||||
"access_identity_account_links",
|
||||
"access_identities",
|
||||
):
|
||||
if table_name in _tables():
|
||||
op.drop_table(table_name)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -18,6 +18,7 @@ from govoplan_access.backend.db.models import (
|
||||
UserGroupMembership,
|
||||
UserRoleAssignment,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_function_roles
|
||||
from govoplan_core.security.permissions import expand_scopes
|
||||
from govoplan_core.security.time import ensure_aware_utc, utc_now
|
||||
|
||||
@@ -169,6 +170,8 @@ def collect_user_roles(session: Session, user: User) -> list[Role]:
|
||||
)
|
||||
for role in group_roles:
|
||||
roles_by_id[role.id] = role
|
||||
for role in collect_function_roles(session, user):
|
||||
roles_by_id[role.id] = role
|
||||
return list(roles_by_id.values())
|
||||
|
||||
|
||||
@@ -218,4 +221,3 @@ def collect_tenant_memberships(session: Session, account: Account) -> list[tuple
|
||||
.order_by(Tenant.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
156
src/govoplan_access/backend/semantic.py
Normal file
156
src/govoplan_access/backend/semantic.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
Function,
|
||||
FunctionAssignment,
|
||||
FunctionDelegation,
|
||||
FunctionRoleAssignment,
|
||||
Identity,
|
||||
IdentityAccountLink,
|
||||
Role,
|
||||
User,
|
||||
)
|
||||
from govoplan_core.security.time import utc_now
|
||||
|
||||
|
||||
def primary_identity_for_account(session: Session, account_id: str) -> Identity | None:
|
||||
return (
|
||||
session.query(Identity)
|
||||
.join(IdentityAccountLink, IdentityAccountLink.identity_id == Identity.id)
|
||||
.filter(
|
||||
IdentityAccountLink.account_id == account_id,
|
||||
IdentityAccountLink.is_primary.is_(True),
|
||||
Identity.is_active.is_(True),
|
||||
)
|
||||
.order_by(IdentityAccountLink.created_at.asc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def identity_id_for_account(session: Session, account_id: str) -> str | None:
|
||||
identity = primary_identity_for_account(session, account_id)
|
||||
return identity.id if identity is not None else None
|
||||
|
||||
|
||||
def ensure_identity_for_account(session: Session, account: Account, *, source: str = "local") -> Identity:
|
||||
identity = primary_identity_for_account(session, account.id)
|
||||
if identity is not None:
|
||||
return identity
|
||||
identity = Identity(display_name=account.display_name or account.email, source=source)
|
||||
session.add(identity)
|
||||
session.flush()
|
||||
session.add(IdentityAccountLink(identity_id=identity.id, account_id=account.id, is_primary=True, source=source))
|
||||
session.flush()
|
||||
return identity
|
||||
|
||||
|
||||
def active_function_assignments_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> list[FunctionAssignment]:
|
||||
now = utc_now()
|
||||
query = (
|
||||
session.query(FunctionAssignment)
|
||||
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionAssignment.account_id == account_id,
|
||||
FunctionAssignment.is_active.is_(True),
|
||||
Function.is_active.is_(True),
|
||||
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
|
||||
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
|
||||
)
|
||||
.order_by(FunctionAssignment.created_at.asc())
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(FunctionAssignment.tenant_id == tenant_id, Function.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
def active_function_delegations_for_account(
|
||||
session: Session,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
modes: Iterable[str] = ("delegate",),
|
||||
) -> list[FunctionDelegation]:
|
||||
now = utc_now()
|
||||
mode_set = sorted({str(mode) for mode in modes})
|
||||
if not mode_set:
|
||||
return []
|
||||
query = (
|
||||
session.query(FunctionDelegation)
|
||||
.join(FunctionAssignment, FunctionAssignment.id == FunctionDelegation.function_assignment_id)
|
||||
.join(Function, Function.id == FunctionAssignment.function_id)
|
||||
.filter(
|
||||
FunctionDelegation.delegate_account_id == account_id,
|
||||
FunctionDelegation.mode.in_(mode_set),
|
||||
FunctionDelegation.is_active.is_(True),
|
||||
FunctionDelegation.revoked_at.is_(None),
|
||||
FunctionAssignment.is_active.is_(True),
|
||||
Function.is_active.is_(True),
|
||||
Function.delegable.is_(True),
|
||||
or_(FunctionDelegation.valid_from.is_(None), FunctionDelegation.valid_from <= now),
|
||||
or_(FunctionDelegation.valid_until.is_(None), FunctionDelegation.valid_until > now),
|
||||
or_(FunctionAssignment.valid_from.is_(None), FunctionAssignment.valid_from <= now),
|
||||
or_(FunctionAssignment.valid_until.is_(None), FunctionAssignment.valid_until > now),
|
||||
)
|
||||
.order_by(FunctionDelegation.created_at.asc())
|
||||
)
|
||||
if tenant_id is not None:
|
||||
query = query.filter(FunctionDelegation.tenant_id == tenant_id, FunctionAssignment.tenant_id == tenant_id)
|
||||
return query.all()
|
||||
|
||||
|
||||
def collect_function_assignment_ids(session: Session, user: User) -> list[str]:
|
||||
ids = [item.id for item in active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)]
|
||||
for delegation in active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id):
|
||||
ids.append(delegation.function_assignment_id)
|
||||
return sorted(dict.fromkeys(ids))
|
||||
|
||||
|
||||
def collect_function_delegation_ids(session: Session, user: User) -> list[str]:
|
||||
return [
|
||||
item.id
|
||||
for item in active_function_delegations_for_account(
|
||||
session,
|
||||
user.account_id,
|
||||
tenant_id=user.tenant_id,
|
||||
modes=("delegate", "act_in_place"),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def collect_function_roles(session: Session, user: User) -> list[Role]:
|
||||
assignments = active_function_assignments_for_account(session, user.account_id, tenant_id=user.tenant_id)
|
||||
delegated = active_function_delegations_for_account(session, user.account_id, tenant_id=user.tenant_id, modes=("delegate",))
|
||||
assignment_ids = {assignment.id for assignment in assignments}
|
||||
assignment_ids.update(delegation.function_assignment_id for delegation in delegated)
|
||||
if not assignment_ids:
|
||||
return []
|
||||
function_ids = [
|
||||
row[0]
|
||||
for row in session.query(FunctionAssignment.function_id)
|
||||
.filter(FunctionAssignment.tenant_id == user.tenant_id, FunctionAssignment.id.in_(assignment_ids))
|
||||
.all()
|
||||
]
|
||||
if not function_ids:
|
||||
return []
|
||||
return (
|
||||
session.query(Role)
|
||||
.join(FunctionRoleAssignment, FunctionRoleAssignment.role_id == Role.id)
|
||||
.filter(
|
||||
FunctionRoleAssignment.tenant_id == user.tenant_id,
|
||||
FunctionRoleAssignment.function_id.in_(function_ids),
|
||||
Role.tenant_id == user.tenant_id,
|
||||
)
|
||||
.order_by(Role.name.asc())
|
||||
.all()
|
||||
)
|
||||
Reference in New Issue
Block a user