Implement identity trust module
This commit is contained in:
@@ -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."""
|
||||
+190
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user