feat: add canonical identity administration
This commit is contained in:
+14
-3
@@ -75,6 +75,17 @@ Rollout plan:
|
||||
The close-out condition is that Access works with canonical Identity installed
|
||||
and still works without it through the projection fallback.
|
||||
|
||||
The current lifecycle service is intentionally not an administration API.
|
||||
Identity administration screens and endpoint permissions remain tracked
|
||||
separately; IDM continues to own external import/reconciliation decisions.
|
||||
## Administration surface
|
||||
|
||||
Identity now exposes a system-scoped administration API and an embedded
|
||||
administration workspace. Administrators can create, inspect, update,
|
||||
deactivate, and reactivate identities, then add or remove opaque platform
|
||||
account references and promote one link as primary. The first account link is
|
||||
made primary automatically. A primary link cannot be removed while another
|
||||
link remains; the replacement must be promoted first.
|
||||
|
||||
The current tenant is retained as the actor context, but it does not make the
|
||||
canonical identity record tenant-owned. Every mutation is therefore written as
|
||||
a system-scoped audit event. Identity does not inspect account credentials or
|
||||
authorization state and does not treat deactivation as account suspension.
|
||||
IDM continues to own external import and reconciliation decisions.
|
||||
|
||||
@@ -1,36 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.audit.logging import audit_from_principal
|
||||
from govoplan_core.auth import ApiPrincipal, require_any_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
|
||||
from govoplan_identity.backend.lifecycle import (
|
||||
IdentityLifecycleError,
|
||||
set_identity_active,
|
||||
set_primary_account,
|
||||
)
|
||||
|
||||
from .schemas import IdentityItem, IdentityListResponse
|
||||
from .schemas import (
|
||||
IdentityAccountLinkCreateRequest,
|
||||
IdentityAccountLinkItem,
|
||||
IdentityAccountLinkUpdateRequest,
|
||||
IdentityCreateRequest,
|
||||
IdentityItem,
|
||||
IdentityLifecycleRequest,
|
||||
IdentityListResponse,
|
||||
IdentityUpdateRequest,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/identity", tags=["identity"])
|
||||
|
||||
IDENTITY_READ_SCOPES = (
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
"identity:read",
|
||||
"admin:users:read",
|
||||
"system:accounts:read",
|
||||
"organizations:function:assign",
|
||||
)
|
||||
IDENTITY_WRITE_SCOPES = (
|
||||
"identity:identity:admin",
|
||||
"system:accounts:update",
|
||||
"access:account:update",
|
||||
)
|
||||
ACCOUNT_LINK_WRITE_SCOPES = (
|
||||
"identity:account_link:admin",
|
||||
"identity:identity:admin",
|
||||
"system:accounts:update",
|
||||
"access:account:update",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/identities", response_model=IdentityListResponse)
|
||||
def list_identities(
|
||||
query: str | None = Query(default=None, min_length=1, max_length=255),
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
limit: int = Query(default=25, ge=1, le=500),
|
||||
include_inactive: bool = False,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_READ_SCOPES)),
|
||||
) -> IdentityListResponse:
|
||||
del principal
|
||||
identity_query = session.query(Identity)
|
||||
if not include_inactive:
|
||||
identity_query = identity_query.filter(Identity.is_active.is_(True))
|
||||
@@ -48,28 +79,403 @@ def list_identities(
|
||||
)
|
||||
)
|
||||
|
||||
identities = identity_query.order_by(Identity.display_name.asc(), Identity.id.asc()).limit(limit).all()
|
||||
identity_ids = [identity.id for identity in identities]
|
||||
links_by_identity: dict[str, list[IdentityAccountLink]] = {identity_id: [] for identity_id in identity_ids}
|
||||
if identity_ids:
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
|
||||
.order_by(IdentityAccountLink.is_primary.desc(), IdentityAccountLink.account_id.asc())
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
links_by_identity.setdefault(link.identity_id, []).append(link)
|
||||
|
||||
identities = (
|
||||
identity_query.order_by(Identity.display_name.asc(), Identity.id.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
links_by_identity = _links_by_identity(session, identities)
|
||||
return IdentityListResponse(
|
||||
identities=[
|
||||
_identity_item(identity, links_by_identity.get(identity.id, []))
|
||||
_identity_item(identity, links_by_identity.get(identity.id, ()))
|
||||
for identity in identities
|
||||
]
|
||||
],
|
||||
tenant_context_id=principal.tenant_id,
|
||||
)
|
||||
|
||||
|
||||
def _identity_item(identity: Identity, links: list[IdentityAccountLink]) -> IdentityItem:
|
||||
@router.get("/identities/{identity_id}", response_model=IdentityItem)
|
||||
def get_identity(
|
||||
identity_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_READ_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
del principal
|
||||
identity = _require_identity(session, identity_id)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/identities",
|
||||
response_model=IdentityItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def create_identity(
|
||||
payload: IdentityCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = Identity(
|
||||
display_name=_optional_text(payload.display_name),
|
||||
external_subject=_optional_text(payload.external_subject),
|
||||
source=payload.source.strip(),
|
||||
is_active=payload.is_active,
|
||||
settings=dict(payload.settings),
|
||||
)
|
||||
session.add(identity)
|
||||
session.flush()
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.created",
|
||||
scope="system",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
details={
|
||||
"management_scope": "system",
|
||||
"source": identity.source,
|
||||
"active": identity.is_active,
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, ())
|
||||
|
||||
|
||||
@router.patch("/identities/{identity_id}", response_model=IdentityItem)
|
||||
def update_identity(
|
||||
identity_id: str,
|
||||
payload: IdentityUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
changed_fields: list[str] = []
|
||||
supplied = payload.model_fields_set
|
||||
if "display_name" in supplied:
|
||||
identity.display_name = _optional_text(payload.display_name)
|
||||
changed_fields.append("display_name")
|
||||
if "external_subject" in supplied:
|
||||
identity.external_subject = _optional_text(payload.external_subject)
|
||||
changed_fields.append("external_subject")
|
||||
if "source" in supplied and payload.source is not None:
|
||||
identity.source = payload.source.strip()
|
||||
changed_fields.append("source")
|
||||
if "settings" in supplied and payload.settings is not None:
|
||||
identity.settings = dict(payload.settings)
|
||||
changed_fields.append("settings")
|
||||
if "is_active" in supplied and payload.is_active is not None:
|
||||
try:
|
||||
result = set_identity_active(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
active=payload.is_active,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=payload.reason,
|
||||
)
|
||||
except IdentityLifecycleError as exc: # pragma: no cover - already loaded
|
||||
raise _lifecycle_http_error(exc) from exc
|
||||
if result.changed:
|
||||
changed_fields.append("is_active")
|
||||
if changed_fields:
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.updated",
|
||||
scope="system",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
details={
|
||||
"management_scope": "system",
|
||||
"changed_fields": sorted(changed_fields),
|
||||
"reason": _optional_text(payload.reason),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.post("/identities/{identity_id}/deactivate", response_model=IdentityItem)
|
||||
def deactivate_identity(
|
||||
identity_id: str,
|
||||
payload: IdentityLifecycleRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
return _set_active_response(session, principal, identity_id, False, payload.reason)
|
||||
|
||||
|
||||
@router.post("/identities/{identity_id}/activate", response_model=IdentityItem)
|
||||
def activate_identity(
|
||||
identity_id: str,
|
||||
payload: IdentityLifecycleRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*IDENTITY_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
return _set_active_response(session, principal, identity_id, True, payload.reason)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/identities/{identity_id}/account-links",
|
||||
response_model=IdentityItem,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def add_account_link(
|
||||
identity_id: str,
|
||||
payload: IdentityAccountLinkCreateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
account_id = payload.account_id.strip()
|
||||
existing = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.account_id == account_id)
|
||||
.first()
|
||||
)
|
||||
if existing is not None:
|
||||
detail = (
|
||||
"The account is already linked to this identity."
|
||||
if existing.identity_id == identity.id
|
||||
else "The account is already linked to another identity."
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
has_links = (
|
||||
session.query(IdentityAccountLink.id)
|
||||
.filter(IdentityAccountLink.identity_id == identity.id)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
link = IdentityAccountLink(
|
||||
identity_id=identity.id,
|
||||
account_id=account_id,
|
||||
is_primary=False,
|
||||
source=payload.source.strip(),
|
||||
)
|
||||
session.add(link)
|
||||
try:
|
||||
session.flush()
|
||||
if payload.make_primary or not has_links:
|
||||
set_primary_account(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
account_id=account_id,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=payload.reason,
|
||||
)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.account_link_added",
|
||||
scope="system",
|
||||
object_type="identity_account_link",
|
||||
object_id=link.id,
|
||||
details={
|
||||
"management_scope": "system",
|
||||
"identity_id": identity.id,
|
||||
"account_id": account_id,
|
||||
"source": link.source,
|
||||
"made_primary": payload.make_primary or not has_links,
|
||||
"reason": _optional_text(payload.reason),
|
||||
},
|
||||
)
|
||||
session.commit()
|
||||
except IntegrityError as exc:
|
||||
session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="The account link conflicts with an existing primary-account assignment.",
|
||||
) from exc
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/identities/{identity_id}/account-links/{link_id}",
|
||||
response_model=IdentityItem,
|
||||
)
|
||||
def update_account_link(
|
||||
identity_id: str,
|
||||
link_id: str,
|
||||
payload: IdentityAccountLinkUpdateRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
link = _require_link(session, identity.id, link_id)
|
||||
if not payload.is_primary:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
detail="A primary link can only be demoted by promoting a replacement.",
|
||||
)
|
||||
try:
|
||||
set_primary_account(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
account_id=link.account_id,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=payload.reason,
|
||||
)
|
||||
except IdentityLifecycleError as exc:
|
||||
raise _lifecycle_http_error(exc) from exc
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/identities/{identity_id}/account-links/{link_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def remove_account_link(
|
||||
identity_id: str,
|
||||
link_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(require_any_scope(*ACCOUNT_LINK_WRITE_SCOPES)),
|
||||
) -> Response:
|
||||
identity = _require_identity(session, identity_id)
|
||||
link = _require_link(session, identity.id, link_id)
|
||||
remaining_count = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(
|
||||
IdentityAccountLink.identity_id == identity.id,
|
||||
IdentityAccountLink.id != link.id,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if link.is_primary and remaining_count:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Promote another account before removing the primary link.",
|
||||
)
|
||||
evidence = {
|
||||
"management_scope": "system",
|
||||
"identity_id": identity.id,
|
||||
"account_id": link.account_id,
|
||||
"source": link.source,
|
||||
"was_primary": link.is_primary,
|
||||
}
|
||||
session.delete(link)
|
||||
audit_from_principal(
|
||||
session,
|
||||
principal,
|
||||
action="identity.account_link_removed",
|
||||
scope="system",
|
||||
object_type="identity_account_link",
|
||||
object_id=link.id,
|
||||
details=evidence,
|
||||
)
|
||||
session.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
def _set_active_response(
|
||||
session: Session,
|
||||
principal: ApiPrincipal,
|
||||
identity_id: str,
|
||||
active: bool,
|
||||
reason: str | None,
|
||||
) -> IdentityItem:
|
||||
identity = _require_identity(session, identity_id)
|
||||
try:
|
||||
set_identity_active(
|
||||
session,
|
||||
identity_id=identity.id,
|
||||
active=active,
|
||||
actor_tenant_id=principal.tenant_id,
|
||||
actor_user_id=principal.user.id,
|
||||
actor_scope="system",
|
||||
reason=reason,
|
||||
)
|
||||
except IdentityLifecycleError as exc:
|
||||
raise _lifecycle_http_error(exc) from exc
|
||||
session.commit()
|
||||
session.refresh(identity)
|
||||
return _identity_item(identity, _identity_links(session, identity.id))
|
||||
|
||||
|
||||
def _require_identity(session: Session, identity_id: str) -> Identity:
|
||||
identity = session.get(Identity, identity_id)
|
||||
if identity is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Identity not found.",
|
||||
)
|
||||
return identity
|
||||
|
||||
|
||||
def _require_link(
|
||||
session: Session,
|
||||
identity_id: str,
|
||||
link_id: str,
|
||||
) -> IdentityAccountLink:
|
||||
link = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(
|
||||
IdentityAccountLink.id == link_id,
|
||||
IdentityAccountLink.identity_id == identity_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if link is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Account link not found.",
|
||||
)
|
||||
return link
|
||||
|
||||
|
||||
def _links_by_identity(
|
||||
session: Session,
|
||||
identities: Sequence[Identity],
|
||||
) -> dict[str, list[IdentityAccountLink]]:
|
||||
identity_ids = [identity.id for identity in identities]
|
||||
result: dict[str, list[IdentityAccountLink]] = {
|
||||
identity_id: [] for identity_id in identity_ids
|
||||
}
|
||||
if not identity_ids:
|
||||
return result
|
||||
links = (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id.in_(identity_ids))
|
||||
.order_by(
|
||||
IdentityAccountLink.identity_id.asc(),
|
||||
IdentityAccountLink.is_primary.desc(),
|
||||
IdentityAccountLink.account_id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
result.setdefault(link.identity_id, []).append(link)
|
||||
return result
|
||||
|
||||
|
||||
def _identity_links(
|
||||
session: Session,
|
||||
identity_id: str,
|
||||
) -> list[IdentityAccountLink]:
|
||||
return (
|
||||
session.query(IdentityAccountLink)
|
||||
.filter(IdentityAccountLink.identity_id == identity_id)
|
||||
.order_by(
|
||||
IdentityAccountLink.is_primary.desc(),
|
||||
IdentityAccountLink.account_id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _identity_item(
|
||||
identity: Identity,
|
||||
links: Sequence[IdentityAccountLink],
|
||||
) -> IdentityItem:
|
||||
primary_link = next((link for link in links if link.is_primary), None)
|
||||
return IdentityItem(
|
||||
id=identity.id,
|
||||
@@ -78,5 +484,36 @@ def _identity_item(identity: Identity, links: list[IdentityAccountLink]) -> Iden
|
||||
source=identity.source,
|
||||
primary_account_id=primary_link.account_id if primary_link is not None else None,
|
||||
account_ids=[link.account_id for link in links],
|
||||
account_links=[_account_link_item(link) for link in links],
|
||||
status="active" if identity.is_active else "inactive",
|
||||
is_active=identity.is_active,
|
||||
settings=dict(identity.settings or {}),
|
||||
created_at=identity.created_at,
|
||||
updated_at=identity.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _account_link_item(link: IdentityAccountLink) -> IdentityAccountLinkItem:
|
||||
return IdentityAccountLinkItem(
|
||||
id=link.id,
|
||||
identity_id=link.identity_id,
|
||||
account_id=link.account_id,
|
||||
is_primary=link.is_primary,
|
||||
source=link.source,
|
||||
created_at=link.created_at,
|
||||
updated_at=link.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _optional_text(value: Any) -> str | None:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
|
||||
|
||||
def _lifecycle_http_error(exc: IdentityLifecycleError) -> HTTPException:
|
||||
code = (
|
||||
status.HTTP_404_NOT_FOUND
|
||||
if exc.code == "identity_not_found"
|
||||
else status.HTTP_409_CONFLICT
|
||||
)
|
||||
return HTTPException(status_code=code, detail=str(exc))
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class IdentityAccountLinkItem(BaseModel):
|
||||
id: str
|
||||
identity_id: str
|
||||
account_id: str
|
||||
is_primary: bool
|
||||
source: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityItem(BaseModel):
|
||||
@@ -10,8 +23,59 @@ class IdentityItem(BaseModel):
|
||||
source: str
|
||||
primary_account_id: str | None = None
|
||||
account_ids: list[str]
|
||||
status: str
|
||||
account_links: list[IdentityAccountLinkItem] = Field(default_factory=list)
|
||||
status: Literal["active", "inactive"]
|
||||
is_active: bool
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
management_scope: Literal["system"] = "system"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class IdentityListResponse(BaseModel):
|
||||
identities: list[IdentityItem]
|
||||
management_scope: Literal["system"] = "system"
|
||||
tenant_context_id: str | None = None
|
||||
|
||||
|
||||
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", min_length=1, max_length=50)
|
||||
is_active: bool = True
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
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, min_length=1, max_length=50)
|
||||
is_active: bool | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class IdentityLifecycleRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class IdentityAccountLinkCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
account_id: str = Field(min_length=1, max_length=36)
|
||||
source: str = Field(default="local", min_length=1, max_length=50)
|
||||
make_primary: bool = False
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class IdentityAccountLinkUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
is_primary: bool
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
|
||||
@@ -30,6 +30,7 @@ def set_identity_active(
|
||||
active: bool,
|
||||
actor_tenant_id: str | None,
|
||||
actor_user_id: str | None,
|
||||
actor_scope: str = "tenant",
|
||||
reason: str | None = None,
|
||||
) -> IdentityLifecycleResult:
|
||||
"""Change directory visibility without deleting identity or link evidence.
|
||||
@@ -55,6 +56,7 @@ def set_identity_active(
|
||||
session,
|
||||
tenant_id=actor_tenant_id,
|
||||
user_id=actor_user_id,
|
||||
scope=actor_scope,
|
||||
action="identity.activated" if desired else "identity.deactivated",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
@@ -75,6 +77,7 @@ def set_primary_account(
|
||||
account_id: str,
|
||||
actor_tenant_id: str | None,
|
||||
actor_user_id: str | None,
|
||||
actor_scope: str = "tenant",
|
||||
reason: str | None = None,
|
||||
) -> IdentityLifecycleResult:
|
||||
"""Atomically promote an existing link and retain every other account link."""
|
||||
@@ -129,11 +132,16 @@ def set_primary_account(
|
||||
with session.begin_nested():
|
||||
if previous is not None:
|
||||
previous.is_primary = False
|
||||
# Partial unique indexes are evaluated per statement. Persist the
|
||||
# demotion before the promotion so SQLite and PostgreSQL never see
|
||||
# two primary links during the transition.
|
||||
session.flush()
|
||||
target.is_primary = True
|
||||
audit_event(
|
||||
session,
|
||||
tenant_id=actor_tenant_id,
|
||||
user_id=actor_user_id,
|
||||
scope=actor_scope,
|
||||
action="identity.primary_account_changed",
|
||||
object_type="identity",
|
||||
object_id=identity.id,
|
||||
|
||||
@@ -5,20 +5,35 @@ from pathlib import Path
|
||||
from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER
|
||||
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_SEARCH
|
||||
from govoplan_core.core.module_guards import persistent_table_uninstall_guard
|
||||
from govoplan_core.core.modules import DocumentationTopic, MigrationSpec, ModuleContext, ModuleManifest, PermissionDefinition, RoleTemplate
|
||||
from govoplan_core.core.modules import (
|
||||
DocumentationTopic,
|
||||
FrontendModule,
|
||||
MigrationSpec,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
ViewSurface,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_identity.backend.db import models as identity_models # noqa: F401 - populate metadata
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
def _permission(
|
||||
scope: str,
|
||||
label: str,
|
||||
description: str,
|
||||
*,
|
||||
level: str = "tenant",
|
||||
) -> PermissionDefinition:
|
||||
module_id, resource, action = scope.split(":", 2)
|
||||
return PermissionDefinition(
|
||||
scope=scope,
|
||||
label=label,
|
||||
description=description,
|
||||
category="Identity",
|
||||
level="tenant",
|
||||
level=level,
|
||||
module_id=module_id,
|
||||
resource=resource,
|
||||
action=action,
|
||||
@@ -27,6 +42,18 @@ def _permission(scope: str, label: str, description: str) -> PermissionDefinitio
|
||||
|
||||
PERMISSIONS = (
|
||||
_permission("identity:identity:read", "View identities", "Search and read normalized identities and their account links."),
|
||||
_permission(
|
||||
"identity:identity:admin",
|
||||
"Administer identities",
|
||||
"Create, update, activate, and deactivate canonical system identities.",
|
||||
level="system",
|
||||
),
|
||||
_permission(
|
||||
"identity:account_link:admin",
|
||||
"Administer identity account links",
|
||||
"Add, remove, and select the primary platform account for a canonical identity.",
|
||||
level="system",
|
||||
),
|
||||
)
|
||||
|
||||
ROLE_TEMPLATES = (
|
||||
@@ -36,6 +63,16 @@ ROLE_TEMPLATES = (
|
||||
description="Read normalized identities and account links.",
|
||||
permissions=("identity:identity:read",),
|
||||
),
|
||||
RoleTemplate(
|
||||
slug="identity_administrator",
|
||||
name="Identity administrator",
|
||||
description="Administer the canonical system identity directory and account links.",
|
||||
permissions=(
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -61,6 +98,27 @@ manifest = ModuleManifest(
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
route_factory=_route_factory,
|
||||
frontend=FrontendModule(
|
||||
module_id="identity",
|
||||
package_name="@govoplan/identity-webui",
|
||||
view_surfaces=(
|
||||
ViewSurface(
|
||||
id="identity.admin.directory",
|
||||
module_id="identity",
|
||||
kind="section",
|
||||
label="Identity directory",
|
||||
order=30,
|
||||
),
|
||||
ViewSurface(
|
||||
id="identity.admin.account-links",
|
||||
module_id="identity",
|
||||
kind="section",
|
||||
label="Identity account links",
|
||||
parent_id="identity.admin.directory",
|
||||
order=20,
|
||||
),
|
||||
),
|
||||
),
|
||||
migration_spec=MigrationSpec(
|
||||
module_id="identity",
|
||||
metadata=Base.metadata,
|
||||
@@ -92,6 +150,28 @@ manifest = ModuleManifest(
|
||||
audience=("tenant_admin", "access_admin", "operator"),
|
||||
order=24,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="identity.administration",
|
||||
title="Administer the canonical identity directory",
|
||||
summary="Manage system-scoped identities and their account links without taking over authentication or access control.",
|
||||
body=(
|
||||
"The Identity administration surface lists, creates, inspects, updates, deactivates, and reactivates canonical identities. These records are system-scoped; the current tenant is shown only as the acting administrative context. Account references remain opaque to Identity and one account can be linked to only one identity through this administration API. The first link becomes primary automatically. A primary link cannot be removed while another link remains: promote the replacement first. Every write and primary-account transition is recorded as a system audit event. Deactivation is reversible and does not suspend authentication, erase links, or change permissions."
|
||||
),
|
||||
layer="configured",
|
||||
documentation_types=("admin",),
|
||||
audience=("system_admin", "identity_admin", "access_admin"),
|
||||
order=26,
|
||||
metadata={
|
||||
"kind": "guide",
|
||||
"help_contexts": ["identity.admin.directory"],
|
||||
"prerequisites": [
|
||||
"The administrator has system identity administration permission.",
|
||||
"Account IDs are obtained from an authorized Access administration workflow.",
|
||||
],
|
||||
"outcome": "Canonical identity and link state changes atomically with system-scoped audit evidence.",
|
||||
"verification": "Reload the identity, verify its primary marker and lifecycle state, then inspect the corresponding system audit records.",
|
||||
},
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="identity.lifecycle",
|
||||
title="Administer identity and account-link lifecycle",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_identity.backend.api.v1.routes import router
|
||||
|
||||
|
||||
class IdentityAdminApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.root = Path(tempfile.mkdtemp(prefix="govoplan-identity-admin-api-"))
|
||||
self.database = configure_database(f"sqlite:///{self.root / 'identity.db'}")
|
||||
Base.metadata.create_all(self.database.engine)
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
app.dependency_overrides[get_api_principal] = self._principal
|
||||
self.client = TestClient(app)
|
||||
self.audit_patches = (
|
||||
patch("govoplan_identity.backend.api.v1.routes.audit_from_principal"),
|
||||
patch("govoplan_identity.backend.lifecycle.audit_event"),
|
||||
)
|
||||
self.route_audit = self.audit_patches[0].start()
|
||||
self.lifecycle_audit = self.audit_patches[1].start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client.close()
|
||||
for item in reversed(self.audit_patches):
|
||||
item.stop()
|
||||
reset_database(dispose=True)
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
def test_primary_lifecycle_and_system_scope_are_enforced(self) -> None:
|
||||
created = self.client.post(
|
||||
"/api/v1/identity/identities",
|
||||
json={
|
||||
"display_name": "Ada Example",
|
||||
"external_subject": "subject-ada",
|
||||
"source": "local",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, created.status_code, created.text)
|
||||
identity = created.json()
|
||||
self.assertEqual("system", identity["management_scope"])
|
||||
identity_id = identity["id"]
|
||||
|
||||
first = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links",
|
||||
json={"account_id": "account-1", "source": "local"},
|
||||
)
|
||||
self.assertEqual(201, first.status_code, first.text)
|
||||
self.assertEqual("account-1", first.json()["primary_account_id"])
|
||||
|
||||
second = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links",
|
||||
json={"account_id": "account-2", "source": "idm:accepted"},
|
||||
)
|
||||
self.assertEqual(201, second.status_code, second.text)
|
||||
links = second.json()["account_links"]
|
||||
first_link = next(item for item in links if item["account_id"] == "account-1")
|
||||
second_link = next(item for item in links if item["account_id"] == "account-2")
|
||||
|
||||
blocked = self.client.delete(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links/{first_link['id']}"
|
||||
)
|
||||
self.assertEqual(409, blocked.status_code, blocked.text)
|
||||
|
||||
promoted = self.client.patch(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links/{second_link['id']}",
|
||||
json={"is_primary": True, "reason": "Preferred institutional account"},
|
||||
)
|
||||
self.assertEqual(200, promoted.status_code, promoted.text)
|
||||
self.assertEqual("account-2", promoted.json()["primary_account_id"])
|
||||
|
||||
removed = self.client.delete(
|
||||
f"/api/v1/identity/identities/{identity_id}/account-links/{first_link['id']}"
|
||||
)
|
||||
self.assertEqual(204, removed.status_code, removed.text)
|
||||
|
||||
deactivated = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_id}/deactivate",
|
||||
json={"reason": "Duplicate subject under review"},
|
||||
)
|
||||
self.assertEqual(200, deactivated.status_code, deactivated.text)
|
||||
self.assertEqual("inactive", deactivated.json()["status"])
|
||||
|
||||
default_list = self.client.get("/api/v1/identity/identities")
|
||||
self.assertEqual([], default_list.json()["identities"])
|
||||
inclusive_list = self.client.get(
|
||||
"/api/v1/identity/identities",
|
||||
params={"include_inactive": "true"},
|
||||
)
|
||||
self.assertEqual(identity_id, inclusive_list.json()["identities"][0]["id"])
|
||||
self.assertEqual("system", inclusive_list.json()["management_scope"])
|
||||
self.assertEqual("tenant-1", inclusive_list.json()["tenant_context_id"])
|
||||
|
||||
self.assertTrue(
|
||||
all(call.kwargs["scope"] == "system" for call in self.route_audit.call_args_list)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(call.kwargs["scope"] == "system" for call in self.lifecycle_audit.call_args_list)
|
||||
)
|
||||
|
||||
def test_account_can_only_be_linked_to_one_identity(self) -> None:
|
||||
identity_ids = []
|
||||
for name in ("Ada", "Grace"):
|
||||
response = self.client.post(
|
||||
"/api/v1/identity/identities",
|
||||
json={"display_name": name},
|
||||
)
|
||||
self.assertEqual(201, response.status_code, response.text)
|
||||
identity_ids.append(response.json()["id"])
|
||||
|
||||
first = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_ids[0]}/account-links",
|
||||
json={"account_id": "account-shared"},
|
||||
)
|
||||
self.assertEqual(201, first.status_code, first.text)
|
||||
conflict = self.client.post(
|
||||
f"/api/v1/identity/identities/{identity_ids[1]}/account-links",
|
||||
json={"account_id": "account-shared"},
|
||||
)
|
||||
self.assertEqual(409, conflict.status_code, conflict.text)
|
||||
|
||||
@staticmethod
|
||||
def _principal() -> ApiPrincipal:
|
||||
return ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-admin",
|
||||
membership_id="user-admin",
|
||||
tenant_id="tenant-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=SimpleNamespace(id="account-admin"),
|
||||
user=SimpleNamespace(id="user-admin"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@govoplan/identity-webui",
|
||||
"version": "0.1.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"module": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./styles/identity.css": "./src/styles/identity.css"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@govoplan/core-webui": "^0.1.18",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": ">=19.2.7 <20",
|
||||
"react-dom": ">=19.2.7 <20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@govoplan/core-webui": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test:identity-admin-ui": "node tests/identity-admin-ui-structure.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
apiFetch,
|
||||
apiPath,
|
||||
type ApiSettings
|
||||
} from "@govoplan/core-webui";
|
||||
|
||||
export type IdentityAccountLink = {
|
||||
id: string;
|
||||
identity_id: string;
|
||||
account_id: string;
|
||||
is_primary: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type IdentityItem = {
|
||||
id: string;
|
||||
display_name?: string | null;
|
||||
external_subject?: string | null;
|
||||
source: string;
|
||||
primary_account_id?: string | null;
|
||||
account_ids: string[];
|
||||
account_links: IdentityAccountLink[];
|
||||
status: "active" | "inactive";
|
||||
is_active: boolean;
|
||||
settings: Record<string, unknown>;
|
||||
management_scope: "system";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type IdentityDraft = {
|
||||
display_name?: string | null;
|
||||
external_subject?: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export async function listIdentities(
|
||||
settings: ApiSettings,
|
||||
query = "",
|
||||
includeInactive = true
|
||||
): Promise<IdentityItem[]> {
|
||||
const result = await apiFetch<{ identities: IdentityItem[] }>(
|
||||
settings,
|
||||
apiPath("/api/v1/identity/identities", {
|
||||
query: query.trim() || undefined,
|
||||
include_inactive: includeInactive,
|
||||
limit: 500
|
||||
})
|
||||
);
|
||||
return result.identities;
|
||||
}
|
||||
|
||||
export function createIdentity(
|
||||
settings: ApiSettings,
|
||||
payload: IdentityDraft
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(settings, "/api/v1/identity/identities", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
export function updateIdentity(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
payload: Partial<IdentityDraft>
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setIdentityActive(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
active: boolean,
|
||||
reason: string
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/${active ? "activate" : "deactivate"}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: reason.trim() || null })
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function addIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
payload: {
|
||||
account_id: string;
|
||||
source: string;
|
||||
make_primary: boolean;
|
||||
reason?: string | null;
|
||||
}
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function promoteIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
linkId: string,
|
||||
reason: string
|
||||
): Promise<IdentityItem> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links/${encodeURIComponent(linkId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
is_primary: true,
|
||||
reason: reason.trim() || null
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function removeIdentityAccountLink(
|
||||
settings: ApiSettings,
|
||||
identityId: string,
|
||||
linkId: string
|
||||
): Promise<void> {
|
||||
return apiFetch(
|
||||
settings,
|
||||
`/api/v1/identity/identities/${encodeURIComponent(identityId)}/account-links/${encodeURIComponent(linkId)}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
import {
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserMinus
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AdminPageLayout,
|
||||
Button,
|
||||
Card,
|
||||
DataGrid,
|
||||
Dialog,
|
||||
FilterBar,
|
||||
FormField,
|
||||
FormGrid,
|
||||
MetricCard,
|
||||
MetricGrid,
|
||||
PageActionBar,
|
||||
SelectionList,
|
||||
SelectionListItem,
|
||||
SelectionListItemContent,
|
||||
StatePanel,
|
||||
StatusBadge,
|
||||
TableActionGroup,
|
||||
ToggleSwitch,
|
||||
WorkspaceLayout,
|
||||
formatDateTime,
|
||||
hasScope,
|
||||
useUnsavedChanges,
|
||||
useUnsavedDraftGuard,
|
||||
type ApiSettings,
|
||||
type AuthInfo,
|
||||
type DataGridColumn
|
||||
} from "@govoplan/core-webui";
|
||||
import {
|
||||
addIdentityAccountLink,
|
||||
createIdentity,
|
||||
listIdentities,
|
||||
promoteIdentityAccountLink,
|
||||
removeIdentityAccountLink,
|
||||
setIdentityActive,
|
||||
updateIdentity,
|
||||
type IdentityAccountLink,
|
||||
type IdentityDraft,
|
||||
type IdentityItem
|
||||
} from "../api/identities";
|
||||
|
||||
type Props = {
|
||||
settings: ApiSettings;
|
||||
auth: AuthInfo;
|
||||
};
|
||||
|
||||
type LinkDraft = {
|
||||
accountId: string;
|
||||
source: string;
|
||||
makePrimary: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const EMPTY_DRAFT: IdentityDraft = {
|
||||
display_name: "",
|
||||
external_subject: "",
|
||||
source: "local"
|
||||
};
|
||||
|
||||
const EMPTY_LINK: LinkDraft = {
|
||||
accountId: "",
|
||||
source: "local",
|
||||
makePrimary: false,
|
||||
reason: ""
|
||||
};
|
||||
|
||||
export default function IdentityAdminPage({ settings, auth }: Props) {
|
||||
const [items, setItems] = useState<IdentityItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [draft, setDraft] = useState<IdentityDraft>(EMPTY_DRAFT);
|
||||
const [savedKey, setSavedKey] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showInactive, setShowInactive] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createDraft, setCreateDraft] = useState<IdentityDraft>(EMPTY_DRAFT);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkDraft, setLinkDraft] = useState<LinkDraft>(EMPTY_LINK);
|
||||
const [lifecycleOpen, setLifecycleOpen] = useState(false);
|
||||
const [lifecycleReason, setLifecycleReason] = useState("");
|
||||
const [removeLink, setRemoveLink] = useState<IdentityAccountLink | null>(null);
|
||||
const { requestDiscard } = useUnsavedChanges();
|
||||
|
||||
const selected = items.find((item) => item.id === selectedId) ?? null;
|
||||
const canWrite = hasScope(auth, "identity:identity:admin")
|
||||
|| hasScope(auth, "system:accounts:update")
|
||||
|| hasScope(auth, "access:account:update");
|
||||
const canManageLinks = hasScope(auth, "identity:account_link:admin")
|
||||
|| canWrite;
|
||||
const dirty = Boolean(selected && draftKey(draft) !== savedKey);
|
||||
|
||||
const applyIdentity = useCallback((item: IdentityItem | null) => {
|
||||
const next = item ? draftFromIdentity(item) : EMPTY_DRAFT;
|
||||
setDraft(next);
|
||||
setSavedKey(item ? draftKey(next) : "");
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(async (preferredId?: string) => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const next = await listIdentities(settings, "", true);
|
||||
setItems(next);
|
||||
const nextId = preferredId && next.some((item) => item.id === preferredId)
|
||||
? preferredId
|
||||
: next.some((item) => item.id === selectedId)
|
||||
? selectedId
|
||||
: next[0]?.id ?? "";
|
||||
setSelectedId(nextId);
|
||||
applyIdentity(next.find((item) => item.id === nextId) ?? null);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyIdentity, selectedId, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase();
|
||||
return items.filter((item) => {
|
||||
if (!showInactive && !item.is_active) return false;
|
||||
if (!needle) return true;
|
||||
return `${item.display_name ?? ""} ${item.external_subject ?? ""} ${item.id} ${item.account_ids.join(" ")}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(needle);
|
||||
});
|
||||
}, [items, search, showInactive]);
|
||||
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!selected || !canWrite) return false;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateIdentity(settings, selected.id, draft);
|
||||
setSuccess("Identity saved.");
|
||||
await reload(updated.id);
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useUnsavedDraftGuard({
|
||||
dirty,
|
||||
onSave: save,
|
||||
onDiscard: () => applyIdentity(selected),
|
||||
title: "Unsaved identity changes",
|
||||
message: "Save or discard the current identity changes before continuing."
|
||||
});
|
||||
|
||||
const selectIdentity = (item: IdentityItem) => {
|
||||
if (item.id === selectedId) return;
|
||||
requestDiscard(() => {
|
||||
setSelectedId(item.id);
|
||||
applyIdentity(item);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
});
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
if (!createDraft.display_name?.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createIdentity(settings, createDraft);
|
||||
setCreateOpen(false);
|
||||
setCreateDraft(EMPTY_DRAFT);
|
||||
setSuccess("Identity created.");
|
||||
await reload(created.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addLink = async () => {
|
||||
if (!selected || !linkDraft.accountId.trim() || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await addIdentityAccountLink(settings, selected.id, {
|
||||
account_id: linkDraft.accountId.trim(),
|
||||
source: linkDraft.source.trim() || "local",
|
||||
make_primary: linkDraft.makePrimary,
|
||||
reason: linkDraft.reason.trim() || null
|
||||
});
|
||||
setLinkOpen(false);
|
||||
setLinkDraft(EMPTY_LINK);
|
||||
setSuccess("Account link added.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const promote = async (link: IdentityAccountLink) => {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await promoteIdentityAccountLink(
|
||||
settings,
|
||||
selected.id,
|
||||
link.id,
|
||||
"Promoted through Identity administration"
|
||||
);
|
||||
setSuccess("Primary account changed.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!selected || !removeLink || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await removeIdentityAccountLink(settings, selected.id, removeLink.id);
|
||||
setRemoveLink(null);
|
||||
setSuccess("Account link removed.");
|
||||
await reload(selected.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const applyLifecycle = async () => {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await setIdentityActive(
|
||||
settings,
|
||||
selected.id,
|
||||
!selected.is_active,
|
||||
lifecycleReason
|
||||
);
|
||||
setLifecycleOpen(false);
|
||||
setLifecycleReason("");
|
||||
setSuccess(updated.is_active ? "Identity reactivated." : "Identity deactivated.");
|
||||
await reload(updated.id);
|
||||
} catch (caught) {
|
||||
setError(errorMessage(caught));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const linkColumns = useMemo<DataGridColumn<IdentityAccountLink>[]>(() => [
|
||||
{
|
||||
id: "account",
|
||||
header: "Account ID",
|
||||
width: "1fr",
|
||||
minWidth: 220,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.account_id,
|
||||
render: (row) => <code>{row.account_id}</code>
|
||||
},
|
||||
{
|
||||
id: "primary",
|
||||
header: "Role",
|
||||
width: 130,
|
||||
sortable: true,
|
||||
value: (row) => row.is_primary ? "primary" : "linked",
|
||||
render: (row) => (
|
||||
<StatusBadge
|
||||
status={row.is_primary ? "active" : "neutral"}
|
||||
label={row.is_primary ? "Primary" : "Linked"}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
header: "Source",
|
||||
width: 180,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
value: (row) => row.source
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: "Linked",
|
||||
width: 190,
|
||||
sortable: true,
|
||||
value: (row) => row.created_at,
|
||||
render: (row) => formatDateTime(row.created_at)
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
width: 100,
|
||||
sticky: "end",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<TableActionGroup actions={[
|
||||
{
|
||||
id: "promote",
|
||||
label: "Promote to primary",
|
||||
icon: <Star aria-hidden="true" />,
|
||||
applicable: !row.is_primary,
|
||||
disabled: !canManageLinks || busy,
|
||||
disabledReason: !canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => void promote(row)
|
||||
},
|
||||
{
|
||||
id: "remove",
|
||||
label: "Remove account link",
|
||||
icon: <Trash2 aria-hidden="true" />,
|
||||
variant: "danger",
|
||||
disabled: !canManageLinks || busy,
|
||||
disabledReason: row.is_primary && (selected?.account_links.length ?? 0) > 1
|
||||
? "Promote another account before removing the primary link."
|
||||
: !canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => setRemoveLink(row)
|
||||
}
|
||||
]} />
|
||||
)
|
||||
}
|
||||
], [busy, canManageLinks, selected?.account_links.length]);
|
||||
|
||||
const actionBar = (
|
||||
<PageActionBar
|
||||
variant="editor"
|
||||
state={busy ? "saving" : dirty ? "dirty" : "clean"}
|
||||
refreshable
|
||||
reloadAction={{
|
||||
onReload: () => void reload(selectedId),
|
||||
loading: loading
|
||||
}}
|
||||
primaryActions={
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
<Plus aria-hidden="true" /> New identity
|
||||
</Button>
|
||||
}
|
||||
destructiveActions={selected ? (
|
||||
<Button
|
||||
variant={selected.is_active ? "danger" : "secondary"}
|
||||
onClick={() => setLifecycleOpen(true)}
|
||||
disabled={!canWrite || busy}
|
||||
disabledReason={!canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
{selected.is_active
|
||||
? <><UserMinus aria-hidden="true" /> Deactivate</>
|
||||
: <><UserCheck aria-hidden="true" /> Reactivate</>}
|
||||
</Button>
|
||||
) : null}
|
||||
discardAction={{
|
||||
label: "Discard changes",
|
||||
disabled: !selected,
|
||||
onClick: () => applyIdentity(selected)
|
||||
}}
|
||||
saveAction={{
|
||||
label: "Save",
|
||||
disabled: !selected || !canWrite || busy,
|
||||
disabledReason: !canWrite
|
||||
? "System identity administration permission is required."
|
||||
: undefined,
|
||||
onClick: () => void save()
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminPageLayout
|
||||
archetype="workspace"
|
||||
title="Identity directory"
|
||||
description="Manage canonical system identities and their opaque platform-account links."
|
||||
loading={loading && !items.length}
|
||||
error={error}
|
||||
success={success}
|
||||
actions={actionBar}
|
||||
className="identity-admin-page"
|
||||
helpContextId="identity.admin.directory"
|
||||
>
|
||||
<p className="muted identity-admin-scope-note">
|
||||
<strong>Management scope:</strong> {selected?.management_scope ?? "system"}.
|
||||
{" "}The active tenant is the
|
||||
actor context only; Identity does not grant account access or suspend authentication.
|
||||
</p>
|
||||
|
||||
<MetricGrid columns={3} density="compact" minimum="compact">
|
||||
<MetricCard label="Identities" value={items.length} />
|
||||
<MetricCard
|
||||
label="Active"
|
||||
value={items.filter((item) => item.is_active).length}
|
||||
tone="good"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Without account"
|
||||
value={items.filter((item) => !item.account_links.length).length}
|
||||
tone="warning"
|
||||
/>
|
||||
</MetricGrid>
|
||||
|
||||
<WorkspaceLayout
|
||||
variant="split"
|
||||
primarySize="compact"
|
||||
surface="contained"
|
||||
primaryScrollable={false}
|
||||
contentScrollable={false}
|
||||
primaryLabel="Identities"
|
||||
contentLabel="Identity details"
|
||||
contentClassName="identity-admin-workspace"
|
||||
primary={<div className="identity-admin-list">
|
||||
<FilterBar surface="panel">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search identities or account IDs"
|
||||
aria-label="Search identities"
|
||||
/>
|
||||
<ToggleSwitch
|
||||
label="Show inactive"
|
||||
checked={showInactive}
|
||||
onChange={setShowInactive}
|
||||
/>
|
||||
</FilterBar>
|
||||
<SelectionList variant="navigation" label="Identities">
|
||||
{visibleItems.map((item) => (
|
||||
<SelectionListItem
|
||||
key={item.id}
|
||||
selected={item.id === selectedId}
|
||||
onClick={() => selectIdentity(item)}
|
||||
>
|
||||
<SelectionListItemContent
|
||||
title={item.display_name || item.id}
|
||||
description={item.primary_account_id || "No account linked"}
|
||||
/>
|
||||
<StatusBadge status={item.status} />
|
||||
</SelectionListItem>
|
||||
))}
|
||||
{!visibleItems.length
|
||||
? <StatePanel size="compact" description="No matching identities." />
|
||||
: null}
|
||||
</SelectionList>
|
||||
</div>}
|
||||
>
|
||||
{!selected ? (
|
||||
<StatePanel
|
||||
size="fill"
|
||||
title="Identity directory"
|
||||
description="Create or select an identity to inspect it."
|
||||
/>
|
||||
) : (
|
||||
<div className="identity-admin-detail">
|
||||
<Card
|
||||
title={selected.display_name || selected.id}
|
||||
>
|
||||
<p className="muted">System identity · {selected.status}</p>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Display name">
|
||||
<input
|
||||
value={draft.display_name ?? ""}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
display_name: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="External subject">
|
||||
<input
|
||||
value={draft.external_subject ?? ""}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
external_subject: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={draft.source}
|
||||
disabled={!canWrite || busy}
|
||||
onChange={(event) => setDraft({
|
||||
...draft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Identity ID">
|
||||
<input value={selected.id} readOnly />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Account links"
|
||||
actions={
|
||||
<Button
|
||||
onClick={() => setLinkOpen(true)}
|
||||
disabled={!canManageLinks || busy}
|
||||
disabledReason={!canManageLinks
|
||||
? "Identity account-link administration permission is required."
|
||||
: undefined}
|
||||
>
|
||||
<Plus aria-hidden="true" /> Add account link
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="muted">
|
||||
One opaque account reference is primary. Authentication and
|
||||
account lookup remain owned by Access.
|
||||
</p>
|
||||
<DataGrid
|
||||
id="identity-account-links"
|
||||
rows={selected.account_links}
|
||||
columns={linkColumns}
|
||||
initialFit="container"
|
||||
getRowKey={(row) => row.id}
|
||||
emptyText="No platform accounts are linked."
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Accepted normalized facts">
|
||||
<pre className="identity-admin-settings">
|
||||
{JSON.stringify(selected.settings, null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</WorkspaceLayout>
|
||||
|
||||
<Dialog
|
||||
open={createOpen}
|
||||
title="Create identity"
|
||||
onClose={() => !busy && setCreateOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setCreateOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void create()}
|
||||
disabled={busy || !createDraft.display_name?.trim()}
|
||||
>
|
||||
Create identity
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Display name">
|
||||
<input
|
||||
value={createDraft.display_name ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
display_name: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={createDraft.source}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="External subject">
|
||||
<input
|
||||
value={createDraft.external_subject ?? ""}
|
||||
disabled={busy}
|
||||
onChange={(event) => setCreateDraft({
|
||||
...createDraft,
|
||||
external_subject: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={linkOpen}
|
||||
title="Add account link"
|
||||
onClose={() => !busy && setLinkOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setLinkOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => void addLink()}
|
||||
disabled={busy || !linkDraft.accountId.trim()}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label="Account ID">
|
||||
<input
|
||||
value={linkDraft.accountId}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
accountId: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Source">
|
||||
<input
|
||||
value={linkDraft.source}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
source: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
<ToggleSwitch
|
||||
label="Make primary"
|
||||
checked={linkDraft.makePrimary}
|
||||
onChange={(makePrimary) => setLinkDraft({ ...linkDraft, makePrimary })}
|
||||
/>
|
||||
<FormField label="Reason">
|
||||
<input
|
||||
value={linkDraft.reason}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLinkDraft({
|
||||
...linkDraft,
|
||||
reason: event.target.value
|
||||
})}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<p className="muted">
|
||||
The first account link becomes primary automatically. Identity stores
|
||||
only the account reference and provenance.
|
||||
</p>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={lifecycleOpen}
|
||||
title={selected?.is_active ? "Deactivate identity" : "Reactivate identity"}
|
||||
onClose={() => !busy && setLifecycleOpen(false)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setLifecycleOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button
|
||||
variant={selected?.is_active ? "danger" : "primary"}
|
||||
onClick={() => void applyLifecycle()}
|
||||
disabled={busy}
|
||||
>
|
||||
{selected?.is_active ? "Deactivate" : "Reactivate"}
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p>
|
||||
{selected?.is_active
|
||||
? "Deactivation hides the identity from ordinary directory search. It does not suspend authentication, erase links, or revoke permissions."
|
||||
: "Reactivation restores the identity to ordinary directory search."}
|
||||
</p>
|
||||
<FormField label="Reason">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={lifecycleReason}
|
||||
disabled={busy}
|
||||
onChange={(event) => setLifecycleReason(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(removeLink)}
|
||||
title="Remove account link"
|
||||
onClose={() => !busy && setRemoveLink(null)}
|
||||
closeDisabled={busy}
|
||||
footer={<>
|
||||
<Button onClick={() => setRemoveLink(null)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="danger" onClick={() => void remove()} disabled={busy}>
|
||||
Remove link
|
||||
</Button>
|
||||
</>}
|
||||
>
|
||||
<p>
|
||||
Remove account <strong>{removeLink?.account_id}</strong> from this
|
||||
identity? The account itself is not deleted.
|
||||
</p>
|
||||
</Dialog>
|
||||
</AdminPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function draftFromIdentity(item: IdentityItem): IdentityDraft {
|
||||
return {
|
||||
display_name: item.display_name ?? "",
|
||||
external_subject: item.external_subject ?? "",
|
||||
source: item.source
|
||||
};
|
||||
}
|
||||
|
||||
function draftKey(value: IdentityDraft): string {
|
||||
return JSON.stringify({
|
||||
display_name: value.display_name?.trim() || null,
|
||||
external_subject: value.external_subject?.trim() || null,
|
||||
source: value.source.trim()
|
||||
});
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default, identityModule } from "./module";
|
||||
export * from "./api/identities";
|
||||
export { default as IdentityAdminPage } from "./features/IdentityAdminPage";
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createElement, lazy } from "react";
|
||||
import type {
|
||||
AdminSectionsUiCapability,
|
||||
PlatformWebModule
|
||||
} from "@govoplan/core-webui";
|
||||
import "./styles/identity.css";
|
||||
|
||||
const IdentityAdminPage = lazy(() => import("./features/IdentityAdminPage"));
|
||||
|
||||
const readScopes = [
|
||||
"identity:identity:read",
|
||||
"identity:identity:admin",
|
||||
"identity:account_link:admin",
|
||||
"system:accounts:read"
|
||||
];
|
||||
|
||||
const adminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
{
|
||||
id: "system-identities",
|
||||
moduleId: "identity",
|
||||
kind: "management",
|
||||
surfaceId: "identity.admin.directory",
|
||||
label: "Identity directory",
|
||||
group: "SYSTEM",
|
||||
order: 30,
|
||||
anyOf: readScopes,
|
||||
render: ({ settings, auth }) =>
|
||||
createElement(IdentityAdminPage, { settings, auth })
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const identityModule: PlatformWebModule = {
|
||||
id: "identity",
|
||||
label: "Identity",
|
||||
version: "0.1.18",
|
||||
dependencies: [],
|
||||
optionalDependencies: ["access", "audit", "idm"],
|
||||
viewSurfaces: [
|
||||
{
|
||||
id: "identity.admin.directory",
|
||||
moduleId: "identity",
|
||||
kind: "section",
|
||||
label: "Identity directory",
|
||||
order: 30
|
||||
},
|
||||
{
|
||||
id: "identity.admin.account-links",
|
||||
moduleId: "identity",
|
||||
kind: "section",
|
||||
label: "Identity account links",
|
||||
parentId: "identity.admin.directory",
|
||||
order: 20
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
"admin.sections": adminSections
|
||||
}
|
||||
};
|
||||
|
||||
export default identityModule;
|
||||
@@ -0,0 +1,25 @@
|
||||
.identity-admin-page .identity-admin-workspace {
|
||||
min-height: 34rem;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-list {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-detail {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-scope-note {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.identity-admin-page .identity-admin-settings {
|
||||
margin: 0;
|
||||
max-height: 14rem;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const moduleSource = readFileSync("src/module.ts", "utf8");
|
||||
const page = readFileSync("src/features/IdentityAdminPage.tsx", "utf8");
|
||||
const api = readFileSync("src/api/identities.ts", "utf8");
|
||||
|
||||
assert.match(moduleSource, /"admin.sections": adminSections/);
|
||||
assert.match(moduleSource, /identity\.admin\.directory/);
|
||||
assert.match(page, /<AdminPageLayout/);
|
||||
assert.match(page, /<PageActionBar/);
|
||||
assert.match(page, /refreshable/);
|
||||
assert.match(page, /saveAction=/);
|
||||
assert.match(page, /useUnsavedDraftGuard/);
|
||||
assert.match(page, /management_scope/);
|
||||
assert.match(page, /Promote/);
|
||||
assert.match(api, /account-links/);
|
||||
assert.match(api, /include_inactive/);
|
||||
|
||||
console.log("Identity administration UI structural contract passed.");
|
||||
Reference in New Issue
Block a user