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 IdentityTrustAccessDenied(IdentityTrustError): 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 not _may_read_account(principal, account_id): raise IdentityTrustAccessDenied( "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 list_assurance_evidence( self, session: object, principal: object, *, tenant_id: str, account_id: str, active_only: bool = False, limit: int = 200, ) -> tuple[AssuranceEvidence, ...]: db = _session(session) _require_tenant(principal, tenant_id) if not _may_read_account(principal, account_id): raise IdentityTrustAccessDenied( "The acting account cannot inspect this assurance evidence." ) statement = select(AssuranceEvidence).where( AssuranceEvidence.tenant_id == tenant_id, AssuranceEvidence.account_id == account_id, ) if active_only: statement = statement.where( AssuranceEvidence.expires_at >= _as_utc(utcnow()) ) return tuple( db.scalars( statement.order_by( AssuranceEvidence.verified_at.desc(), AssuranceEvidence.id, ).limit(max(1, min(int(limit), 500))) ) ) def list_epochs( self, session: object, principal: object, *, tenant_id: str, subject_kind: str, subject_id: str, limit: int = 200, ) -> tuple[KeyEpochRef, ...]: db = _session(session) _require_tenant(principal, tenant_id) if not _has_scope(principal, "identity_trust:device:admin"): raise IdentityTrustAccessDenied( "Identity Trust administration is required to inspect key epochs." ) values = db.scalars( select(TrustKeyEpoch) .where( TrustKeyEpoch.tenant_id == tenant_id, TrustKeyEpoch.subject_kind == subject_kind, TrustKeyEpoch.subject_id == subject_id, ) .order_by(TrustKeyEpoch.epoch.desc()) .limit(max(1, min(int(limit), 500))) ) return tuple(_epoch_ref(value) for value in values) def list_key_access_decisions( self, session: object, principal: object, *, tenant_id: str, account_id: str, limit: int = 200, ) -> tuple[KeyAccessDecisionRecord, ...]: db = _session(session) _require_tenant(principal, tenant_id) if not ( _has_scope(principal, "identity_trust:key_access:approve") or _has_scope(principal, "identity_trust:device:admin") ): raise IdentityTrustAccessDenied( "Key-access decision authority is required to inspect decisions." ) return tuple( db.scalars( select(KeyAccessDecisionRecord) .where( KeyAccessDecisionRecord.tenant_id == tenant_id, KeyAccessDecisionRecord.account_id == account_id, ) .order_by( KeyAccessDecisionRecord.created_at.desc(), KeyAccessDecisionRecord.id, ) .limit(max(1, min(int(limit), 500))) ) ) 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 IdentityTrustAccessDenied( "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 _may_read_account(principal: object, account_id: str) -> bool: return ( account_id == _account_id(principal) or _has_scope(principal, "identity_trust:device:read_all") or _has_scope(principal, "identity_trust:device:admin") ) 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__ = [ "IdentityTrustAccessDenied", "IdentityTrustError", "SqlIdentityTrustService", "record_assurance_evidence", ]