feat: manage bounded automation service accounts
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
@@ -26,7 +27,15 @@ from govoplan_core.core.modules import AccessDecision
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.maintenance import MAINTENANCE_ACCESS_SCOPE, maintenance_response_detail, saved_maintenance_mode
|
||||
from govoplan_core.db.session import get_database, get_session
|
||||
from govoplan_access.backend.db.models import Account, ApiKey, AuthSession, Role, Tenant, User
|
||||
from govoplan_access.backend.db.models import (
|
||||
Account,
|
||||
ApiKey,
|
||||
AuthSession,
|
||||
Role,
|
||||
ServiceAccount,
|
||||
Tenant,
|
||||
User,
|
||||
)
|
||||
from govoplan_access.backend.semantic import collect_external_function_roles, identity_id_for_account
|
||||
from govoplan_access.backend.security.api_keys import authenticate_api_key
|
||||
from govoplan_access.backend.security.sessions import (
|
||||
@@ -605,122 +614,341 @@ class AccessAutomationPrincipalProvider:
|
||||
"Access automation principal resolution requires a "
|
||||
"SQLAlchemy session"
|
||||
)
|
||||
account = session.get(Account, request.account_id)
|
||||
user = session.get(User, request.membership_id)
|
||||
tenant = session.get(Tenant, request.tenant_id)
|
||||
if (
|
||||
account is None
|
||||
or user is None
|
||||
or tenant is None
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=False,
|
||||
reason=(
|
||||
"The automation owner is inactive, missing, or no longer "
|
||||
"belongs to the tenant."
|
||||
),
|
||||
missing_scopes=tuple(sorted(set(request.grant_scopes))),
|
||||
provenance={
|
||||
"authorization_ref": request.authorization_ref,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"status": "inactive_or_inconsistent",
|
||||
},
|
||||
if request.subject_kind == "service_account":
|
||||
return _resolve_service_account_automation(
|
||||
session,
|
||||
request=request,
|
||||
)
|
||||
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
return _resolve_delegated_user_automation(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=request.tenant_id,
|
||||
request=request,
|
||||
idm_directory=self._idm_directory,
|
||||
identity_directory=self._identity_directory,
|
||||
organization_directory=self._organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=False,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
current_scopes = authorization_context.scopes
|
||||
granted_scopes = tuple(
|
||||
sorted(
|
||||
{
|
||||
required
|
||||
for required in request.grant_scopes
|
||||
if scopes_grant_compatible(current_scopes, required)
|
||||
}
|
||||
)
|
||||
)
|
||||
missing_scopes = tuple(
|
||||
sorted(set(request.grant_scopes) - set(granted_scopes))
|
||||
)
|
||||
if missing_scopes:
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=False,
|
||||
reason=(
|
||||
"The automation owner no longer has every scope granted "
|
||||
"to this trigger."
|
||||
),
|
||||
granted_scopes=granted_scopes,
|
||||
missing_scopes=missing_scopes,
|
||||
provenance={
|
||||
"authorization_ref": request.authorization_ref,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"status": "authorization_reduced",
|
||||
},
|
||||
)
|
||||
|
||||
principal_ref = _build_principal_ref(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=request.tenant_id,
|
||||
scopes=list(granted_scopes),
|
||||
auth_method="service_account",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=self._identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=False,
|
||||
|
||||
def _resolve_delegated_user_automation(
|
||||
session: Session,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
idm_directory: IdmDirectory | None,
|
||||
identity_directory: IdentityDirectory | None,
|
||||
organization_directory: OrganizationDirectory | None,
|
||||
) -> AutomationPrincipalResolution:
|
||||
account = session.get(Account, request.account_id)
|
||||
user = session.get(User, request.membership_id)
|
||||
tenant = session.get(Tenant, request.tenant_id)
|
||||
if (
|
||||
account is None
|
||||
or user is None
|
||||
or tenant is None
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or not tenant.is_active
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
):
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="inactive_or_inconsistent",
|
||||
reason=(
|
||||
"The automation owner is inactive, missing, or no longer "
|
||||
"belongs to the tenant."
|
||||
),
|
||||
)
|
||||
principal_ref = replace(
|
||||
principal_ref,
|
||||
service_account_id=request.authorization_ref,
|
||||
acting_for_account_id=account.id,
|
||||
)
|
||||
api_principal = _api_principal_from_ref(
|
||||
session,
|
||||
principal_ref,
|
||||
permission_evaluator=LegacyPermissionEvaluator(),
|
||||
)
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=True,
|
||||
principal=api_principal,
|
||||
idm_assignments, idm_roles = _principal_idm_context(
|
||||
session,
|
||||
user=user,
|
||||
account=account,
|
||||
tenant_id=request.tenant_id,
|
||||
idm_directory=idm_directory,
|
||||
organization_directory=organization_directory,
|
||||
)
|
||||
authorization_context = collect_user_authorization_context(
|
||||
session,
|
||||
user,
|
||||
account=account,
|
||||
include_system=False,
|
||||
extra_roles=idm_roles,
|
||||
)
|
||||
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||
request.grant_scopes,
|
||||
current_scopes=authorization_context.scopes,
|
||||
)
|
||||
if missing_scopes:
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="authorization_reduced",
|
||||
reason=(
|
||||
"The automation owner no longer has every scope granted "
|
||||
"to this trigger."
|
||||
),
|
||||
granted_scopes=granted_scopes,
|
||||
provenance={
|
||||
"authorization_ref": request.authorization_ref,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"role_ids": sorted(principal_ref.role_ids),
|
||||
"group_ids": sorted(principal_ref.group_ids),
|
||||
"function_assignment_ids": sorted(
|
||||
principal_ref.function_assignment_ids
|
||||
),
|
||||
"delegation_ids": sorted(principal_ref.delegation_ids),
|
||||
"status": "current_authorization_resolved",
|
||||
},
|
||||
missing_scopes=missing_scopes,
|
||||
)
|
||||
principal_ref = _build_principal_ref(
|
||||
session,
|
||||
account=account,
|
||||
user=user,
|
||||
tenant_id=request.tenant_id,
|
||||
scopes=list(granted_scopes),
|
||||
auth_method="service_account",
|
||||
idm_assignments=idm_assignments,
|
||||
identity_directory=identity_directory,
|
||||
extra_roles=idm_roles,
|
||||
authorization_context=authorization_context,
|
||||
include_system_roles=False,
|
||||
)
|
||||
principal_ref = replace(
|
||||
principal_ref,
|
||||
service_account_id=None,
|
||||
acting_for_account_id=account.id,
|
||||
)
|
||||
return _automation_allowed(
|
||||
session,
|
||||
request=request,
|
||||
principal_ref=principal_ref,
|
||||
granted_scopes=granted_scopes,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_service_account_automation(
|
||||
session: Session,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> AutomationPrincipalResolution:
|
||||
item = session.get(ServiceAccount, request.service_account_id)
|
||||
tenant = session.get(Tenant, request.tenant_id)
|
||||
account = (
|
||||
session.get(Account, item.account_id)
|
||||
if item is not None
|
||||
else None
|
||||
)
|
||||
user = (
|
||||
session.get(User, item.membership_id)
|
||||
if item is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
item is None
|
||||
or tenant is None
|
||||
or account is None
|
||||
or user is None
|
||||
or item.tenant_id != request.tenant_id
|
||||
or not item.is_active
|
||||
or not tenant.is_active
|
||||
or not account.is_active
|
||||
or not user.is_active
|
||||
or item.account_id != account.id
|
||||
or item.membership_id != user.id
|
||||
or user.account_id != account.id
|
||||
or user.tenant_id != tenant.id
|
||||
or account.auth_provider != "service_account"
|
||||
or user.auth_provider != "service_account"
|
||||
):
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="inactive_or_inconsistent",
|
||||
reason=(
|
||||
"The service account is inactive, missing, or no longer "
|
||||
"belongs to the tenant."
|
||||
),
|
||||
)
|
||||
granted_scopes, missing_scopes = _current_trigger_grants(
|
||||
request.grant_scopes,
|
||||
current_scopes=item.scope_ceiling,
|
||||
)
|
||||
if missing_scopes:
|
||||
return _automation_denied(
|
||||
request,
|
||||
status="authorization_reduced",
|
||||
reason=(
|
||||
"The service account no longer has every scope granted "
|
||||
"to this trigger."
|
||||
),
|
||||
granted_scopes=granted_scopes,
|
||||
missing_scopes=missing_scopes,
|
||||
)
|
||||
principal_ref = PrincipalRef(
|
||||
account_id=account.id,
|
||||
membership_id=user.id,
|
||||
tenant_id=tenant.id,
|
||||
scopes=frozenset(granted_scopes),
|
||||
auth_method="service_account",
|
||||
service_account_id=item.id,
|
||||
email=None,
|
||||
display_name=item.name,
|
||||
)
|
||||
return _automation_allowed(
|
||||
session,
|
||||
request=request,
|
||||
principal_ref=principal_ref,
|
||||
granted_scopes=granted_scopes,
|
||||
)
|
||||
|
||||
|
||||
def _current_trigger_grants(
|
||||
trigger_scopes: tuple[str, ...],
|
||||
*,
|
||||
current_scopes: list[str] | tuple[str, ...],
|
||||
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
granted = tuple(
|
||||
sorted(
|
||||
{
|
||||
required
|
||||
for required in trigger_scopes
|
||||
if scopes_grant_compatible(
|
||||
current_scopes,
|
||||
required,
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
missing = tuple(
|
||||
sorted(set(trigger_scopes) - set(granted))
|
||||
)
|
||||
return granted, missing
|
||||
|
||||
|
||||
def _automation_allowed(
|
||||
session: Session,
|
||||
*,
|
||||
request: AutomationPrincipalRequest,
|
||||
principal_ref: PrincipalRef,
|
||||
granted_scopes: tuple[str, ...],
|
||||
) -> AutomationPrincipalResolution:
|
||||
api_principal = _api_principal_from_ref(
|
||||
session,
|
||||
principal_ref,
|
||||
permission_evaluator=LegacyPermissionEvaluator(),
|
||||
)
|
||||
provenance = _automation_provenance(
|
||||
request,
|
||||
status="current_authorization_resolved",
|
||||
current_principal={
|
||||
"kind": request.subject_kind,
|
||||
"account_id": principal_ref.account_id,
|
||||
"membership_id": principal_ref.membership_id,
|
||||
"service_account_id": principal_ref.service_account_id,
|
||||
"role_ids": sorted(principal_ref.role_ids),
|
||||
"group_ids": sorted(principal_ref.group_ids),
|
||||
"function_assignment_ids": sorted(
|
||||
principal_ref.function_assignment_ids
|
||||
),
|
||||
"delegation_ids": sorted(
|
||||
principal_ref.delegation_ids
|
||||
),
|
||||
"granted_scopes": list(granted_scopes),
|
||||
},
|
||||
)
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=True,
|
||||
principal=api_principal,
|
||||
granted_scopes=granted_scopes,
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
|
||||
def _automation_denied(
|
||||
request: AutomationPrincipalRequest,
|
||||
*,
|
||||
status: str,
|
||||
reason: str,
|
||||
granted_scopes: tuple[str, ...] = (),
|
||||
missing_scopes: tuple[str, ...] | None = None,
|
||||
) -> AutomationPrincipalResolution:
|
||||
missing = (
|
||||
tuple(sorted(set(request.grant_scopes)))
|
||||
if missing_scopes is None
|
||||
else missing_scopes
|
||||
)
|
||||
return AutomationPrincipalResolution(
|
||||
allowed=False,
|
||||
reason=reason,
|
||||
granted_scopes=granted_scopes,
|
||||
missing_scopes=missing,
|
||||
provenance=_automation_provenance(
|
||||
request,
|
||||
status=status,
|
||||
current_principal=None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _automation_provenance(
|
||||
request: AutomationPrincipalRequest,
|
||||
*,
|
||||
status: str,
|
||||
current_principal: Mapping[str, object] | None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"contract_version": request.contract_version,
|
||||
"authorization_artifact": {
|
||||
"ref": request.authorization_ref,
|
||||
},
|
||||
"trigger_owner": _automation_trigger_owner(request),
|
||||
"current_automation_principal": (
|
||||
dict(current_principal)
|
||||
if current_principal is not None
|
||||
else None
|
||||
),
|
||||
"event_actor": _automation_context_actor(
|
||||
request.context.get("event_actor")
|
||||
),
|
||||
"operator_override": _automation_context_actor(
|
||||
request.context.get("operator_override")
|
||||
),
|
||||
"trigger_ref": _optional_context_text(
|
||||
request.context.get("trigger_ref")
|
||||
),
|
||||
"delivery_ref": _optional_context_text(
|
||||
request.context.get("delivery_ref")
|
||||
),
|
||||
"status": status,
|
||||
}
|
||||
|
||||
|
||||
def _automation_trigger_owner(
|
||||
request: AutomationPrincipalRequest,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"kind": request.subject_kind,
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"membership_id": request.membership_id,
|
||||
"service_account_id": request.service_account_id,
|
||||
}
|
||||
|
||||
|
||||
def _automation_context_actor(
|
||||
value: object,
|
||||
) -> dict[str, str] | None:
|
||||
if not isinstance(value, Mapping):
|
||||
return None
|
||||
result = {
|
||||
key: str(value[key]).strip()
|
||||
for key in (
|
||||
"kind",
|
||||
"type",
|
||||
"id",
|
||||
"label",
|
||||
"account_id",
|
||||
"membership_id",
|
||||
"service_account_id",
|
||||
"reason",
|
||||
)
|
||||
if value.get(key) is not None
|
||||
and str(value[key]).strip()
|
||||
}
|
||||
return result or None
|
||||
|
||||
|
||||
def _optional_context_text(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
clean = str(value).strip()
|
||||
return clean[:300] or None
|
||||
|
||||
|
||||
def get_api_principal(
|
||||
|
||||
Reference in New Issue
Block a user