generated from coulomb/repo-seed
enterprise/flex-auth integration layer
This commit is contained in:
@@ -52,7 +52,12 @@ from markitect_tool.generation import (
|
||||
from markitect_tool.literate import tangle_markdown, weave_markdown, write_tangle_files
|
||||
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
|
||||
from markitect_tool.processor import ProcessorContext, run_fenced_processors
|
||||
from markitect_tool.policy import LocalLabelPolicyGateway
|
||||
from markitect_tool.policy import (
|
||||
EnterprisePolicyError,
|
||||
FlexAuthResourceManifest,
|
||||
LocalLabelPolicyGateway,
|
||||
load_enterprise_policy_subject,
|
||||
)
|
||||
from markitect_tool.query import (
|
||||
InvalidQueryError,
|
||||
extract_document,
|
||||
@@ -791,6 +796,68 @@ def policy_check(
|
||||
raise click.exceptions.Exit(0 if decision.get("allowed") else 1)
|
||||
|
||||
|
||||
@policy.command("subject")
|
||||
@click.argument("claims_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--policy-map",
|
||||
"policy_map_file",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
required=True,
|
||||
help="Enterprise policy map file.",
|
||||
)
|
||||
@click.option("--group", "groups", multiple=True, help="Additional resolved group. May be repeated.")
|
||||
@click.option(
|
||||
"--environment",
|
||||
type=click.Choice(["development", "test", "production"], case_sensitive=False),
|
||||
help="Validation environment for issuer safety checks.",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
||||
default="text",
|
||||
show_default=True,
|
||||
)
|
||||
def policy_subject(
|
||||
claims_file: Path,
|
||||
policy_map_file: Path,
|
||||
groups: tuple[str, ...],
|
||||
environment: str | None,
|
||||
output_format: str,
|
||||
) -> None:
|
||||
"""Map enterprise identity claims into a Markitect policy subject."""
|
||||
|
||||
try:
|
||||
subject = load_enterprise_policy_subject(
|
||||
claims_file,
|
||||
policy_map_file,
|
||||
extra_groups=list(groups),
|
||||
environment=environment,
|
||||
)
|
||||
except EnterprisePolicyError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
_emit_subject_result({"subject": subject.to_dict()}, output_format)
|
||||
|
||||
|
||||
@policy.command("resource-manifest")
|
||||
@click.argument("manifest_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--format",
|
||||
"output_format",
|
||||
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
||||
default="text",
|
||||
show_default=True,
|
||||
)
|
||||
def policy_resource_manifest(manifest_file: Path, output_format: str) -> None:
|
||||
"""Inspect a Markitect flex-auth resource registration manifest."""
|
||||
|
||||
try:
|
||||
manifest = FlexAuthResourceManifest.from_file(manifest_file)
|
||||
except EnterprisePolicyError as exc:
|
||||
raise click.ClickException(str(exc)) from exc
|
||||
_emit_resource_manifest_result({"manifest": manifest.to_dict()}, output_format)
|
||||
|
||||
|
||||
@main.group("class")
|
||||
def class_group() -> None:
|
||||
"""Resolve deterministic content classes."""
|
||||
@@ -1736,6 +1803,34 @@ def _emit_policy_result(data: dict, output_format: str) -> None:
|
||||
click.echo(f"reason: {decision.get('reason')}")
|
||||
|
||||
|
||||
def _emit_subject_result(data: dict, output_format: str) -> None:
|
||||
if output_format == "json":
|
||||
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
elif output_format == "yaml":
|
||||
click.echo(yaml.safe_dump(data, sort_keys=False))
|
||||
else:
|
||||
subject = data["subject"]
|
||||
click.echo(f"subject: {subject.get('id')}")
|
||||
click.echo(f"roles: {', '.join(subject.get('roles', [])) or '<none>'}")
|
||||
click.echo(f"labels: {', '.join(subject.get('allowed_labels', [])) or '<none>'}")
|
||||
click.echo(f"trust_zones: {', '.join(subject.get('trust_zones', [])) or '<none>'}")
|
||||
click.echo(f"actions: {', '.join(subject.get('allowed_actions', [])) or '<none>'}")
|
||||
|
||||
|
||||
def _emit_resource_manifest_result(data: dict, output_format: str) -> None:
|
||||
if output_format == "json":
|
||||
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
elif output_format == "yaml":
|
||||
click.echo(yaml.safe_dump(data, sort_keys=False))
|
||||
else:
|
||||
manifest = data["manifest"]
|
||||
click.echo(f"manifest: {manifest.get('id')}")
|
||||
click.echo(f"system: {manifest.get('system')}")
|
||||
click.echo(f"resources: {len(manifest.get('resources', []))}")
|
||||
actions = ", ".join(manifest.get("actions", [])) or "<none>"
|
||||
click.echo(f"actions: {actions}")
|
||||
|
||||
|
||||
def _emit_metrics(data: dict, output_format: str) -> None:
|
||||
if output_format == "json":
|
||||
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
||||
|
||||
@@ -200,6 +200,9 @@ def _local_label_policy_descriptor() -> ExtensionDescriptor:
|
||||
capabilities=[
|
||||
ProcessingCapability(id="policy", kind="authorize"),
|
||||
ProcessingCapability(id="policy_filter", kind="filter"),
|
||||
ProcessingCapability(id="identity_claims", kind="normalize"),
|
||||
ProcessingCapability(id="resource_manifest", kind="register"),
|
||||
ProcessingCapability(id="decision_log", kind="emit"),
|
||||
ProcessingCapability(id="diagnostics", kind="emit"),
|
||||
ProcessingCapability(id="provenance", kind="emit"),
|
||||
],
|
||||
@@ -211,6 +214,8 @@ def _local_label_policy_descriptor() -> ExtensionDescriptor:
|
||||
cli={
|
||||
"commands": [
|
||||
"mkt policy check",
|
||||
"mkt policy subject",
|
||||
"mkt policy resource-manifest",
|
||||
"mkt cache query --policy",
|
||||
"mkt search --policy",
|
||||
]
|
||||
|
||||
@@ -14,6 +14,18 @@ from markitect_tool.policy.adapters import (
|
||||
RulePolicyAdapter,
|
||||
RulePolicyRequest,
|
||||
)
|
||||
from markitect_tool.policy.enterprise import (
|
||||
EnterprisePolicyError,
|
||||
EnterprisePolicyMap,
|
||||
FlexAuthResource,
|
||||
FlexAuthResourceManifest,
|
||||
LocalDecisionLogStore,
|
||||
LocalEnterprisePolicyMapper,
|
||||
NetKingdomIdentityClaimsAdapter,
|
||||
StaticDirectoryGroupResolver,
|
||||
load_enterprise_identity_file,
|
||||
load_enterprise_policy_subject,
|
||||
)
|
||||
from markitect_tool.policy.local import (
|
||||
LocalLabelPolicy,
|
||||
LocalLabelPolicyGateway,
|
||||
@@ -36,16 +48,26 @@ __all__ = [
|
||||
"DirectoryGroupResolutionRequest",
|
||||
"DirectoryGroupResolver",
|
||||
"EnterpriseIdentity",
|
||||
"EnterprisePolicyError",
|
||||
"EnterprisePolicyMap",
|
||||
"EnterprisePolicyMapRequest",
|
||||
"EnterprisePolicyMapper",
|
||||
"FlexAuthResource",
|
||||
"FlexAuthResourceManifest",
|
||||
"IdentityClaimsAdapter",
|
||||
"LocalDecisionLogStore",
|
||||
"LocalEnterprisePolicyMapper",
|
||||
"PolicyDecision",
|
||||
"PolicyFilterResult",
|
||||
"PolicyObject",
|
||||
"PolicySubject",
|
||||
"NetKingdomIdentityClaimsAdapter",
|
||||
"RelationshipPolicyAdapter",
|
||||
"RelationshipPolicyRequest",
|
||||
"RulePolicyAdapter",
|
||||
"RulePolicyRequest",
|
||||
"StaticDirectoryGroupResolver",
|
||||
"load_enterprise_identity_file",
|
||||
"load_enterprise_policy_subject",
|
||||
"policy_metadata_from_document",
|
||||
]
|
||||
|
||||
579
src/markitect_tool/policy/enterprise.py
Normal file
579
src/markitect_tool/policy/enterprise.py
Normal file
@@ -0,0 +1,579 @@
|
||||
"""Enterprise policy integration helpers for Markitect-side adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from markitect_tool.policy.adapters import (
|
||||
DirectoryGroupResolution,
|
||||
DirectoryGroupResolutionRequest,
|
||||
EnterpriseIdentity,
|
||||
EnterprisePolicyMapRequest,
|
||||
)
|
||||
from markitect_tool.policy.models import PolicyDecision, PolicySubject
|
||||
|
||||
|
||||
NETKINGDOM_REQUIRED_CLAIMS = {
|
||||
"iss",
|
||||
"sub",
|
||||
"aud",
|
||||
"exp",
|
||||
"iat",
|
||||
"preferred_username",
|
||||
}
|
||||
|
||||
|
||||
class EnterprisePolicyError(ValueError):
|
||||
"""Raised when enterprise identity or policy mapping is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NetKingdomIdentityClaimsAdapter:
|
||||
"""Validate NetKingdom/key-cape-compatible claims and normalize identity.
|
||||
|
||||
This adapter validates already trusted claims or explicitly allowed
|
||||
unverified JWT fixtures. Live OIDC discovery, JWKS retrieval, and signature
|
||||
verification belong in provider adapters, normally in flex-auth or
|
||||
key-cape-facing integration code.
|
||||
"""
|
||||
|
||||
issuer: str | None = None
|
||||
audiences: list[str] = field(default_factory=list)
|
||||
clock_skew_seconds: int = 60
|
||||
reject_local_issuers_in_production: bool = True
|
||||
|
||||
def verify(
|
||||
self,
|
||||
token_or_assertion: str | dict[str, Any],
|
||||
*,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> EnterpriseIdentity:
|
||||
context = dict(context or {})
|
||||
claims, provenance = self._claims(token_or_assertion, context)
|
||||
self._validate_claims(claims, context)
|
||||
|
||||
roles = _roles_from_claims(claims)
|
||||
scopes = _scopes_from_claims(claims)
|
||||
groups = _string_list(claims.get("groups"))
|
||||
issuer = str(claims["iss"])
|
||||
subject = str(claims["sub"])
|
||||
principal_type = _principal_type(claims, roles)
|
||||
|
||||
return EnterpriseIdentity(
|
||||
issuer=issuer,
|
||||
subject=subject,
|
||||
principal_type=principal_type,
|
||||
audience=_string_list(claims.get("aud")),
|
||||
authorized_party=claims.get("azp") or claims.get("client_id"),
|
||||
preferred_username=claims.get("preferred_username"),
|
||||
roles=roles,
|
||||
scopes=scopes,
|
||||
groups=groups,
|
||||
assurance=_assurance_from_claims(claims),
|
||||
directory={
|
||||
"groups_claim_present": "groups" in claims,
|
||||
"group_overage": _has_group_overage(claims),
|
||||
},
|
||||
claims={key: claims[key] for key in sorted(claims) if key not in {"groups"}},
|
||||
provenance=provenance,
|
||||
)
|
||||
|
||||
def _claims(
|
||||
self,
|
||||
token_or_assertion: str | dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
if isinstance(token_or_assertion, dict):
|
||||
return dict(token_or_assertion), {"source": "claims", "verified_signature": context.get("verified_signature")}
|
||||
if not context.get("allow_unverified_jwt_fixture"):
|
||||
raise EnterprisePolicyError(
|
||||
"JWT strings require a provider adapter with signature verification "
|
||||
"or context.allow_unverified_jwt_fixture=true for test fixtures."
|
||||
)
|
||||
claims = _decode_unverified_jwt_payload(token_or_assertion)
|
||||
return claims, {"source": "jwt-fixture", "verified_signature": False}
|
||||
|
||||
def _validate_claims(self, claims: dict[str, Any], context: dict[str, Any]) -> None:
|
||||
missing = sorted(NETKINGDOM_REQUIRED_CLAIMS - set(claims))
|
||||
if missing:
|
||||
raise EnterprisePolicyError(f"Missing required NetKingdom claims: {missing}")
|
||||
if not (_roles_from_claims(claims)):
|
||||
raise EnterprisePolicyError("Missing required role claim: roles or realm_access.roles")
|
||||
if not (_scopes_from_claims(claims)):
|
||||
raise EnterprisePolicyError("Missing required scope claim: scope or scp")
|
||||
|
||||
issuer = str(claims["iss"])
|
||||
if self.issuer and issuer != self.issuer:
|
||||
raise EnterprisePolicyError(f"Unexpected issuer `{issuer}`; expected `{self.issuer}`")
|
||||
if self.reject_local_issuers_in_production and context.get("environment") == "production":
|
||||
if _is_local_development_issuer(issuer):
|
||||
raise EnterprisePolicyError("Local development issuer is not accepted in production")
|
||||
|
||||
expected_audiences = set(self.audiences or _string_list(context.get("audiences")))
|
||||
if expected_audiences:
|
||||
actual_audiences = set(_string_list(claims.get("aud")))
|
||||
if not actual_audiences.intersection(expected_audiences):
|
||||
raise EnterprisePolicyError(
|
||||
f"Token audience {sorted(actual_audiences)} does not include one of {sorted(expected_audiences)}"
|
||||
)
|
||||
|
||||
now = int(context.get("now", time.time()))
|
||||
exp = _int_claim(claims, "exp")
|
||||
iat = _int_claim(claims, "iat")
|
||||
if exp + self.clock_skew_seconds < now:
|
||||
raise EnterprisePolicyError("Token is expired")
|
||||
if iat - self.clock_skew_seconds > now:
|
||||
raise EnterprisePolicyError("Token issued-at is in the future")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StaticDirectoryGroupResolver:
|
||||
"""Deterministic group resolver for fixtures and local development."""
|
||||
|
||||
groups_by_subject: dict[str, list[str]] = field(default_factory=dict)
|
||||
source: str = "static"
|
||||
refreshed_at: str | None = None
|
||||
|
||||
def resolve(
|
||||
self,
|
||||
request: DirectoryGroupResolutionRequest,
|
||||
) -> DirectoryGroupResolution:
|
||||
groups = _unique(
|
||||
list(request.groups)
|
||||
+ _string_list(request.claims.get("groups"))
|
||||
+ self.groups_by_subject.get(request.subject_id, [])
|
||||
)
|
||||
return DirectoryGroupResolution(
|
||||
groups=groups,
|
||||
source=self.source,
|
||||
refreshed_at=self.refreshed_at,
|
||||
overage=_has_group_overage(request.claims),
|
||||
metadata={
|
||||
"subject_id": request.subject_id,
|
||||
"issuer": request.issuer,
|
||||
"claim_group_count": len(_string_list(request.claims.get("groups"))),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnterprisePolicyMap:
|
||||
"""Versioned Markitect-side mapping from IAM claims to policy subjects."""
|
||||
|
||||
id: str = "enterprise-policy-map"
|
||||
issuer: str | None = None
|
||||
audiences: list[str] = field(default_factory=list)
|
||||
defaults: dict[str, Any] = field(default_factory=dict)
|
||||
groups: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
roles: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
scopes: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
trust_zones: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: dict[str, Any]) -> "EnterprisePolicyMap":
|
||||
data = raw.get("policy_map") if isinstance(raw.get("policy_map"), dict) else raw
|
||||
return cls(
|
||||
id=str(data.get("id", "enterprise-policy-map")),
|
||||
issuer=data.get("issuer"),
|
||||
audiences=_string_list(data.get("audiences") or data.get("audience")),
|
||||
defaults=dict(data.get("defaults") or {}),
|
||||
groups=_rule_mapping(data.get("groups")),
|
||||
roles=_rule_mapping(data.get("roles")),
|
||||
scopes=_rule_mapping(data.get("scopes")),
|
||||
trust_zones=_rule_mapping(data.get("trust_zones")),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path) -> "EnterprisePolicyMap":
|
||||
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise EnterprisePolicyError("Enterprise policy map must contain a mapping.")
|
||||
return cls.from_mapping(data)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return _drop_empty(asdict(self))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalEnterprisePolicyMapper:
|
||||
"""Map verified enterprise identity into Markitect's PolicySubject shape."""
|
||||
|
||||
policy_map: EnterprisePolicyMap
|
||||
|
||||
def map_subject(self, request: EnterprisePolicyMapRequest) -> PolicySubject:
|
||||
identity = _identity_from_value(request.identity)
|
||||
if self.policy_map.issuer and identity.issuer != self.policy_map.issuer:
|
||||
raise EnterprisePolicyError(
|
||||
f"Identity issuer `{identity.issuer}` does not match policy map issuer `{self.policy_map.issuer}`"
|
||||
)
|
||||
if self.policy_map.audiences:
|
||||
if not set(identity.audience).intersection(self.policy_map.audiences):
|
||||
raise EnterprisePolicyError(
|
||||
f"Identity audience {identity.audience} does not match policy map audiences {self.policy_map.audiences}"
|
||||
)
|
||||
|
||||
group_values = _unique(identity.groups + list(request.groups))
|
||||
allowed_labels = _string_list(self.policy_map.defaults.get("allowed_labels"))
|
||||
trust_zones = _string_list(self.policy_map.defaults.get("trust_zones"))
|
||||
allowed_actions = _string_list(self.policy_map.defaults.get("actions") or self.policy_map.defaults.get("allowed_actions"))
|
||||
attributes: dict[str, Any] = {
|
||||
"policy_map_id": self.policy_map.id,
|
||||
"matched_policy_rules": [],
|
||||
"tenant": request.context.get("tenant"),
|
||||
}
|
||||
|
||||
for rule_type, values, rules in (
|
||||
("group", group_values, self.policy_map.groups),
|
||||
("role", identity.roles, self.policy_map.roles),
|
||||
("scope", identity.scopes, self.policy_map.scopes),
|
||||
):
|
||||
for value in values:
|
||||
rule = rules.get(value)
|
||||
if not rule:
|
||||
continue
|
||||
allowed_labels = _unique(allowed_labels + _string_list(rule.get("allowed_labels") or rule.get("labels")))
|
||||
trust_zones = _unique(trust_zones + _string_list(rule.get("trust_zones") or rule.get("zones")))
|
||||
allowed_actions = _unique(allowed_actions + _string_list(rule.get("actions") or rule.get("allowed_actions")))
|
||||
attributes["matched_policy_rules"].append(f"{rule_type}:{value}")
|
||||
attributes.update(dict(rule.get("attributes") or {}))
|
||||
|
||||
trust_zones = _unique(trust_zones + self._allowed_trust_zones(group_values, identity))
|
||||
attributes["groups"] = group_values
|
||||
attributes["scopes"] = identity.scopes
|
||||
attributes["assurance"] = identity.assurance
|
||||
return identity.to_policy_subject(
|
||||
allowed_labels=allowed_labels,
|
||||
trust_zones=trust_zones,
|
||||
allowed_actions=allowed_actions,
|
||||
attributes={key: value for key, value in attributes.items() if value not in (None, [], {})},
|
||||
)
|
||||
|
||||
def _allowed_trust_zones(
|
||||
self,
|
||||
groups: list[str],
|
||||
identity: EnterpriseIdentity,
|
||||
) -> list[str]:
|
||||
allowed: list[str] = []
|
||||
group_set = set(groups)
|
||||
role_set = set(identity.roles)
|
||||
scope_set = set(identity.scopes)
|
||||
for zone, rule in self.policy_map.trust_zones.items():
|
||||
required_groups = set(_string_list(rule.get("required_groups")))
|
||||
required_roles = set(_string_list(rule.get("required_roles")))
|
||||
required_scopes = set(_string_list(rule.get("required_scopes")))
|
||||
if required_groups and not required_groups.issubset(group_set):
|
||||
continue
|
||||
if required_roles and not required_roles.issubset(role_set):
|
||||
continue
|
||||
if required_scopes and not required_scopes.issubset(scope_set):
|
||||
continue
|
||||
if required_groups or required_roles or required_scopes:
|
||||
allowed.append(zone)
|
||||
return allowed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlexAuthResource:
|
||||
"""Resource record Markitect can register with flex-auth."""
|
||||
|
||||
id: str
|
||||
type: str
|
||||
path: str | None = None
|
||||
parent: str | None = None
|
||||
labels: list[str] = field(default_factory=list)
|
||||
trust_zone: str | None = None
|
||||
owner: str | None = None
|
||||
attributes: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: dict[str, Any]) -> "FlexAuthResource":
|
||||
return cls(
|
||||
id=str(raw["id"]),
|
||||
type=str(raw.get("type", "document")),
|
||||
path=raw.get("path"),
|
||||
parent=raw.get("parent"),
|
||||
labels=_string_list(raw.get("labels") or raw.get("label")),
|
||||
trust_zone=raw.get("trust_zone"),
|
||||
owner=raw.get("owner"),
|
||||
attributes=dict(raw.get("attributes") or {}),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return _drop_empty(asdict(self))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FlexAuthResourceManifest:
|
||||
"""Manifest of Markitect resources intended for flex-auth registration."""
|
||||
|
||||
id: str
|
||||
system: str = "markitect-tool"
|
||||
resources: list[FlexAuthResource] = field(default_factory=list)
|
||||
actions: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, raw: dict[str, Any]) -> "FlexAuthResourceManifest":
|
||||
data = raw.get("manifest") if isinstance(raw.get("manifest"), dict) else raw
|
||||
resources = [
|
||||
FlexAuthResource.from_mapping(item)
|
||||
for item in data.get("resources", [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
return cls(
|
||||
id=str(data.get("id", "markitect-resources")),
|
||||
system=str(data.get("system", "markitect-tool")),
|
||||
resources=resources,
|
||||
actions=_string_list(data.get("actions")),
|
||||
metadata=dict(data.get("metadata") or {}),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, path: str | Path) -> "FlexAuthResourceManifest":
|
||||
data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(data, dict):
|
||||
raise EnterprisePolicyError("Flex-auth resource manifest must contain a mapping.")
|
||||
return cls.from_mapping(data)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
data["resources"] = [resource.to_dict() for resource in self.resources]
|
||||
return _drop_empty(data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalDecisionLogStore:
|
||||
"""Local JSONL decision sink for development and tests."""
|
||||
|
||||
path: Path | None = None
|
||||
_entries: dict[str, dict[str, Any]] = field(default_factory=dict, init=False)
|
||||
|
||||
def record(
|
||||
self,
|
||||
decision: PolicyDecision,
|
||||
*,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
payload = {
|
||||
"decision": decision.to_dict(),
|
||||
"context": _redact_sensitive_context(context or {}),
|
||||
"recorded_by": "markitect.local-decision-log",
|
||||
}
|
||||
audit_id = "audit:" + hashlib.sha256(
|
||||
json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8")
|
||||
).hexdigest()
|
||||
payload["audit_id"] = audit_id
|
||||
self._entries[decision.decision_id] = payload
|
||||
if self.path:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str) + "\n")
|
||||
return audit_id
|
||||
|
||||
def get(self, decision_id: str) -> dict[str, Any] | None:
|
||||
if decision_id in self._entries:
|
||||
return self._entries[decision_id]
|
||||
if not self.path or not self.path.exists():
|
||||
return None
|
||||
with self.path.open(encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
entry = json.loads(line)
|
||||
if entry.get("decision", {}).get("decision_id") == decision_id:
|
||||
self._entries[decision_id] = entry
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def load_enterprise_identity_file(
|
||||
path: str | Path,
|
||||
*,
|
||||
issuer: str | None = None,
|
||||
audiences: list[str] | None = None,
|
||||
environment: str | None = None,
|
||||
) -> EnterpriseIdentity:
|
||||
"""Load and validate deterministic NetKingdom/key-cape-style claims."""
|
||||
|
||||
claims = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(claims, dict):
|
||||
raise EnterprisePolicyError("Enterprise identity claims file must contain a mapping.")
|
||||
adapter = NetKingdomIdentityClaimsAdapter(issuer=issuer, audiences=list(audiences or []))
|
||||
return adapter.verify(claims, context={"environment": environment} if environment else {})
|
||||
|
||||
|
||||
def load_enterprise_policy_subject(
|
||||
claims_file: str | Path,
|
||||
policy_map_file: str | Path,
|
||||
*,
|
||||
extra_groups: list[str] | None = None,
|
||||
environment: str | None = None,
|
||||
) -> PolicySubject:
|
||||
"""Load claims and policy map files and return a gateway-ready subject."""
|
||||
|
||||
policy_map = EnterprisePolicyMap.from_file(policy_map_file)
|
||||
identity = load_enterprise_identity_file(
|
||||
claims_file,
|
||||
issuer=policy_map.issuer,
|
||||
audiences=policy_map.audiences,
|
||||
environment=environment,
|
||||
)
|
||||
group_resolver = StaticDirectoryGroupResolver()
|
||||
group_result = group_resolver.resolve(
|
||||
DirectoryGroupResolutionRequest(
|
||||
subject_id=identity.canonical_id,
|
||||
issuer=identity.issuer,
|
||||
groups=list(extra_groups or []),
|
||||
claims=identity.claims | {"groups": identity.groups},
|
||||
)
|
||||
)
|
||||
mapper = LocalEnterprisePolicyMapper(policy_map)
|
||||
return mapper.map_subject(
|
||||
EnterprisePolicyMapRequest(
|
||||
identity=identity,
|
||||
policy_map=policy_map.to_dict(),
|
||||
groups=group_result.groups,
|
||||
context={"environment": environment},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _roles_from_claims(claims: dict[str, Any]) -> list[str]:
|
||||
roles = _string_list(claims.get("roles"))
|
||||
realm_access = claims.get("realm_access")
|
||||
if isinstance(realm_access, dict):
|
||||
roles += _string_list(realm_access.get("roles"))
|
||||
resource_access = claims.get("resource_access")
|
||||
if isinstance(resource_access, dict):
|
||||
for client in resource_access.values():
|
||||
if isinstance(client, dict):
|
||||
roles += _string_list(client.get("roles"))
|
||||
return _unique(roles)
|
||||
|
||||
|
||||
def _scopes_from_claims(claims: dict[str, Any]) -> list[str]:
|
||||
return _unique(_string_list(claims.get("scope")) + _string_list(claims.get("scp")))
|
||||
|
||||
|
||||
def _assurance_from_claims(claims: dict[str, Any]) -> dict[str, Any]:
|
||||
amr = _string_list(claims.get("amr"))
|
||||
return {
|
||||
"acr": claims.get("acr"),
|
||||
"amr": amr,
|
||||
"mfa": bool(claims.get("mfa") or {"otp", "mfa", "hwk"}.intersection(amr)),
|
||||
}
|
||||
|
||||
|
||||
def _principal_type(claims: dict[str, Any], roles: list[str]) -> str:
|
||||
if claims.get("client_id") and "service" in roles:
|
||||
return "service"
|
||||
if claims.get("azp", "").startswith("svc-") or "service" in roles:
|
||||
return "service"
|
||||
return "human"
|
||||
|
||||
|
||||
def _has_group_overage(claims: dict[str, Any]) -> bool:
|
||||
return bool(claims.get("hasgroups") or claims.get("_claim_names", {}).get("groups"))
|
||||
|
||||
|
||||
def _decode_unverified_jwt_payload(token: str) -> dict[str, Any]:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
raise EnterprisePolicyError("JWT fixture must have three segments.")
|
||||
payload = parts[1] + "=" * (-len(parts[1]) % 4)
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(payload.encode("ascii"))
|
||||
claims = json.loads(decoded.decode("utf-8"))
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
raise EnterprisePolicyError("JWT fixture payload is not valid JSON.") from exc
|
||||
if not isinstance(claims, dict):
|
||||
raise EnterprisePolicyError("JWT fixture payload must contain claims mapping.")
|
||||
return claims
|
||||
|
||||
|
||||
def _identity_from_value(value: EnterpriseIdentity | dict[str, Any]) -> EnterpriseIdentity:
|
||||
if isinstance(value, EnterpriseIdentity):
|
||||
return value
|
||||
return EnterpriseIdentity(
|
||||
issuer=str(value["issuer"]),
|
||||
subject=str(value["subject"]),
|
||||
identity_scheme=str(value.get("identity_scheme", "oidc")),
|
||||
principal_type=str(value.get("principal_type", "human")),
|
||||
audience=_string_list(value.get("audience")),
|
||||
authorized_party=value.get("authorized_party"),
|
||||
preferred_username=value.get("preferred_username"),
|
||||
roles=_string_list(value.get("roles")),
|
||||
scopes=_string_list(value.get("scopes")),
|
||||
groups=_string_list(value.get("groups")),
|
||||
assurance=dict(value.get("assurance") or {}),
|
||||
directory=dict(value.get("directory") or {}),
|
||||
claims=dict(value.get("claims") or {}),
|
||||
provenance=dict(value.get("provenance") or {}),
|
||||
)
|
||||
|
||||
|
||||
def _int_claim(claims: dict[str, Any], key: str) -> int:
|
||||
try:
|
||||
return int(claims[key])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise EnterprisePolicyError(f"Claim `{key}` must be an integer timestamp.") from exc
|
||||
|
||||
|
||||
def _rule_mapping(value: Any) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {
|
||||
str(key): dict(raw or {})
|
||||
for key, raw in value.items()
|
||||
if isinstance(raw, dict)
|
||||
}
|
||||
|
||||
|
||||
def _is_local_development_issuer(issuer: str) -> bool:
|
||||
return any(marker in issuer for marker in ("localhost", "127.0.0.1", ".local", "dev.local"))
|
||||
|
||||
|
||||
def _redact_sensitive_context(context: dict[str, Any]) -> dict[str, Any]:
|
||||
redacted = dict(context)
|
||||
for key in list(redacted):
|
||||
if key.lower() in {"token", "access_token", "refresh_token", "assertion"}:
|
||||
redacted[key] = "<redacted>"
|
||||
return redacted
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [item for item in value.split() if item]
|
||||
if isinstance(value, list | tuple | set):
|
||||
return [str(item) for item in value if item not in (None, "")]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def _unique(values: list[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
normalized = str(value).strip()
|
||||
key = normalized.lower()
|
||||
if normalized and key not in seen:
|
||||
result.append(normalized)
|
||||
seen.add(key)
|
||||
return result
|
||||
|
||||
|
||||
def _drop_empty(data: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in data.items()
|
||||
if value not in (None, [], {}, "")
|
||||
}
|
||||
Reference in New Issue
Block a user