Enterprise access control integration

This commit is contained in:
2026-05-04 15:32:54 +02:00
parent ffab98be10
commit 5ecb52aece
9 changed files with 838 additions and 3 deletions

View File

@@ -219,8 +219,12 @@ def _local_label_policy_descriptor() -> ExtensionDescriptor:
examples=["examples/policy/local-label-policy.yaml"],
metadata={
"external_adapters": [
"IdentityClaimsAdapter",
"DirectoryGroupResolver",
"EnterprisePolicyMapper",
"RelationshipPolicyAdapter",
"RulePolicyAdapter",
"DecisionLogStore",
]
},
)

View File

@@ -1,6 +1,14 @@
"""Access policy gateways and adapter protocols."""
from markitect_tool.policy.adapters import (
DecisionLogStore,
DirectoryGroupResolution,
DirectoryGroupResolutionRequest,
DirectoryGroupResolver,
EnterpriseIdentity,
EnterprisePolicyMapRequest,
EnterprisePolicyMapper,
IdentityClaimsAdapter,
RelationshipPolicyAdapter,
RelationshipPolicyRequest,
RulePolicyAdapter,
@@ -23,6 +31,14 @@ __all__ = [
"LocalLabelPolicy",
"LocalLabelPolicyGateway",
"LocalPathPolicyRule",
"DecisionLogStore",
"DirectoryGroupResolution",
"DirectoryGroupResolutionRequest",
"DirectoryGroupResolver",
"EnterpriseIdentity",
"EnterprisePolicyMapRequest",
"EnterprisePolicyMapper",
"IdentityClaimsAdapter",
"PolicyDecision",
"PolicyFilterResult",
"PolicyObject",

View File

@@ -1,11 +1,168 @@
"""Protocol boundaries for external authorization engines."""
"""Protocol boundaries for external identity and authorization engines."""
from __future__ import annotations
from dataclasses import asdict, dataclass, field
from typing import Any, Protocol
from markitect_tool.policy.models import PolicyDecision
from markitect_tool.policy.models import PolicyDecision, PolicySubject
@dataclass(frozen=True)
class EnterpriseIdentity:
"""Verified enterprise identity claims normalized for policy mapping.
Concrete adapters are responsible for validating tokens or assertions before
returning this shape. Core Markitect only consumes the normalized identity.
"""
issuer: str
subject: str
identity_scheme: str = "oidc"
principal_type: str = "human"
audience: list[str] = field(default_factory=list)
authorized_party: str | None = None
preferred_username: str | None = None
roles: list[str] = field(default_factory=list)
scopes: list[str] = field(default_factory=list)
groups: list[str] = field(default_factory=list)
assurance: dict[str, Any] = field(default_factory=dict)
directory: dict[str, Any] = field(default_factory=dict)
claims: dict[str, Any] = field(default_factory=dict)
provenance: dict[str, Any] = field(default_factory=dict)
@property
def canonical_id(self) -> str:
return f"{self.identity_scheme}:{self.issuer}#{self.subject}"
def to_policy_subject(
self,
*,
allowed_labels: list[str] | None = None,
trust_zones: list[str] | None = None,
allowed_actions: list[str] | None = None,
attributes: dict[str, Any] | None = None,
) -> PolicySubject:
"""Convert verified claims into the subject shape used by gateways."""
return PolicySubject(
id=self.canonical_id,
allowed_labels=list(allowed_labels or []),
trust_zones=list(trust_zones or []),
roles=list(self.roles),
allowed_actions=list(allowed_actions or []),
attributes={
"issuer": self.issuer,
"subject": self.subject,
"identity_scheme": self.identity_scheme,
"principal_type": self.principal_type,
"audience": self.audience,
"authorized_party": self.authorized_party,
"preferred_username": self.preferred_username,
"scopes": self.scopes,
"groups": self.groups,
"assurance": self.assurance,
"directory": self.directory,
"identity_provenance": self.provenance,
}
| dict(attributes or {}),
)
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["canonical_id"] = self.canonical_id
return _drop_empty(data)
class IdentityClaimsAdapter(Protocol):
"""Adapter boundary for OIDC/JWT/SAML verification and normalization."""
def verify(
self,
token_or_assertion: str | dict[str, Any],
*,
context: dict[str, Any] | None = None,
) -> EnterpriseIdentity | dict[str, Any]:
"""Validate identity material and return normalized enterprise claims."""
@dataclass(frozen=True)
class DirectoryGroupResolutionRequest:
"""Request for resolving group claims that are stale, partial, or overlarge."""
subject_id: str
issuer: str
groups: list[str] = field(default_factory=list)
claims: dict[str, Any] = field(default_factory=dict)
context: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return _drop_empty(asdict(self))
@dataclass(frozen=True)
class DirectoryGroupResolution:
"""Directory group resolution result with freshness and source provenance."""
groups: list[str] = field(default_factory=list)
source: str | None = None
refreshed_at: str | None = None
overage: bool = False
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return _drop_empty(asdict(self))
class DirectoryGroupResolver(Protocol):
"""Adapter boundary for SCIM/LDAP/Graph/Keycloak group resolution."""
def resolve(
self,
request: DirectoryGroupResolutionRequest,
) -> DirectoryGroupResolution | dict[str, Any]:
"""Return groups plus freshness/provenance metadata."""
@dataclass(frozen=True)
class EnterprisePolicyMapRequest:
"""Request to map enterprise claims onto Markitect policy vocabulary."""
identity: EnterpriseIdentity | dict[str, Any]
policy_map: dict[str, Any] = field(default_factory=dict)
groups: list[str] = field(default_factory=list)
context: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
if isinstance(self.identity, EnterpriseIdentity):
data["identity"] = self.identity.to_dict()
return _drop_empty(data)
class EnterprisePolicyMapper(Protocol):
"""Adapter boundary for mapping IAM roles/groups/scopes to policy subjects."""
def map_subject(
self,
request: EnterprisePolicyMapRequest,
) -> PolicySubject | dict[str, Any]:
"""Return a gateway-ready subject with labels, zones, and actions."""
class DecisionLogStore(Protocol):
"""Persistent audit boundary for policy decisions."""
def record(
self,
decision: PolicyDecision,
*,
context: dict[str, Any] | None = None,
) -> str:
"""Persist a decision and return its durable audit id."""
def get(self, decision_id: str) -> dict[str, Any] | None:
"""Return one recorded decision when available."""
@dataclass(frozen=True)