Implement identity trust module

This commit is contained in:
2026-08-01 20:57:27 +02:00
parent 765fcd5a2d
commit 0ffc8b6b0f
20 changed files with 2058 additions and 5 deletions
+14
View File
@@ -0,0 +1,14 @@
# GovOPlaN Identity Trust Codex Guide
## Scope
This module owns public device keys, assurance evidence, trust epochs, and
auditable key-access decisions. Access owns authentication and authorization;
Encryption owns key custody and rewrapping. Never persist private keys or
plaintext content here.
## Documentation Contract
- Update manifest-driven user/admin documentation with behavior changes.
- Keep Gitea issues as the delivery source of truth.
- Do not claim cryptographic protection or certification from trust metadata.
+20 -1
View File
@@ -1,5 +1,24 @@
# govoplan-identity-trust # GovOPlaN Identity Trust
<!-- govoplan-repository-type:start --> <!-- govoplan-repository-type:start -->
**Repository type:** module (platform). **Repository type:** module (platform).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
`govoplan-identity-trust` owns public device keys, key epochs, bounded
authentication-assurance evidence, and auditable key-access trust decisions.
It deliberately does not own login sessions, resource authorization, private
keys, encryption, or plaintext.
The headless module exposes `identity_trust.directory` and
`identity_trust.assurance`. Access or Policy must approve resource access
first; Identity Trust then verifies the acting account, active public device
key, current subject epoch, and assurance evidence. Encryption providers may
consume that decision to rewrap a key, but no key material is returned by this
module.
Focused verification:
```bash
PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
```
+52 -4
View File
@@ -9,15 +9,15 @@ recipient trust.
## Responsibilities ## Responsibilities
The module should eventually own: The module owns:
- public key directory - public key directory
- per-device encryption keys - per-device encryption keys
- account or identity signing-key references - account or identity public signing-key references
- device registration and revocation - device registration and revocation
- key rotation and key epochs - key rotation and key epochs
- assurance metadata from OIDC, WebAuthn, SAML, or other providers - assurance metadata from OIDC, WebAuthn, SAML, or other providers
- recovery and lost-device policy hooks - bounded assurance evidence and recovery/lost-device policy hooks
- audited key-access and rewrap decisions - audited key-access and rewrap decisions
It should not own login sessions, tenant membership, groups, functions, roles, It should not own login sessions, tenant membership, groups, functions, roles,
@@ -34,4 +34,52 @@ For encrypted role/function postboxes, identity trust should be able to:
- emit audit events for key fetch or rewrap operations - emit audit events for key fetch or rewrap operations
The module must never require an identity provider or directory service to see The module must never require an identity provider or directory service to see
message plaintext or private postbox keys. message plaintext or private Postbox keys.
## Implemented Boundary
The SQL-backed module now provides:
- tenant-bound public JWK registration with replay safety and explicit
revocation;
- effective key epochs for identities, accounts, functions, Postboxes, and
external recipients, including an explicit retained-history policy;
- immutable allow/deny key-access decisions bound to the upstream Access
decision, acting account, device, epoch, purpose, assignment/delegation, and
resource;
- bounded assurance evidence with provider, level, device, verification,
expiry, and maximum-age checks;
- migrations, uninstall guards, permissions, APIs, capability contracts, and
manifest-driven user/admin documentation.
Only public keys are accepted. JWK private parameters are rejected by the Core
contract. Key-access decisions state explicitly that no cryptographic material
was released. A concrete Encryption provider performs any later key rewrap.
## Epoch And History Semantics
A function or Postbox has one active epoch. Rotation supersedes, but does not
rewrite, the previous epoch. The grant records one explicit history policy,
with `all_retained` as the accepted baseline for a new incumbent. Other bounded
policies may be selected by Policy. Rotation does not itself grant resource
access and ownership transfer does not transfer a private key.
Revocation stops future server-mediated release or rewrap decisions. It cannot
erase plaintext, exports, printouts, or keys already obtained by an endpoint.
That limitation must remain visible to users and operators.
## Recovery
Database recovery restores public keys, epochs, assurance references, and
decision evidence. It cannot restore private device keys. Lost-device recovery
therefore registers a new device key, rotates affected epochs under a separate
authorized workflow, and leaves the old key revoked. Encryption key custody
and quorum recovery remain with `govoplan-encryption` and its selected provider.
## Current Limits
- There is no private-key custody or browser/device key generator.
- Attestation references are retained but no WebAuthn/OIDC attestation adapter
is selected yet.
- The module decides trust eligibility; it does not perform encryption,
decryption, signing, or rewrapping.
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-identity-trust"
version = "0.1.14"
description = "Public device-key, assurance, and key-epoch trust services for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_identity_trust = ["py.typed"]
[project.entry-points."govoplan.modules"]
identity_trust = "govoplan_identity_trust.backend.manifest:get_manifest"
+5
View File
@@ -0,0 +1,5 @@
"""GovOPlaN identity-trust module."""
from govoplan_identity_trust.backend.manifest import get_manifest
__all__ = ["get_manifest"]
@@ -0,0 +1 @@
"""Backend integration surface for govoplan-identity-trust."""
@@ -0,0 +1,13 @@
from govoplan_identity_trust.backend.db.models import (
AssuranceEvidence,
DevicePublicKey,
KeyAccessDecisionRecord,
TrustKeyEpoch,
)
__all__ = [
"AssuranceEvidence",
"DevicePublicKey",
"KeyAccessDecisionRecord",
"TrustKeyEpoch",
]
@@ -0,0 +1,190 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class DevicePublicKey(Base, TimestampMixin):
__tablename__ = "identity_trust_device_keys"
__table_args__ = (
UniqueConstraint("tenant_id", "key_id", name="uq_identity_trust_device_key"),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_identity_trust_device_idempotency",
),
Index(
"ix_identity_trust_device_account",
"tenant_id",
"account_id",
"status",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
identity_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
device_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
key_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
algorithm: Mapped[str] = mapped_column(String(120), nullable=False)
public_jwk: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
purpose: Mapped[str] = mapped_column(String(40), nullable=False)
assurance_level: Mapped[str] = mapped_column(String(80), nullable=False)
attestation_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
status: Mapped[str] = mapped_column(
String(30), default="active", nullable=False, index=True
)
epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
registration_digest: Mapped[str] = mapped_column(String(64), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
registered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
revocation_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
class TrustKeyEpoch(Base, TimestampMixin):
__tablename__ = "identity_trust_key_epochs"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"subject_kind",
"subject_id",
"epoch",
name="uq_identity_trust_key_epoch",
),
UniqueConstraint(
"tenant_id",
"idempotency_key",
name="uq_identity_trust_epoch_idempotency",
),
Index(
"ix_identity_trust_epoch_current",
"tenant_id",
"subject_kind",
"subject_id",
"state",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
epoch: Mapped[int] = mapped_column(Integer, nullable=False)
previous_epoch: Mapped[int | None] = mapped_column(Integer, nullable=True)
state: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
history_policy: Mapped[str] = mapped_column(String(80), nullable=False)
reason: Mapped[str] = mapped_column(Text, nullable=False)
access_decision_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
effective_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
class AssuranceEvidence(Base, TimestampMixin):
__tablename__ = "identity_trust_assurance_evidence"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"evidence_ref",
name="uq_identity_trust_assurance_ref",
),
Index(
"ix_identity_trust_assurance_account",
"tenant_id",
"account_id",
"verified_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
device_key_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
evidence_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
assurance_level: Mapped[str] = mapped_column(String(80), nullable=False)
provider_id: Mapped[str] = mapped_column(String(120), nullable=False)
verified_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
recorded_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
class KeyAccessDecisionRecord(Base, TimestampMixin):
__tablename__ = "identity_trust_key_access_decisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"decision_ref",
name="uq_identity_trust_key_access_decision",
),
Index(
"ix_identity_trust_key_access_subject",
"tenant_id",
"subject_kind",
"subject_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
decision_ref: Mapped[str] = mapped_column(String(255), nullable=False)
request_digest: Mapped[str] = mapped_column(String(64), nullable=False)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
device_key_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
subject_kind: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
subject_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
key_epoch: Mapped[int] = mapped_column(Integer, nullable=False)
access_decision_ref: Mapped[str] = mapped_column(String(1000), nullable=False)
purpose: Mapped[str] = mapped_column(String(255), nullable=False)
allowed: Mapped[bool] = mapped_column(nullable=False)
reason: Mapped[str] = mapped_column(Text, nullable=False)
resource_ref: Mapped[str | None] = mapped_column(String(1000), nullable=True)
function_assignment_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
delegation_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
provenance: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
__all__ = [
"AssuranceEvidence",
"DevicePublicKey",
"KeyAccessDecisionRecord",
"TrustKeyEpoch",
"new_uuid",
]
@@ -0,0 +1,222 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.identity_trust import (
CAPABILITY_IDENTITY_TRUST_ASSURANCE,
CAPABILITY_IDENTITY_TRUST_DIRECTORY,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationLink,
DocumentationTopic,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.db.base import Base
from govoplan_identity_trust.backend.db import models
from govoplan_identity_trust.backend.service import SqlIdentityTrustService
MODULE_ID = "identity_trust"
MODULE_NAME = "Identity Trust"
MODULE_VERSION = "0.1.14"
DEVICE_READ_SCOPE = "identity_trust:device:read"
DEVICE_WRITE_SCOPE = "identity_trust:device:write"
KEY_ACCESS_SCOPE = "identity_trust:key_access:approve"
ASSURANCE_SCOPE = "identity_trust:assurance:record"
ADMIN_SCOPE = "identity_trust:device:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Identity Trust",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
def _router(_context: ModuleContext):
from govoplan_identity_trust.backend.router import router
return router
def _service(_context: ModuleContext) -> SqlIdentityTrustService:
return SqlIdentityTrustService()
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
optional_dependencies=("access", "audit", "policy", "encryption", "postbox"),
provides_interfaces=(
ModuleInterfaceProvider(name="identity_trust.directory", version="1.0.0"),
ModuleInterfaceProvider(name="identity_trust.assurance", version="1.0.0"),
),
permissions=(
_permission(
DEVICE_READ_SCOPE,
"View device keys",
"View public device-key and trust state.",
),
_permission(
DEVICE_WRITE_SCOPE,
"Manage own device keys",
"Register and revoke public keys for the acting account.",
),
_permission(
KEY_ACCESS_SCOPE,
"Evaluate key access",
"Evaluate device and key-epoch trust after Access has approved a resource action.",
),
_permission(
ASSURANCE_SCOPE,
"Record assurance evidence",
"Record bounded assurance evidence from a trusted authentication provider.",
),
_permission(
ADMIN_SCOPE,
"Administer identity trust",
"Administer device keys, trust epochs, and assurance evidence.",
),
),
role_templates=(
RoleTemplate(
slug="identity_trust_user",
name="Identity trust user",
description="Manage own public device keys.",
permissions=(DEVICE_READ_SCOPE, DEVICE_WRITE_SCOPE),
),
RoleTemplate(
slug="identity_trust_officer",
name="Identity trust officer",
description="Administer trust epochs and assurance evidence.",
permissions=(
DEVICE_READ_SCOPE,
KEY_ACCESS_SCOPE,
ASSURANCE_SCOPE,
ADMIN_SCOPE,
),
),
),
route_factory=_router,
capability_factories={
CAPABILITY_IDENTITY_TRUST_DIRECTORY: _service,
CAPABILITY_IDENTITY_TRUST_ASSURANCE: _service,
},
capability_documentation={
CAPABILITY_IDENTITY_TRUST_DIRECTORY: CapabilityDocumentation(
label="Identity Trust directory",
summary="Resolves public device keys, key epochs, and auditable release decisions without private key material.",
contract_version="1.0.0",
),
CAPABILITY_IDENTITY_TRUST_ASSURANCE: CapabilityDocumentation(
label="Identity Trust assurance",
summary="Verifies bounded, recent assurance evidence for high-risk cryptographic operations.",
contract_version="1.0.0",
),
},
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
models.KeyAccessDecisionRecord,
models.AssuranceEvidence,
models.TrustKeyEpoch,
models.DevicePublicKey,
label="Identity Trust",
),
retirement_notes="Destructive retirement removes public-key, epoch, assurance, and key-access evidence after a database snapshot.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
models.DevicePublicKey,
models.TrustKeyEpoch,
models.AssuranceEvidence,
models.KeyAccessDecisionRecord,
label="Identity Trust",
),
),
documentation=(
DocumentationTopic(
id="identity-trust.device-keys",
title="Device keys and key epochs",
summary="Separate login authority from public device-key and cryptographic-access trust.",
body=(
"Identity Trust stores public keys only. Access first decides whether an account may reach a protected resource; Identity Trust then verifies the current device and key epoch and records an auditable decision. Function and Postbox history grants are explicit epoch policy, and revocation cannot erase plaintext already obtained."
),
layer="available",
documentation_types=("admin", "user"),
audience=("user", "administrator", "security_officer", "auditor"),
links=(
DocumentationLink(
label="Device-key trust and recovery boundary",
href="govoplan-identity-trust/docs/DEVICE_KEY_TRUST_CONCEPT.md",
kind="repository",
),
),
),
),
architecture=declared_module_architecture(
layer="institutional_foundation",
kind="foundation",
maturity="vertical_slice",
documentation_ref="docs/DEVICE_KEY_TRUST_CONCEPT.md",
test_ref="tests/test_identity_trust.py",
known_limits=(
"No private key custody, device attestation verifier, or cryptographic rewrap implementation is included.",
),
owned_concepts=(
"public device key",
"key epoch",
"assurance evidence",
"key-access trust decision",
),
non_owned_concepts=(
"login session",
"resource authorization",
"private key",
"content encryption",
),
migration_docs=("docs/DEVICE_KEY_TRUST_CONCEPT.md",),
recovery_docs=("docs/DEVICE_KEY_TRUST_CONCEPT.md",),
security_docs=("docs/DEVICE_KEY_TRUST_CONCEPT.md",),
operations_docs=("README.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
__all__ = [
"ADMIN_SCOPE",
"ASSURANCE_SCOPE",
"DEVICE_READ_SCOPE",
"DEVICE_WRITE_SCOPE",
"KEY_ACCESS_SCOPE",
"MODULE_ID",
"MODULE_VERSION",
"get_manifest",
"manifest",
]
@@ -0,0 +1 @@
"""Identity Trust Alembic migrations."""
@@ -0,0 +1 @@
"""Identity Trust migration versions."""
@@ -0,0 +1,190 @@
"""v0.1.14 identity-trust public keys and epochs
Revision ID: c3f5a7b9d1e2
Revises: None
Create Date: 2026-08-01 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c3f5a7b9d1e2"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"identity_trust_device_keys",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("identity_id", sa.String(255), nullable=False),
sa.Column("account_id", sa.String(255), nullable=False),
sa.Column("device_id", sa.String(255), nullable=False),
sa.Column("key_id", sa.String(255), nullable=False),
sa.Column("algorithm", sa.String(120), nullable=False),
sa.Column("public_jwk", sa.JSON(), nullable=False),
sa.Column("purpose", sa.String(40), nullable=False),
sa.Column("assurance_level", sa.String(80), nullable=False),
sa.Column("attestation_ref", sa.String(1000), nullable=True),
sa.Column("status", sa.String(30), nullable=False),
sa.Column("epoch", sa.Integer(), nullable=False),
sa.Column("registration_digest", sa.String(64), nullable=False),
sa.Column("idempotency_key", sa.String(255), nullable=False),
sa.Column("registered_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revocation_reason", sa.Text(), nullable=True),
sa.Column("created_by", sa.String(255), nullable=True),
sa.Column("updated_by", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("tenant_id", "key_id", name="uq_identity_trust_device_key"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_identity_trust_device_idempotency"
),
)
op.create_index(
"ix_identity_trust_device_account",
"identity_trust_device_keys",
["tenant_id", "account_id", "status"],
)
for name in (
"tenant_id",
"identity_id",
"account_id",
"device_id",
"key_id",
"status",
"expires_at",
"revoked_at",
):
op.create_index(
f"ix_identity_trust_device_keys_{name}",
"identity_trust_device_keys",
[name],
)
op.create_table(
"identity_trust_key_epochs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("subject_kind", sa.String(40), nullable=False),
sa.Column("subject_id", sa.String(255), nullable=False),
sa.Column("epoch", sa.Integer(), nullable=False),
sa.Column("previous_epoch", sa.Integer(), nullable=True),
sa.Column("state", sa.String(30), nullable=False),
sa.Column("history_policy", sa.String(80), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("access_decision_ref", sa.String(1000), nullable=False),
sa.Column("idempotency_key", sa.String(255), nullable=False),
sa.Column("request_digest", sa.String(64), nullable=False),
sa.Column("effective_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_by", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"tenant_id",
"subject_kind",
"subject_id",
"epoch",
name="uq_identity_trust_key_epoch",
),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_identity_trust_epoch_idempotency"
),
)
op.create_index(
"ix_identity_trust_epoch_current",
"identity_trust_key_epochs",
["tenant_id", "subject_kind", "subject_id", "state"],
)
for name in ("tenant_id", "subject_kind", "subject_id", "state"):
op.create_index(
f"ix_identity_trust_key_epochs_{name}", "identity_trust_key_epochs", [name]
)
op.create_table(
"identity_trust_assurance_evidence",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("account_id", sa.String(255), nullable=False),
sa.Column("device_key_id", sa.String(255), nullable=True),
sa.Column("evidence_ref", sa.String(1000), nullable=False),
sa.Column("assurance_level", sa.String(80), nullable=False),
sa.Column("provider_id", sa.String(120), nullable=False),
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("recorded_by", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"tenant_id", "evidence_ref", name="uq_identity_trust_assurance_ref"
),
)
op.create_index(
"ix_identity_trust_assurance_account",
"identity_trust_assurance_evidence",
["tenant_id", "account_id", "verified_at"],
)
for name in ("tenant_id", "account_id", "device_key_id", "expires_at"):
op.create_index(
f"ix_identity_trust_assurance_evidence_{name}",
"identity_trust_assurance_evidence",
[name],
)
op.create_table(
"identity_trust_key_access_decisions",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("decision_ref", sa.String(255), nullable=False),
sa.Column("request_digest", sa.String(64), nullable=False),
sa.Column("account_id", sa.String(255), nullable=False),
sa.Column("device_key_id", sa.String(255), nullable=False),
sa.Column("subject_kind", sa.String(40), nullable=False),
sa.Column("subject_id", sa.String(255), nullable=False),
sa.Column("key_epoch", sa.Integer(), nullable=False),
sa.Column("access_decision_ref", sa.String(1000), nullable=False),
sa.Column("purpose", sa.String(255), nullable=False),
sa.Column("allowed", sa.Boolean(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("resource_ref", sa.String(1000), nullable=True),
sa.Column("function_assignment_id", sa.String(255), nullable=True),
sa.Column("delegation_id", sa.String(255), nullable=True),
sa.Column("provenance", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint(
"tenant_id", "decision_ref", name="uq_identity_trust_key_access_decision"
),
)
op.create_index(
"ix_identity_trust_key_access_subject",
"identity_trust_key_access_decisions",
["tenant_id", "subject_kind", "subject_id", "created_at"],
)
for name in (
"tenant_id",
"account_id",
"device_key_id",
"subject_kind",
"subject_id",
):
op.create_index(
f"ix_identity_trust_key_access_decisions_{name}",
"identity_trust_key_access_decisions",
[name],
)
def downgrade() -> None:
op.drop_table("identity_trust_key_access_decisions")
op.drop_table("identity_trust_assurance_evidence")
op.drop_table("identity_trust_key_epochs")
op.drop_table("identity_trust_device_keys")
@@ -0,0 +1,256 @@
from __future__ import annotations
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.identity_trust import (
AssuranceCheckRequest,
DeviceKeyRegistration,
KeyAccessRequest,
KeyEpochRotationRequest,
)
from govoplan_core.db.session import get_session
from govoplan_identity_trust.backend.manifest import (
ADMIN_SCOPE,
ASSURANCE_SCOPE,
DEVICE_READ_SCOPE,
DEVICE_WRITE_SCOPE,
KEY_ACCESS_SCOPE,
)
from govoplan_identity_trust.backend.schemas import (
AssuranceCheckPayload,
AssuranceEvidencePayload,
AssuranceResponse,
DeviceKeyListResponse,
DeviceKeyRegisterPayload,
DeviceKeyResponse,
DeviceKeyRevokePayload,
EpochResponse,
EpochRotatePayload,
KeyAccessPayload,
KeyAccessResponse,
)
from govoplan_identity_trust.backend.service import (
IdentityTrustError,
SqlIdentityTrustService,
record_assurance_evidence,
)
router = APIRouter(prefix="/identity-trust", tags=["identity-trust"])
service = SqlIdentityTrustService()
def _require(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing one of: {', '.join(scopes)}",
)
def _error(exc: IdentityTrustError) -> HTTPException:
return HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc))
def _device_response(value) -> DeviceKeyResponse:
return DeviceKeyResponse(**asdict(value))
def _epoch_response(value) -> EpochResponse:
return EpochResponse(**asdict(value))
@router.post("/device-keys", response_model=DeviceKeyResponse)
def api_register_device_key(
payload: DeviceKeyRegisterPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DeviceKeyResponse:
_require(principal, DEVICE_WRITE_SCOPE, ADMIN_SCOPE)
try:
value = service.register_device_key(
session,
principal,
request=DeviceKeyRegistration(
tenant_id=principal.tenant_id,
**payload.model_dump(),
),
)
except (IdentityTrustError, ValueError) as exc:
raise _error(IdentityTrustError(str(exc))) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="identity_trust.device_key.registered",
object_type="device_public_key",
object_id=value.key_id,
details={"algorithm": value.algorithm, "private_material": False},
)
session.commit()
return _device_response(value)
@router.get("/device-keys", response_model=DeviceKeyListResponse)
def api_list_device_keys(
account_id: str,
active_only: bool = Query(default=True),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DeviceKeyListResponse:
_require(principal, DEVICE_READ_SCOPE, ADMIN_SCOPE)
try:
values = service.list_device_keys(
session,
principal,
tenant_id=principal.tenant_id,
account_id=account_id,
active_only=active_only,
)
except IdentityTrustError as exc:
raise _error(exc) from exc
return DeviceKeyListResponse(keys=[_device_response(value) for value in values])
@router.post("/device-keys/{key_id}/revoke", response_model=DeviceKeyResponse)
def api_revoke_device_key(
key_id: str,
payload: DeviceKeyRevokePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> DeviceKeyResponse:
_require(principal, DEVICE_WRITE_SCOPE, ADMIN_SCOPE)
try:
value = service.revoke_device_key(
session,
principal,
tenant_id=principal.tenant_id,
key_id=key_id,
expected_epoch=payload.expected_epoch,
reason=payload.reason,
)
except IdentityTrustError as exc:
raise _error(exc) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="identity_trust.device_key.revoked",
object_type="device_public_key",
object_id=key_id,
details={"reason": payload.reason, "epoch": value.epoch},
)
session.commit()
return _device_response(value)
@router.post("/epochs/rotate", response_model=EpochResponse)
def api_rotate_epoch(
payload: EpochRotatePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> EpochResponse:
_require(principal, ADMIN_SCOPE)
try:
value = service.rotate_epoch(
session,
principal,
request=KeyEpochRotationRequest(
tenant_id=principal.tenant_id,
**payload.model_dump(),
),
)
except (IdentityTrustError, ValueError) as exc:
raise _error(IdentityTrustError(str(exc))) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="identity_trust.key_epoch.rotated",
object_type=payload.subject_kind,
object_id=payload.subject_id,
details={"epoch": value.epoch, "history_policy": value.history_policy},
)
session.commit()
return _epoch_response(value)
@router.post("/key-access/decide", response_model=KeyAccessResponse)
def api_decide_key_access(
payload: KeyAccessPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> KeyAccessResponse:
_require(principal, KEY_ACCESS_SCOPE, ADMIN_SCOPE)
try:
value = service.decide_key_access(
session,
principal,
request=KeyAccessRequest(
tenant_id=principal.tenant_id,
**payload.model_dump(),
),
)
except (IdentityTrustError, ValueError) as exc:
raise _error(IdentityTrustError(str(exc))) from exc
session.commit()
data = asdict(value)
data["requirements"] = list(value.requirements)
return KeyAccessResponse(**data)
@router.post("/assurance/evidence", response_model=dict[str, object])
def api_record_assurance(
payload: AssuranceEvidencePayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
_require(principal, ASSURANCE_SCOPE, ADMIN_SCOPE)
try:
value = record_assurance_evidence(
session,
principal,
tenant_id=principal.tenant_id,
**payload.model_dump(),
)
except IdentityTrustError as exc:
raise _error(exc) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="identity_trust.assurance.recorded",
object_type="assurance_evidence",
object_id=value.id,
details={"provider_id": value.provider_id, "level": value.assurance_level},
)
session.commit()
return {"id": value.id, "evidence_ref": value.evidence_ref}
@router.post("/assurance/check", response_model=AssuranceResponse)
def api_check_assurance(
payload: AssuranceCheckPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> AssuranceResponse:
_require(principal, KEY_ACCESS_SCOPE, ADMIN_SCOPE)
value = service.verify_assurance(
session,
principal,
request=AssuranceCheckRequest(
tenant_id=principal.tenant_id,
**payload.model_dump(),
),
)
return AssuranceResponse(**asdict(value))
@@ -0,0 +1,133 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
class DeviceKeyRegisterPayload(BaseModel):
identity_id: str = Field(min_length=1, max_length=255)
account_id: str = Field(min_length=1, max_length=255)
device_id: str = Field(min_length=1, max_length=255)
key_id: str = Field(min_length=1, max_length=255)
algorithm: str = Field(min_length=1, max_length=120)
public_jwk: dict[str, Any]
purpose: Literal["encryption", "signing", "encryption_and_signing"] = "encryption"
assurance_level: str = Field(default="software", min_length=1, max_length=80)
attestation_ref: str | None = Field(default=None, max_length=1000)
expires_at: datetime | None = None
idempotency_key: str = Field(min_length=1, max_length=255)
class DeviceKeyRevokePayload(BaseModel):
expected_epoch: int = Field(ge=1)
reason: str = Field(min_length=1, max_length=4000)
class DeviceKeyResponse(BaseModel):
tenant_id: str
identity_id: str
account_id: str
device_id: str
key_id: str
algorithm: str
public_jwk: dict[str, Any]
purpose: str
assurance_level: str
status: str
epoch: int
registered_at: datetime
attestation_ref: str | None = None
expires_at: datetime | None = None
revoked_at: datetime | None = None
revocation_reason: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class DeviceKeyListResponse(BaseModel):
keys: list[DeviceKeyResponse]
class EpochRotatePayload(BaseModel):
subject_kind: Literal[
"identity", "account", "function", "postbox", "external_recipient"
]
subject_id: str = Field(min_length=1, max_length=255)
reason: str = Field(min_length=1, max_length=4000)
access_decision_ref: str = Field(min_length=1, max_length=1000)
idempotency_key: str = Field(min_length=1, max_length=255)
history_policy: str = Field(default="all_retained", min_length=1, max_length=80)
previous_epoch: int | None = Field(default=None, ge=1)
class EpochResponse(BaseModel):
tenant_id: str
subject_kind: str
subject_id: str
epoch: int
state: str
history_policy: str
effective_at: datetime
previous_epoch: int | None = None
reason: str | None = None
access_decision_ref: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class KeyAccessPayload(BaseModel):
account_id: str = Field(min_length=1, max_length=255)
device_key_id: str = Field(min_length=1, max_length=255)
subject_kind: Literal[
"identity", "account", "function", "postbox", "external_recipient"
]
subject_id: str = Field(min_length=1, max_length=255)
key_epoch: int = Field(ge=1)
access_decision_ref: str = Field(min_length=1, max_length=1000)
purpose: str = Field(min_length=1, max_length=255)
requested_at: datetime
function_assignment_id: str | None = Field(default=None, max_length=255)
delegation_id: str | None = Field(default=None, max_length=255)
resource_ref: str | None = Field(default=None, max_length=1000)
class KeyAccessResponse(BaseModel):
allowed: bool
decision_ref: str
reason: str
device_key: DeviceKeyResponse | None = None
epoch: EpochResponse | None = None
audit_event_ref: str | None = None
requirements: list[str] = Field(default_factory=list)
provenance: dict[str, Any] = Field(default_factory=dict)
class AssuranceEvidencePayload(BaseModel):
account_id: str = Field(min_length=1, max_length=255)
evidence_ref: str = Field(min_length=1, max_length=1000)
assurance_level: str = Field(min_length=1, max_length=80)
provider_id: str = Field(min_length=1, max_length=120)
verified_at: datetime
expires_at: datetime
device_key_id: str | None = Field(default=None, max_length=255)
provenance: dict[str, Any] = Field(default_factory=dict)
class AssuranceCheckPayload(BaseModel):
account_id: str = Field(min_length=1, max_length=255)
purpose: str = Field(min_length=1, max_length=255)
minimum_level: str = Field(min_length=1, max_length=80)
evidence_ref: str = Field(min_length=1, max_length=1000)
evaluated_at: datetime
maximum_age_seconds: int = Field(default=300, ge=1, le=86400)
device_key_id: str | None = Field(default=None, max_length=255)
class AssuranceResponse(BaseModel):
allowed: bool
reason: str
assurance_level: str | None = None
evidence_ref: str | None = None
verified_at: datetime | None = None
expires_at: datetime | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
@@ -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",
]
View File
+225
View File
@@ -0,0 +1,225 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.identity_trust import (
AssuranceCheckRequest,
DeviceKeyRegistration,
KeyAccessRequest,
KeyEpochRotationRequest,
)
from govoplan_identity_trust.backend.db.models import (
AssuranceEvidence,
DevicePublicKey,
KeyAccessDecisionRecord,
TrustKeyEpoch,
)
from govoplan_identity_trust.backend.service import (
IdentityTrustError,
SqlIdentityTrustService,
record_assurance_evidence,
)
NOW = datetime(2026, 8, 1, 12, 0, tzinfo=UTC)
class Principal:
tenant_id = "tenant-1"
account_id = "account-1"
def has(self, scope: str) -> bool:
return scope in {
"identity_trust:device:admin",
"identity_trust:device:read_all",
}
def registration(**changes) -> DeviceKeyRegistration:
values = {
"tenant_id": "tenant-1",
"identity_id": "identity-1",
"account_id": "account-1",
"device_id": "device-1",
"key_id": "key-1",
"algorithm": "X25519",
"public_jwk": {"kty": "OKP", "crv": "X25519", "x": "public"},
"purpose": "encryption",
"assurance_level": "hardware",
"idempotency_key": "register-1",
}
values.update(changes)
return DeviceKeyRegistration(**values)
class IdentityTrustTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite+pysqlite:///:memory:")
self.tables = [
DevicePublicKey.__table__,
TrustKeyEpoch.__table__,
AssuranceEvidence.__table__,
KeyAccessDecisionRecord.__table__,
]
for table in self.tables:
table.create(self.engine)
self.session = Session(self.engine)
self.service = SqlIdentityTrustService()
self.principal = Principal()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_public_key_registration_replay_revoke_and_private_rejection(self) -> None:
first = self.service.register_device_key(
self.session,
self.principal,
request=registration(),
)
replay = self.service.register_device_key(
self.session,
self.principal,
request=registration(),
)
self.assertEqual(first.key_id, replay.key_id)
with self.assertRaisesRegex(ValueError, "public JWK"):
registration(
key_id="private",
idempotency_key="private",
public_jwk={"kty": "OKP", "x": "public", "d": "private"},
)
revoked = self.service.revoke_device_key(
self.session,
self.principal,
tenant_id="tenant-1",
key_id="key-1",
expected_epoch=1,
reason="Device lost.",
)
self.assertEqual("revoked", revoked.status)
with self.assertRaisesRegex(IdentityTrustError, "changed"):
self.service.revoke_device_key(
self.session,
self.principal,
tenant_id="tenant-1",
key_id="key-1",
expected_epoch=1,
reason="Device lost.",
)
def test_epoch_history_and_key_access_decision(self) -> None:
self.service.register_device_key(
self.session,
self.principal,
request=registration(),
)
epoch = self.service.rotate_epoch(
self.session,
self.principal,
request=KeyEpochRotationRequest(
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
reason="Initial incumbent.",
access_decision_ref="access:grant-1",
idempotency_key="epoch-1",
previous_epoch=None,
history_policy="all_retained",
),
)
decision = self.service.decide_key_access(
self.session,
self.principal,
request=KeyAccessRequest(
tenant_id="tenant-1",
account_id="account-1",
device_key_id="key-1",
subject_kind="postbox",
subject_id="postbox-1",
key_epoch=epoch.epoch,
access_decision_ref="access:grant-1",
purpose="postbox.message.read",
requested_at=NOW,
resource_ref="postbox-message:1",
),
)
self.assertTrue(decision.allowed)
self.assertFalse(decision.provenance["cryptographic_material_released"])
next_epoch = self.service.rotate_epoch(
self.session,
self.principal,
request=KeyEpochRotationRequest(
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
reason="Incumbency changed.",
access_decision_ref="access:grant-2",
idempotency_key="epoch-2",
previous_epoch=1,
history_policy="all_retained",
),
)
self.assertEqual(2, next_epoch.epoch)
self.assertEqual(
"superseded",
self.service.resolve_epoch(
self.session,
tenant_id="tenant-1",
subject_kind="postbox",
subject_id="postbox-1",
epoch=1,
).state,
)
def test_assurance_must_be_recent_sufficient_and_device_bound(self) -> None:
record_assurance_evidence(
self.session,
self.principal,
tenant_id="tenant-1",
account_id="account-1",
evidence_ref="webauthn:assertion-1",
assurance_level="hardware",
provider_id="webauthn",
verified_at=NOW,
expires_at=NOW + timedelta(minutes=10),
device_key_id="key-1",
)
allowed = self.service.verify_assurance(
self.session,
self.principal,
request=AssuranceCheckRequest(
tenant_id="tenant-1",
account_id="account-1",
purpose="encryption.recovery.approve",
minimum_level="mfa",
evidence_ref="webauthn:assertion-1",
evaluated_at=NOW + timedelta(minutes=2),
maximum_age_seconds=300,
device_key_id="key-1",
),
)
stale = self.service.verify_assurance(
self.session,
self.principal,
request=AssuranceCheckRequest(
tenant_id="tenant-1",
account_id="account-1",
purpose="encryption.recovery.approve",
minimum_level="mfa",
evidence_ref="webauthn:assertion-1",
evaluated_at=NOW + timedelta(minutes=6),
maximum_age_seconds=300,
device_key_id="key-1",
),
)
self.assertTrue(allowed.allowed)
self.assertFalse(stale.allowed)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
import unittest
from govoplan_core.core.identity_trust import (
CAPABILITY_IDENTITY_TRUST_ASSURANCE,
CAPABILITY_IDENTITY_TRUST_DIRECTORY,
)
from govoplan_identity_trust.backend.manifest import get_manifest
class IdentityTrustManifestTests(unittest.TestCase):
def test_manifest_exposes_headless_trust_capabilities(self) -> None:
manifest = get_manifest()
self.assertEqual("identity_trust", manifest.id)
self.assertEqual((), manifest.dependencies)
self.assertIn(
CAPABILITY_IDENTITY_TRUST_DIRECTORY,
manifest.capability_factories,
)
self.assertIn(
CAPABILITY_IDENTITY_TRUST_ASSURANCE,
manifest.capability_factories,
)
self.assertIsNone(manifest.frontend)
self.assertIsNotNone(manifest.migration_spec)
self.assertEqual("vertical_slice", manifest.architecture.maturity)
if __name__ == "__main__":
unittest.main()
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_identity_trust.backend.manifest import get_manifest
class IdentityTrustMigrationTests(unittest.TestCase):
def test_migration_creates_all_trust_tables(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-identity-trust-"
) as directory:
url = f"sqlite:///{Path(directory) / 'trust.db'}"
migrate_database(
database_url=url,
enabled_modules=("identity_trust",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
tables = set(inspect(engine).get_table_names())
self.assertTrue(
{
"identity_trust_device_keys",
"identity_trust_key_epochs",
"identity_trust_assurance_evidence",
"identity_trust_key_access_decisions",
}.issubset(tables)
)
with engine.connect() as connection:
self.assertIn(
"c3f5a7b9d1e2",
set(MigrationContext.configure(connection).get_current_heads()),
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()