feat: add access module boundary migrations

This commit is contained in:
2026-07-10 12:51:16 +02:00
parent 37828fe340
commit 04681f1d75
41 changed files with 7229 additions and 738 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View 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,
)

View File

@@ -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",

View File

@@ -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)

View 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",
)
)

View File

@@ -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,
)

View File

@@ -0,0 +1 @@

View File

@@ -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)

View File

@@ -0,0 +1 @@

View File

@@ -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()
)

View 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()
)