Implement identity trust module
This commit is contained in:
@@ -0,0 +1,636 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.identity_trust import (
|
||||
AssuranceCheckRequest,
|
||||
AssuranceDecision,
|
||||
DeviceKeyRef,
|
||||
DeviceKeyRegistration,
|
||||
KeyAccessDecision,
|
||||
KeyAccessRequest,
|
||||
KeyEpochRef,
|
||||
KeyEpochRotationRequest,
|
||||
)
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_identity_trust.backend.db.models import (
|
||||
AssuranceEvidence,
|
||||
DevicePublicKey,
|
||||
KeyAccessDecisionRecord,
|
||||
TrustKeyEpoch,
|
||||
)
|
||||
|
||||
|
||||
class IdentityTrustError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class SqlIdentityTrustService:
|
||||
def register_device_key(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: DeviceKeyRegistration,
|
||||
) -> DeviceKeyRef:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, request.tenant_id)
|
||||
actor_id = _account_id(principal)
|
||||
if request.account_id != actor_id and not _has_scope(
|
||||
principal, "identity_trust:device:admin"
|
||||
):
|
||||
raise IdentityTrustError(
|
||||
"A device key can only be registered for the acting account."
|
||||
)
|
||||
digest = _digest(_registration_payload(request))
|
||||
existing = db.scalar(
|
||||
select(DevicePublicKey).where(
|
||||
DevicePublicKey.tenant_id == request.tenant_id,
|
||||
DevicePublicKey.key_id == request.key_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
if (
|
||||
existing.registration_digest != digest
|
||||
or existing.idempotency_key != request.idempotency_key
|
||||
):
|
||||
raise IdentityTrustError(
|
||||
"The public key id already exists with different evidence."
|
||||
)
|
||||
return _device_ref(existing)
|
||||
replay = db.scalar(
|
||||
select(DevicePublicKey).where(
|
||||
DevicePublicKey.tenant_id == request.tenant_id,
|
||||
DevicePublicKey.idempotency_key == request.idempotency_key,
|
||||
)
|
||||
)
|
||||
if replay is not None:
|
||||
if replay.registration_digest != digest:
|
||||
raise IdentityTrustError(
|
||||
"The idempotency key was used for different public-key data."
|
||||
)
|
||||
return _device_ref(replay)
|
||||
item = DevicePublicKey(
|
||||
tenant_id=request.tenant_id,
|
||||
identity_id=request.identity_id,
|
||||
account_id=request.account_id,
|
||||
device_id=request.device_id,
|
||||
key_id=request.key_id,
|
||||
algorithm=request.algorithm,
|
||||
public_jwk=dict(request.public_jwk),
|
||||
purpose=request.purpose,
|
||||
assurance_level=request.assurance_level,
|
||||
attestation_ref=request.attestation_ref,
|
||||
status="active",
|
||||
epoch=1,
|
||||
registration_digest=digest,
|
||||
idempotency_key=request.idempotency_key,
|
||||
registered_at=_as_utc(utcnow()),
|
||||
expires_at=request.expires_at,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
return _device_ref(item)
|
||||
|
||||
def revoke_device_key(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
key_id: str,
|
||||
expected_epoch: int,
|
||||
reason: str,
|
||||
) -> DeviceKeyRef:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, tenant_id)
|
||||
item = _device_key(db, tenant_id=tenant_id, key_id=key_id, lock=True)
|
||||
if item.account_id != _account_id(principal) and not _has_scope(
|
||||
principal, "identity_trust:device:admin"
|
||||
):
|
||||
raise IdentityTrustError(
|
||||
"The acting account cannot revoke this device key."
|
||||
)
|
||||
if expected_epoch != item.epoch:
|
||||
raise IdentityTrustError(
|
||||
"The device key changed; reload before revoking it."
|
||||
)
|
||||
if item.status == "revoked":
|
||||
if item.revocation_reason != reason.strip():
|
||||
raise IdentityTrustError(
|
||||
"The device key is already revoked for another reason."
|
||||
)
|
||||
return _device_ref(item)
|
||||
item.status = "revoked"
|
||||
item.revoked_at = _as_utc(utcnow())
|
||||
item.revocation_reason = _required(reason, "revocation reason")
|
||||
item.epoch += 1
|
||||
item.updated_by = _account_id(principal)
|
||||
db.flush()
|
||||
return _device_ref(item)
|
||||
|
||||
def list_device_keys(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
active_only: bool = True,
|
||||
) -> tuple[DeviceKeyRef, ...]:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, tenant_id)
|
||||
if account_id != _account_id(principal) and not _has_scope(
|
||||
principal, "identity_trust:device:read_all"
|
||||
):
|
||||
raise IdentityTrustError(
|
||||
"The acting account cannot inspect these device keys."
|
||||
)
|
||||
statement = select(DevicePublicKey).where(
|
||||
DevicePublicKey.tenant_id == tenant_id,
|
||||
DevicePublicKey.account_id == account_id,
|
||||
)
|
||||
if active_only:
|
||||
statement = statement.where(DevicePublicKey.status == "active")
|
||||
return tuple(
|
||||
_device_ref(item)
|
||||
for item in db.scalars(
|
||||
statement.order_by(
|
||||
DevicePublicKey.registered_at.desc(),
|
||||
DevicePublicKey.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def rotate_epoch(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: KeyEpochRotationRequest,
|
||||
) -> KeyEpochRef:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, request.tenant_id)
|
||||
digest = _digest(
|
||||
{
|
||||
"tenant_id": request.tenant_id,
|
||||
"subject_kind": request.subject_kind,
|
||||
"subject_id": request.subject_id,
|
||||
"reason": request.reason,
|
||||
"access_decision_ref": request.access_decision_ref,
|
||||
"history_policy": request.history_policy,
|
||||
"previous_epoch": request.previous_epoch,
|
||||
}
|
||||
)
|
||||
replay = db.scalar(
|
||||
select(TrustKeyEpoch).where(
|
||||
TrustKeyEpoch.tenant_id == request.tenant_id,
|
||||
TrustKeyEpoch.idempotency_key == request.idempotency_key,
|
||||
)
|
||||
)
|
||||
if replay is not None:
|
||||
if replay.request_digest != digest:
|
||||
raise IdentityTrustError(
|
||||
"The epoch idempotency key was used with another request."
|
||||
)
|
||||
return _epoch_ref(replay)
|
||||
current = db.scalar(
|
||||
select(TrustKeyEpoch)
|
||||
.where(
|
||||
TrustKeyEpoch.tenant_id == request.tenant_id,
|
||||
TrustKeyEpoch.subject_kind == request.subject_kind,
|
||||
TrustKeyEpoch.subject_id == request.subject_id,
|
||||
TrustKeyEpoch.state == "active",
|
||||
)
|
||||
.order_by(TrustKeyEpoch.epoch.desc())
|
||||
.with_for_update()
|
||||
)
|
||||
current_epoch = current.epoch if current else None
|
||||
if request.previous_epoch != current_epoch:
|
||||
raise IdentityTrustError(
|
||||
"The key epoch changed; reload before rotating it."
|
||||
)
|
||||
if current is not None:
|
||||
current.state = "superseded"
|
||||
item = TrustKeyEpoch(
|
||||
tenant_id=request.tenant_id,
|
||||
subject_kind=request.subject_kind,
|
||||
subject_id=request.subject_id,
|
||||
epoch=(current_epoch or 0) + 1,
|
||||
previous_epoch=current_epoch,
|
||||
state="active",
|
||||
history_policy=_required(request.history_policy, "history policy"),
|
||||
reason=request.reason.strip(),
|
||||
access_decision_ref=request.access_decision_ref.strip(),
|
||||
idempotency_key=request.idempotency_key,
|
||||
request_digest=digest,
|
||||
effective_at=_as_utc(utcnow()),
|
||||
created_by=_account_id(principal),
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
return _epoch_ref(item)
|
||||
|
||||
def resolve_epoch(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
subject_kind: str,
|
||||
subject_id: str,
|
||||
epoch: int | None = None,
|
||||
) -> KeyEpochRef | None:
|
||||
db = _session(session)
|
||||
statement = select(TrustKeyEpoch).where(
|
||||
TrustKeyEpoch.tenant_id == tenant_id,
|
||||
TrustKeyEpoch.subject_kind == subject_kind,
|
||||
TrustKeyEpoch.subject_id == subject_id,
|
||||
)
|
||||
if epoch is None:
|
||||
statement = statement.where(TrustKeyEpoch.state == "active")
|
||||
else:
|
||||
statement = statement.where(TrustKeyEpoch.epoch == epoch)
|
||||
item = db.scalar(statement.order_by(TrustKeyEpoch.epoch.desc()))
|
||||
return _epoch_ref(item) if item else None
|
||||
|
||||
def decide_key_access(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: KeyAccessRequest,
|
||||
) -> KeyAccessDecision:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, request.tenant_id)
|
||||
request_payload = {
|
||||
"tenant_id": request.tenant_id,
|
||||
"account_id": request.account_id,
|
||||
"device_key_id": request.device_key_id,
|
||||
"subject_kind": request.subject_kind,
|
||||
"subject_id": request.subject_id,
|
||||
"key_epoch": request.key_epoch,
|
||||
"access_decision_ref": request.access_decision_ref,
|
||||
"purpose": request.purpose,
|
||||
"function_assignment_id": request.function_assignment_id,
|
||||
"delegation_id": request.delegation_id,
|
||||
"resource_ref": request.resource_ref,
|
||||
}
|
||||
digest = _digest(request_payload)
|
||||
decision_ref = f"identity-trust:key-access:{digest}"
|
||||
existing = db.scalar(
|
||||
select(KeyAccessDecisionRecord).where(
|
||||
KeyAccessDecisionRecord.tenant_id == request.tenant_id,
|
||||
KeyAccessDecisionRecord.decision_ref == decision_ref,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
return _decision_ref(db, existing)
|
||||
device = db.scalar(
|
||||
select(DevicePublicKey).where(
|
||||
DevicePublicKey.tenant_id == request.tenant_id,
|
||||
DevicePublicKey.key_id == request.device_key_id,
|
||||
)
|
||||
)
|
||||
epoch = db.scalar(
|
||||
select(TrustKeyEpoch).where(
|
||||
TrustKeyEpoch.tenant_id == request.tenant_id,
|
||||
TrustKeyEpoch.subject_kind == request.subject_kind,
|
||||
TrustKeyEpoch.subject_id == request.subject_id,
|
||||
TrustKeyEpoch.epoch == request.key_epoch,
|
||||
)
|
||||
)
|
||||
allowed = True
|
||||
reason = "Current device, epoch, and upstream access evidence are valid."
|
||||
if request.account_id != _account_id(principal):
|
||||
allowed = False
|
||||
reason = "The access request does not belong to the acting account."
|
||||
elif device is None or device.account_id != request.account_id:
|
||||
allowed = False
|
||||
reason = "The requested device key is not registered for this account."
|
||||
elif _device_status(device) != "active":
|
||||
allowed = False
|
||||
reason = "The requested device key is not active."
|
||||
elif epoch is None or epoch.state != "active":
|
||||
allowed = False
|
||||
reason = "The requested key epoch is not active."
|
||||
item = KeyAccessDecisionRecord(
|
||||
tenant_id=request.tenant_id,
|
||||
decision_ref=decision_ref,
|
||||
request_digest=digest,
|
||||
account_id=request.account_id,
|
||||
device_key_id=request.device_key_id,
|
||||
subject_kind=request.subject_kind,
|
||||
subject_id=request.subject_id,
|
||||
key_epoch=request.key_epoch,
|
||||
access_decision_ref=request.access_decision_ref,
|
||||
purpose=request.purpose,
|
||||
allowed=allowed,
|
||||
reason=reason,
|
||||
resource_ref=request.resource_ref,
|
||||
function_assignment_id=request.function_assignment_id,
|
||||
delegation_id=request.delegation_id,
|
||||
provenance={
|
||||
"upstream_access_decision_ref": request.access_decision_ref,
|
||||
"requested_at": request.requested_at.isoformat(),
|
||||
"cryptographic_material_released": False,
|
||||
},
|
||||
)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
return _decision_ref(db, item)
|
||||
|
||||
def verify_assurance(
|
||||
self,
|
||||
session: object,
|
||||
principal: object,
|
||||
*,
|
||||
request: AssuranceCheckRequest,
|
||||
) -> AssuranceDecision:
|
||||
db = _session(session)
|
||||
_require_tenant(principal, request.tenant_id)
|
||||
if request.account_id != _account_id(principal):
|
||||
return AssuranceDecision(
|
||||
allowed=False,
|
||||
reason="Assurance evidence belongs to another account.",
|
||||
)
|
||||
evidence = db.scalar(
|
||||
select(AssuranceEvidence).where(
|
||||
AssuranceEvidence.tenant_id == request.tenant_id,
|
||||
AssuranceEvidence.account_id == request.account_id,
|
||||
AssuranceEvidence.evidence_ref == request.evidence_ref,
|
||||
)
|
||||
)
|
||||
now = _as_utc(request.evaluated_at)
|
||||
if evidence is None:
|
||||
return AssuranceDecision(
|
||||
allowed=False,
|
||||
reason="Assurance evidence was not found.",
|
||||
)
|
||||
age = (now - _as_utc(evidence.verified_at)).total_seconds()
|
||||
allowed = (
|
||||
_as_utc(evidence.expires_at) >= now
|
||||
and age <= request.maximum_age_seconds
|
||||
and _assurance_rank(evidence.assurance_level)
|
||||
>= _assurance_rank(request.minimum_level)
|
||||
and (
|
||||
request.device_key_id is None
|
||||
or evidence.device_key_id == request.device_key_id
|
||||
)
|
||||
)
|
||||
return AssuranceDecision(
|
||||
allowed=allowed,
|
||||
reason=(
|
||||
"Assurance evidence satisfies the requested level and age."
|
||||
if allowed
|
||||
else "Assurance evidence is stale, insufficient, expired, or for another device."
|
||||
),
|
||||
assurance_level=evidence.assurance_level,
|
||||
evidence_ref=evidence.evidence_ref,
|
||||
verified_at=evidence.verified_at,
|
||||
expires_at=evidence.expires_at,
|
||||
provenance={"provider_id": evidence.provider_id},
|
||||
)
|
||||
|
||||
|
||||
def record_assurance_evidence(
|
||||
session: Session,
|
||||
principal: object,
|
||||
*,
|
||||
tenant_id: str,
|
||||
account_id: str,
|
||||
evidence_ref: str,
|
||||
assurance_level: str,
|
||||
provider_id: str,
|
||||
verified_at: datetime,
|
||||
expires_at: datetime,
|
||||
device_key_id: str | None = None,
|
||||
provenance: Mapping[str, object] | None = None,
|
||||
) -> AssuranceEvidence:
|
||||
_require_tenant(principal, tenant_id)
|
||||
if _as_utc(expires_at) <= _as_utc(verified_at):
|
||||
raise IdentityTrustError("Assurance expiry must follow verification.")
|
||||
existing = session.scalar(
|
||||
select(AssuranceEvidence).where(
|
||||
AssuranceEvidence.tenant_id == tenant_id,
|
||||
AssuranceEvidence.evidence_ref == evidence_ref,
|
||||
)
|
||||
)
|
||||
payload = {
|
||||
"account_id": account_id,
|
||||
"device_key_id": device_key_id,
|
||||
"assurance_level": assurance_level,
|
||||
"provider_id": provider_id,
|
||||
"verified_at": verified_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat(),
|
||||
"provenance": dict(provenance or {}),
|
||||
}
|
||||
if existing is not None:
|
||||
current = {
|
||||
"account_id": existing.account_id,
|
||||
"device_key_id": existing.device_key_id,
|
||||
"assurance_level": existing.assurance_level,
|
||||
"provider_id": existing.provider_id,
|
||||
"verified_at": existing.verified_at.isoformat(),
|
||||
"expires_at": existing.expires_at.isoformat(),
|
||||
"provenance": dict(existing.provenance),
|
||||
}
|
||||
if _digest(current) != _digest(payload):
|
||||
raise IdentityTrustError(
|
||||
"The assurance reference already exists with different evidence."
|
||||
)
|
||||
return existing
|
||||
item = AssuranceEvidence(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account_id,
|
||||
device_key_id=device_key_id,
|
||||
evidence_ref=_required(evidence_ref, "evidence reference"),
|
||||
assurance_level=_required(assurance_level, "assurance level"),
|
||||
provider_id=_required(provider_id, "provider id"),
|
||||
verified_at=_as_utc(verified_at),
|
||||
expires_at=_as_utc(expires_at),
|
||||
provenance=dict(provenance or {}),
|
||||
recorded_by=_account_id(principal),
|
||||
)
|
||||
session.add(item)
|
||||
session.flush()
|
||||
return item
|
||||
|
||||
|
||||
def _device_key(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
key_id: str,
|
||||
lock: bool = False,
|
||||
) -> DevicePublicKey:
|
||||
statement = select(DevicePublicKey).where(
|
||||
DevicePublicKey.tenant_id == tenant_id,
|
||||
DevicePublicKey.key_id == key_id,
|
||||
)
|
||||
if lock:
|
||||
statement = statement.with_for_update()
|
||||
item = session.scalar(statement)
|
||||
if item is None:
|
||||
raise IdentityTrustError("Device key not found.")
|
||||
return item
|
||||
|
||||
|
||||
def _device_ref(item: DevicePublicKey) -> DeviceKeyRef:
|
||||
return DeviceKeyRef(
|
||||
tenant_id=item.tenant_id,
|
||||
identity_id=item.identity_id,
|
||||
account_id=item.account_id,
|
||||
device_id=item.device_id,
|
||||
key_id=item.key_id,
|
||||
algorithm=item.algorithm,
|
||||
public_jwk=dict(item.public_jwk),
|
||||
purpose=item.purpose, # type: ignore[arg-type]
|
||||
assurance_level=item.assurance_level,
|
||||
status=_device_status(item), # type: ignore[arg-type]
|
||||
epoch=item.epoch,
|
||||
registered_at=item.registered_at,
|
||||
attestation_ref=item.attestation_ref,
|
||||
expires_at=item.expires_at,
|
||||
revoked_at=item.revoked_at,
|
||||
revocation_reason=item.revocation_reason,
|
||||
provenance={"registration_digest": item.registration_digest},
|
||||
)
|
||||
|
||||
|
||||
def _epoch_ref(item: TrustKeyEpoch) -> KeyEpochRef:
|
||||
return KeyEpochRef(
|
||||
tenant_id=item.tenant_id,
|
||||
subject_kind=item.subject_kind, # type: ignore[arg-type]
|
||||
subject_id=item.subject_id,
|
||||
epoch=item.epoch,
|
||||
state=item.state, # type: ignore[arg-type]
|
||||
history_policy=item.history_policy,
|
||||
effective_at=item.effective_at,
|
||||
previous_epoch=item.previous_epoch,
|
||||
reason=item.reason,
|
||||
access_decision_ref=item.access_decision_ref,
|
||||
provenance={"request_digest": item.request_digest},
|
||||
)
|
||||
|
||||
|
||||
def _decision_ref(
|
||||
session: Session,
|
||||
item: KeyAccessDecisionRecord,
|
||||
) -> KeyAccessDecision:
|
||||
device = session.scalar(
|
||||
select(DevicePublicKey).where(
|
||||
DevicePublicKey.tenant_id == item.tenant_id,
|
||||
DevicePublicKey.key_id == item.device_key_id,
|
||||
)
|
||||
)
|
||||
epoch = session.scalar(
|
||||
select(TrustKeyEpoch).where(
|
||||
TrustKeyEpoch.tenant_id == item.tenant_id,
|
||||
TrustKeyEpoch.subject_kind == item.subject_kind,
|
||||
TrustKeyEpoch.subject_id == item.subject_id,
|
||||
TrustKeyEpoch.epoch == item.key_epoch,
|
||||
)
|
||||
)
|
||||
return KeyAccessDecision(
|
||||
allowed=item.allowed,
|
||||
decision_ref=item.decision_ref,
|
||||
reason=item.reason,
|
||||
device_key=_device_ref(device) if device else None,
|
||||
epoch=_epoch_ref(epoch) if epoch else None,
|
||||
audit_event_ref=f"identity-trust-decision:{item.id}",
|
||||
requirements=() if item.allowed else ("current_device", "current_epoch"),
|
||||
provenance=dict(item.provenance),
|
||||
)
|
||||
|
||||
|
||||
def _registration_payload(request: DeviceKeyRegistration) -> dict[str, object]:
|
||||
return {
|
||||
"tenant_id": request.tenant_id,
|
||||
"identity_id": request.identity_id,
|
||||
"account_id": request.account_id,
|
||||
"device_id": request.device_id,
|
||||
"key_id": request.key_id,
|
||||
"algorithm": request.algorithm,
|
||||
"public_jwk": dict(request.public_jwk),
|
||||
"purpose": request.purpose,
|
||||
"assurance_level": request.assurance_level,
|
||||
"attestation_ref": request.attestation_ref,
|
||||
"expires_at": request.expires_at.isoformat() if request.expires_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _device_status(item: DevicePublicKey) -> str:
|
||||
if item.status == "active" and item.expires_at is not None:
|
||||
if _as_utc(item.expires_at) < _as_utc(utcnow()):
|
||||
return "expired"
|
||||
return item.status
|
||||
|
||||
|
||||
def _assurance_rank(value: str) -> int:
|
||||
return {
|
||||
"none": 0,
|
||||
"software": 1,
|
||||
"mfa": 2,
|
||||
"hardware": 3,
|
||||
"high": 4,
|
||||
}.get(value.strip().lower(), 0)
|
||||
|
||||
|
||||
def _session(value: object) -> Session:
|
||||
if not isinstance(value, Session):
|
||||
raise TypeError("Identity Trust requires a SQLAlchemy Session.")
|
||||
return value
|
||||
|
||||
|
||||
def _require_tenant(principal: object, tenant_id: str) -> None:
|
||||
if str(getattr(principal, "tenant_id", "")) != tenant_id:
|
||||
raise IdentityTrustError("Cross-tenant identity-trust access is denied.")
|
||||
|
||||
|
||||
def _account_id(principal: object) -> str:
|
||||
return _required(str(getattr(principal, "account_id", "")), "account id")
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
if hasattr(principal, "has"):
|
||||
return bool(principal.has(scope))
|
||||
return scope in set(getattr(principal, "scopes", ()))
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
cleaned = value.strip()
|
||||
if not cleaned:
|
||||
raise IdentityTrustError(f"{label.capitalize()} is required.")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _digest(value: Mapping[str, object]) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=True,
|
||||
default=str,
|
||||
)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"IdentityTrustError",
|
||||
"SqlIdentityTrustService",
|
||||
"record_assurance_evidence",
|
||||
]
|
||||
Reference in New Issue
Block a user