generated from coulomb/repo-seed
enterprise/flex-auth integration layer
This commit is contained in:
@@ -109,6 +109,19 @@ mkt cache query 'sections[heading=Decision]' \
|
||||
--subject public-agent
|
||||
```
|
||||
|
||||
Map NetKingdom/key-cape-style claims into a Markitect policy subject:
|
||||
|
||||
```text
|
||||
mkt policy subject examples/policy/netkingdom-claims.yaml \
|
||||
--policy-map examples/policy/enterprise-policy-map.yaml
|
||||
```
|
||||
|
||||
Inspect a Markitect resource manifest intended for flex-auth registration:
|
||||
|
||||
```text
|
||||
mkt policy resource-manifest examples/policy/flex-auth-resource-manifest.yaml
|
||||
```
|
||||
|
||||
JSON and YAML outputs include:
|
||||
|
||||
- `policy`: mode, subject, action, allowed, denied, redacted, audit counts
|
||||
@@ -155,6 +168,21 @@ Identity and directory integration use these provider-neutral boundaries:
|
||||
to `PolicySubject` labels, trust zones, and allowed actions.
|
||||
- `DecisionLogStore` persists durable audit records for policy decisions.
|
||||
|
||||
The Markitect-side enterprise helpers provide deterministic local
|
||||
implementations:
|
||||
|
||||
- `NetKingdomIdentityClaimsAdapter` validates required IAM-profile claims,
|
||||
issuer, audience, token lifetime, local-production issuer safety, roles, and
|
||||
scopes for trusted claims or explicit JWT fixtures.
|
||||
- `StaticDirectoryGroupResolver` records group overage/freshness for tests and
|
||||
development.
|
||||
- `EnterprisePolicyMap` and `LocalEnterprisePolicyMapper` translate groups,
|
||||
roles, and scopes into `PolicySubject` labels, trust zones, and actions.
|
||||
- `FlexAuthResourceManifest` describes Markitect knowledge resources that a
|
||||
future flex-auth service can register.
|
||||
- `LocalDecisionLogStore` is a JSONL development sink; durable enterprise audit
|
||||
remains flex-auth scope.
|
||||
|
||||
Relationship policies use `RelationshipPolicyAdapter`:
|
||||
|
||||
```text
|
||||
|
||||
@@ -155,6 +155,26 @@ assurance metadata before any authorization decision is made. The core package
|
||||
now exposes protocol/data boundaries for this without taking a dependency on
|
||||
Keycloak, Entra, LDAP, SCIM, OpenFGA, OPA, or Cedar client libraries.
|
||||
|
||||
The current Markitect-side implementation provides deterministic local
|
||||
building blocks:
|
||||
|
||||
- `NetKingdomIdentityClaimsAdapter` validates required IAM-profile claims,
|
||||
issuer, audience, token timestamps, roles/scopes, and production rejection of
|
||||
local development issuers for already trusted claims or explicit JWT fixtures.
|
||||
- `EnterprisePolicyMap` and `LocalEnterprisePolicyMapper` map groups, roles,
|
||||
and scopes into `PolicySubject` labels, trust zones, actions, and diagnostic
|
||||
attributes.
|
||||
- `StaticDirectoryGroupResolver` models group freshness and overage without a
|
||||
live directory dependency.
|
||||
- `FlexAuthResourceManifest` describes Markitect knowledge resources for
|
||||
future flex-auth registration.
|
||||
- `LocalDecisionLogStore` provides a JSONL development/test sink for decision
|
||||
records.
|
||||
|
||||
Live OIDC discovery, JWKS signature verification, directory synchronization,
|
||||
central policy administration, and durable enterprise audit should be provided
|
||||
by flex-auth/key-cape-facing adapters rather than Markitect core.
|
||||
|
||||
## Canonical Subject Mapping
|
||||
|
||||
Recommended normalized shape:
|
||||
@@ -209,6 +229,13 @@ trust_zones:
|
||||
required_groups: [/markitect/readers]
|
||||
```
|
||||
|
||||
Command-line subject mapping:
|
||||
|
||||
```text
|
||||
mkt policy subject examples/policy/netkingdom-claims.yaml \
|
||||
--policy-map examples/policy/enterprise-policy-map.yaml
|
||||
```
|
||||
|
||||
## Data/Object Mapping
|
||||
|
||||
Markdown remains the source-friendly object labeling layer:
|
||||
@@ -222,6 +249,23 @@ policy:
|
||||
---
|
||||
```
|
||||
|
||||
A Markitect knowledge base can publish an explicit flex-auth resource manifest:
|
||||
|
||||
```yaml
|
||||
id: markitect-example-knowledge-base
|
||||
system: markitect-tool
|
||||
actions: [read, query, search, package, export]
|
||||
resources:
|
||||
- id: knowledge-base:markitect-example
|
||||
type: knowledge_base
|
||||
- id: document:internal-note
|
||||
type: document
|
||||
parent: knowledge-base:markitect-example
|
||||
path: examples/policy/private/internal-note.md
|
||||
labels: [internal]
|
||||
trust_zone: internal
|
||||
```
|
||||
|
||||
For enterprise environments, object metadata should eventually include:
|
||||
|
||||
- content labels/classification
|
||||
|
||||
@@ -272,6 +272,15 @@ permissions:
|
||||
write: [out]
|
||||
network: false
|
||||
assisted_generation: false
|
||||
policy:
|
||||
subject_from_token: examples/policy/netkingdom-claims.yaml
|
||||
policy_map: examples/policy/enterprise-policy-map.yaml
|
||||
required_assurance:
|
||||
mfa: true
|
||||
emergency_justification: INC-123
|
||||
decision_log: .markitect/policy-decisions.jsonl
|
||||
flex_auth:
|
||||
resource_manifest: examples/policy/flex-auth-resource-manifest.yaml
|
||||
responsibilities:
|
||||
human:
|
||||
approves_outputs: true
|
||||
|
||||
@@ -38,7 +38,7 @@ and descriptions mirror the operational view.
|
||||
| `MKTT-WP-0005` | complete | done | `MKTT-WP-0003`, `MKTT-WP-0004` | Runtime context, form state, dynamic rules, workflow integration, and provider-neutral assessment boundary are complete. |
|
||||
| `MKTT-WP-0011` | complete | done | `MKTT-WP-0003`; task-level triggers: `MKTT-WP-0010-T001`, `MKTT-WP-0010-T005` | Markdown dataflow workflow layer is complete: workflow standard, source collectors, binding model, deterministic steps, assisted boundary, safe outputs, CLI, docs, and examples. |
|
||||
| `MKTT-WP-0009` | complete | done | `MKTT-WP-0006` | Access-controlled knowledge gateway is complete: local labels, trust zones, path rules, policy-aware cache query/search, decisions, diagnostics, and external adapter boundaries. |
|
||||
| `MKTT-WP-0014` | P2 | todo | `MKTT-WP-0009` | Markitect-side enterprise IAM access-control integration: NetKingdom/key-cape-compatible identity claims, flex-auth resource/policy contract, directory group resolution, decision-log sink, and external PDP request examples. |
|
||||
| `MKTT-WP-0014` | complete | done | `MKTT-WP-0009` | Markitect-side enterprise IAM access-control integration is complete: NetKingdom/key-cape-compatible identity claims, flex-auth resource/policy contract, directory group resolution fixtures, decision-log sink, workflow declarations, CLI commands, and external PDP request examples. |
|
||||
| `MKTT-WP-0012` | P3 | todo | `MKTT-WP-0004`, `MKTT-WP-0010`, `MKTT-WP-0011` | Future Quarkdown-inspired document function layer: reusable Markdown-native function calls over processors, references, contracts, workflows, and later assisted steps. |
|
||||
| `MKTT-WP-0008` | P3 | todo | `MKTT-WP-0006`, `MKTT-WP-0007`, `MKTT-WP-0009` | Agent working-memory cache after backend and policy floor are available. |
|
||||
|
||||
@@ -75,14 +75,13 @@ operations deserve author-facing function syntax. It should remain optional and
|
||||
capability-gated, especially before assisted, external, file, or network
|
||||
functions are allowed.
|
||||
|
||||
`MKTT-WP-0014` captures Markitect-side enterprise IAM integration for the
|
||||
access-control gateway. Central authorization administration should live in the
|
||||
future `flex-auth` repo/service; Markitect should provide resource registration,
|
||||
policy request, decision, diagnostics, and local development adapter contracts.
|
||||
It should follow `MKTT-WP-0009` and can run before or alongside
|
||||
security-sensitive context memory work. It does not block local `MKTT-WP-0008`
|
||||
research, but it should gate production deployment of reactivatable agent
|
||||
context packages in enterprise environments.
|
||||
`MKTT-WP-0014` completed Markitect-side enterprise IAM integration for the
|
||||
access-control gateway. Central authorization administration remains
|
||||
`flex-auth` scope; Markitect now provides resource registration, policy
|
||||
request, decision, diagnostics, local development adapter contracts, workflow
|
||||
declarations, and CLI inspection/mapping commands. Production deployment of
|
||||
reactivatable agent context packages should still wait for a flex-auth-backed
|
||||
enterprise policy service or equivalent.
|
||||
|
||||
## State Hub Mirror
|
||||
|
||||
|
||||
58
examples/policy/enterprise-policy-map.yaml
Normal file
58
examples/policy/enterprise-policy-map.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
id: markitect-enterprise-policy-map
|
||||
issuer: https://sso.example.test/realms/netkingdom
|
||||
audiences:
|
||||
- markitect-tool
|
||||
defaults:
|
||||
allowed_labels:
|
||||
- public
|
||||
trust_zones:
|
||||
- public
|
||||
groups:
|
||||
/markitect/readers:
|
||||
allowed_labels:
|
||||
- public
|
||||
- internal
|
||||
trust_zones:
|
||||
- public
|
||||
- internal
|
||||
actions:
|
||||
- read
|
||||
- query
|
||||
- search
|
||||
/markitect/stewards:
|
||||
allowed_labels:
|
||||
- public
|
||||
- internal
|
||||
- restricted
|
||||
trust_zones:
|
||||
- public
|
||||
- internal
|
||||
- restricted
|
||||
actions:
|
||||
- read
|
||||
- query
|
||||
- search
|
||||
- package
|
||||
- export
|
||||
roles:
|
||||
viewer:
|
||||
actions:
|
||||
- read
|
||||
- query
|
||||
- search
|
||||
scopes:
|
||||
markitect:read:
|
||||
actions:
|
||||
- read
|
||||
- query
|
||||
- search
|
||||
trust_zones:
|
||||
internal:
|
||||
required_groups:
|
||||
- /markitect/readers
|
||||
restricted:
|
||||
required_groups:
|
||||
- /markitect/stewards
|
||||
metadata:
|
||||
owner: flex-auth
|
||||
version: example
|
||||
28
examples/policy/external-pdp-requests.yaml
Normal file
28
examples/policy/external-pdp-requests.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
relationship_request:
|
||||
subject: oidc:https://sso.example.test/realms/netkingdom#user-123
|
||||
relation: reader
|
||||
object_id: document:internal-note
|
||||
namespace: markitect/document
|
||||
context:
|
||||
action: query
|
||||
trust_zone: internal
|
||||
resource_path: examples/policy/private/internal-note.md
|
||||
rule_request:
|
||||
subject:
|
||||
id: oidc:https://sso.example.test/realms/netkingdom#user-123
|
||||
roles:
|
||||
- viewer
|
||||
groups:
|
||||
- /markitect/readers
|
||||
assurance:
|
||||
mfa: true
|
||||
action: query
|
||||
object:
|
||||
id: document:internal-note
|
||||
type: document
|
||||
labels:
|
||||
- internal
|
||||
trust_zone: internal
|
||||
context:
|
||||
policy_map_id: markitect-enterprise-policy-map
|
||||
workflow_id: assisted-review-boundary
|
||||
34
examples/policy/flex-auth-resource-manifest.yaml
Normal file
34
examples/policy/flex-auth-resource-manifest.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
id: markitect-example-knowledge-base
|
||||
system: markitect-tool
|
||||
actions:
|
||||
- read
|
||||
- query
|
||||
- search
|
||||
- package
|
||||
- export
|
||||
resources:
|
||||
- id: knowledge-base:markitect-example
|
||||
type: knowledge_base
|
||||
labels:
|
||||
- public
|
||||
trust_zone: public
|
||||
owner: team:platform-architecture
|
||||
- id: document:public-note
|
||||
type: document
|
||||
parent: knowledge-base:markitect-example
|
||||
path: examples/policy/public-note.md
|
||||
labels:
|
||||
- public
|
||||
trust_zone: public
|
||||
owner: team:platform-architecture
|
||||
- id: document:internal-note
|
||||
type: document
|
||||
parent: knowledge-base:markitect-example
|
||||
path: examples/policy/private/internal-note.md
|
||||
labels:
|
||||
- internal
|
||||
trust_zone: internal
|
||||
owner: team:platform-architecture
|
||||
metadata:
|
||||
source: markitect example policy fixtures
|
||||
flex_auth_contract: resource-registration-v0
|
||||
19
examples/policy/netkingdom-claims.yaml
Normal file
19
examples/policy/netkingdom-claims.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
iss: https://sso.example.test/realms/netkingdom
|
||||
sub: user-123
|
||||
aud:
|
||||
- markitect-tool
|
||||
exp: 4102444800
|
||||
iat: 1767225600
|
||||
preferred_username: ada
|
||||
email: ada@example.test
|
||||
name: Ada Lovelace
|
||||
scope: openid profile markitect:read hub:read
|
||||
azp: markitect-cli
|
||||
realm_access:
|
||||
roles:
|
||||
- viewer
|
||||
groups:
|
||||
- /markitect/readers
|
||||
amr:
|
||||
- pwd
|
||||
- otp
|
||||
35
examples/workflows/policy-aware-review.workflow.md
Normal file
35
examples/workflows/policy-aware-review.workflow.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Policy-Aware Review Workflow
|
||||
|
||||
```yaml workflow
|
||||
metadata:
|
||||
id: policy-aware-review
|
||||
intent:
|
||||
summary: Declare enterprise identity and policy mapping for a review workflow.
|
||||
inputs:
|
||||
note:
|
||||
file: ../policy/private/internal-note.md
|
||||
selector: sections[heading=Decision]
|
||||
steps:
|
||||
shape:
|
||||
kind: shape
|
||||
data:
|
||||
note: ${sources.note.items}
|
||||
outputs:
|
||||
review:
|
||||
path: out/policy-aware-review.md
|
||||
content: ${steps.shape.value.note}
|
||||
permissions:
|
||||
policy:
|
||||
subject_from_token: examples/policy/netkingdom-claims.yaml
|
||||
policy_map: examples/policy/enterprise-policy-map.yaml
|
||||
required_assurance:
|
||||
mfa: true
|
||||
decision_log: .markitect/policy-decisions.jsonl
|
||||
flex_auth:
|
||||
resource_manifest: examples/policy/flex-auth-resource-manifest.yaml
|
||||
responsibilities:
|
||||
system:
|
||||
enforces_policy: true
|
||||
human:
|
||||
reviews_denials: true
|
||||
```
|
||||
@@ -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, [], {}, "")
|
||||
}
|
||||
@@ -94,7 +94,12 @@ def test_builtin_policy_descriptor_exposes_cli_and_adapter_boundary():
|
||||
assert {capability.id for capability in descriptor.capabilities} >= {
|
||||
"policy",
|
||||
"policy_filter",
|
||||
"identity_claims",
|
||||
"resource_manifest",
|
||||
"decision_log",
|
||||
}
|
||||
assert "mkt policy check" in descriptor.cli["commands"]
|
||||
assert "mkt policy subject" in descriptor.cli["commands"]
|
||||
assert "mkt policy resource-manifest" in descriptor.cli["commands"]
|
||||
assert "IdentityClaimsAdapter" in descriptor.metadata["external_adapters"]
|
||||
assert "RelationshipPolicyAdapter" in descriptor.metadata["external_adapters"]
|
||||
|
||||
@@ -7,11 +7,19 @@ from markitect_tool.cli import main
|
||||
from markitect_tool.policy import (
|
||||
DirectoryGroupResolution,
|
||||
DirectoryGroupResolutionRequest,
|
||||
EnterprisePolicyError,
|
||||
EnterprisePolicyMap,
|
||||
EnterpriseIdentity,
|
||||
EnterprisePolicyMapRequest,
|
||||
FlexAuthResourceManifest,
|
||||
LocalDecisionLogStore,
|
||||
LocalEnterprisePolicyMapper,
|
||||
LocalLabelPolicy,
|
||||
LocalLabelPolicyGateway,
|
||||
NetKingdomIdentityClaimsAdapter,
|
||||
StaticDirectoryGroupResolver,
|
||||
)
|
||||
from markitect_tool.policy.models import PolicyDecision
|
||||
|
||||
|
||||
POLICY_TEXT = """id: example-policy
|
||||
@@ -247,6 +255,203 @@ def test_enterprise_policy_adapter_requests_serialize_cleanly():
|
||||
assert map_request.to_dict()["identity"]["canonical_id"].endswith("#user-123")
|
||||
|
||||
|
||||
def test_netkingdom_claims_adapter_validates_required_claims_and_audience():
|
||||
adapter = NetKingdomIdentityClaimsAdapter(
|
||||
issuer="https://sso.example.test/realms/netkingdom",
|
||||
audiences=["markitect-tool"],
|
||||
)
|
||||
|
||||
identity = adapter.verify(_claims())
|
||||
|
||||
assert identity.canonical_id == "oidc:https://sso.example.test/realms/netkingdom#user-123"
|
||||
assert identity.roles == ["viewer"]
|
||||
assert identity.scopes == ["openid", "profile", "markitect:read"]
|
||||
assert identity.groups == ["/markitect/readers"]
|
||||
assert identity.assurance["mfa"] is True
|
||||
|
||||
|
||||
def test_netkingdom_claims_adapter_rejects_local_issuer_in_production():
|
||||
adapter = NetKingdomIdentityClaimsAdapter(audiences=["markitect-tool"])
|
||||
claims = _claims() | {"iss": "http://localhost:8080/realms/dev"}
|
||||
|
||||
try:
|
||||
adapter.verify(claims, context={"environment": "production"})
|
||||
except EnterprisePolicyError as exc:
|
||||
assert "Local development issuer" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected local production issuer rejection")
|
||||
|
||||
|
||||
def test_static_group_resolver_reports_group_overage_and_freshness():
|
||||
request = DirectoryGroupResolutionRequest(
|
||||
subject_id="oidc:https://sso.example.test/realms/netkingdom#user-123",
|
||||
issuer="https://sso.example.test/realms/netkingdom",
|
||||
claims={"hasgroups": True},
|
||||
)
|
||||
resolver = StaticDirectoryGroupResolver(
|
||||
groups_by_subject={request.subject_id: ["/markitect/readers"]},
|
||||
refreshed_at="2026-05-04T10:00:00Z",
|
||||
)
|
||||
|
||||
result = resolver.resolve(request)
|
||||
|
||||
assert result.groups == ["/markitect/readers"]
|
||||
assert result.overage is True
|
||||
assert result.refreshed_at == "2026-05-04T10:00:00Z"
|
||||
|
||||
|
||||
def test_local_enterprise_policy_mapper_maps_groups_roles_and_scopes():
|
||||
identity = NetKingdomIdentityClaimsAdapter(
|
||||
issuer="https://sso.example.test/realms/netkingdom",
|
||||
audiences=["markitect-tool"],
|
||||
).verify(_claims())
|
||||
policy_map = EnterprisePolicyMap.from_mapping(_enterprise_policy_map())
|
||||
subject = LocalEnterprisePolicyMapper(policy_map).map_subject(
|
||||
EnterprisePolicyMapRequest(identity=identity)
|
||||
)
|
||||
|
||||
assert subject.allowed_labels == ["public", "internal"]
|
||||
assert subject.trust_zones == ["public", "internal"]
|
||||
assert subject.allowed_actions == ["read", "query", "search"]
|
||||
assert subject.attributes["matched_policy_rules"] == [
|
||||
"group:/markitect/readers",
|
||||
"role:viewer",
|
||||
"scope:markitect:read",
|
||||
]
|
||||
|
||||
|
||||
def test_flex_auth_resource_manifest_serializes_resources():
|
||||
manifest = FlexAuthResourceManifest.from_mapping(
|
||||
{
|
||||
"id": "markitect-example",
|
||||
"system": "markitect-tool",
|
||||
"actions": ["read", "query"],
|
||||
"resources": [
|
||||
{
|
||||
"id": "document:public-note",
|
||||
"type": "document",
|
||||
"path": "examples/policy/public-note.md",
|
||||
"labels": ["public"],
|
||||
"trust_zone": "public",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
data = manifest.to_dict()
|
||||
|
||||
assert data["id"] == "markitect-example"
|
||||
assert data["resources"][0]["id"] == "document:public-note"
|
||||
|
||||
|
||||
def test_local_decision_log_store_redacts_token_context(tmp_path: Path):
|
||||
store = LocalDecisionLogStore(tmp_path / "decisions.jsonl")
|
||||
decision = PolicyDecision(
|
||||
subject="subject-1",
|
||||
action="query",
|
||||
object_id="document:internal",
|
||||
effect="deny",
|
||||
reason="missing label",
|
||||
)
|
||||
|
||||
audit_id = store.record(decision, context={"token": "secret", "policy_version": "v1"})
|
||||
entry = store.get(decision.decision_id)
|
||||
|
||||
assert audit_id.startswith("audit:")
|
||||
assert entry is not None
|
||||
assert entry["context"]["token"] == "<redacted>"
|
||||
assert entry["context"]["policy_version"] == "v1"
|
||||
|
||||
|
||||
def test_mkt_policy_subject_maps_claims_file_to_subject(tmp_path: Path):
|
||||
claims_file = tmp_path / "claims.yaml"
|
||||
map_file = tmp_path / "policy-map.yaml"
|
||||
claims_file.write_text(yaml_dump(_claims()), encoding="utf-8")
|
||||
map_file.write_text(yaml_dump(_enterprise_policy_map()), encoding="utf-8")
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
[
|
||||
"policy",
|
||||
"subject",
|
||||
str(claims_file),
|
||||
"--policy-map",
|
||||
str(map_file),
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
)
|
||||
data = json.loads(result.output)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert data["subject"]["allowed_labels"] == ["public", "internal"]
|
||||
assert data["subject"]["allowed_actions"] == ["read", "query", "search"]
|
||||
|
||||
|
||||
def test_mkt_policy_resource_manifest_inspects_manifest(tmp_path: Path):
|
||||
manifest_file = tmp_path / "resources.yaml"
|
||||
manifest_file.write_text(
|
||||
yaml_dump(
|
||||
{
|
||||
"id": "manifest-1",
|
||||
"system": "markitect-tool",
|
||||
"actions": ["read"],
|
||||
"resources": [{"id": "document:one", "type": "document"}],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
main,
|
||||
["policy", "resource-manifest", str(manifest_file), "--format", "json"],
|
||||
)
|
||||
data = json.loads(result.output)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert data["manifest"]["resources"][0]["id"] == "document:one"
|
||||
|
||||
|
||||
def yaml_dump(value: dict) -> str:
|
||||
import yaml
|
||||
|
||||
return yaml.safe_dump(value, sort_keys=False)
|
||||
|
||||
|
||||
def _claims() -> dict:
|
||||
return {
|
||||
"iss": "https://sso.example.test/realms/netkingdom",
|
||||
"sub": "user-123",
|
||||
"aud": ["markitect-tool"],
|
||||
"exp": 4102444800,
|
||||
"iat": 1767225600,
|
||||
"preferred_username": "ada",
|
||||
"scope": "openid profile markitect:read",
|
||||
"realm_access": {"roles": ["viewer"]},
|
||||
"groups": ["/markitect/readers"],
|
||||
"amr": ["pwd", "otp"],
|
||||
}
|
||||
|
||||
|
||||
def _enterprise_policy_map() -> dict:
|
||||
return {
|
||||
"id": "markitect-enterprise-policy-map",
|
||||
"issuer": "https://sso.example.test/realms/netkingdom",
|
||||
"audiences": ["markitect-tool"],
|
||||
"defaults": {"allowed_labels": ["public"], "trust_zones": ["public"]},
|
||||
"groups": {
|
||||
"/markitect/readers": {
|
||||
"allowed_labels": ["internal"],
|
||||
"trust_zones": ["internal"],
|
||||
"actions": ["read", "query", "search"],
|
||||
}
|
||||
},
|
||||
"roles": {"viewer": {"actions": ["read", "query", "search"]}},
|
||||
"scopes": {"markitect:read": {"actions": ["read", "query", "search"]}},
|
||||
"trust_zones": {"internal": {"required_groups": ["/markitect/readers"]}},
|
||||
}
|
||||
|
||||
|
||||
def _policy_mapping() -> dict:
|
||||
return {
|
||||
"id": "example-policy",
|
||||
|
||||
@@ -83,6 +83,39 @@ def test_load_workflow_file_preserves_standard_sections(tmp_path: Path):
|
||||
assert plan.steps[0]["id"] == "render"
|
||||
|
||||
|
||||
def test_load_workflow_file_preserves_policy_identity_permissions(tmp_path: Path):
|
||||
workflow = tmp_path / "policy.workflow.md"
|
||||
workflow.write_text(
|
||||
"""# Policy Workflow
|
||||
|
||||
```yaml workflow
|
||||
metadata:
|
||||
id: policy-aware
|
||||
inputs:
|
||||
static:
|
||||
value: ok
|
||||
permissions:
|
||||
policy:
|
||||
subject_from_token: examples/policy/netkingdom-claims.yaml
|
||||
policy_map: examples/policy/enterprise-policy-map.yaml
|
||||
required_assurance:
|
||||
mfa: true
|
||||
emergency_justification: INC-123
|
||||
decision_log: .markitect/policy-decisions.jsonl
|
||||
flex_auth:
|
||||
resource_manifest: examples/policy/flex-auth-resource-manifest.yaml
|
||||
```
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
plan = load_workflow_file(workflow)
|
||||
|
||||
assert plan.permissions["policy"]["subject_from_token"] == "examples/policy/netkingdom-claims.yaml"
|
||||
assert plan.permissions["policy"]["required_assurance"]["mfa"] is True
|
||||
assert plan.permissions["flex_auth"]["resource_manifest"].endswith("flex-auth-resource-manifest.yaml")
|
||||
|
||||
|
||||
def test_workflow_runner_collects_sources_and_renders_output(tmp_path: Path):
|
||||
workflow = _write_workflow_fixture(tmp_path)
|
||||
plan = load_workflow_file(workflow)
|
||||
|
||||
@@ -3,10 +3,10 @@ id: MKTT-WP-0014
|
||||
type: workplan
|
||||
title: "Enterprise IAM Access-Control Integration"
|
||||
domain: markitect
|
||||
status: todo
|
||||
status: done
|
||||
owner: markitect-tool
|
||||
topic_slug: markitect
|
||||
planning_priority: P2
|
||||
planning_priority: complete
|
||||
planning_order: 82
|
||||
depends_on_workplans:
|
||||
- MKTT-WP-0009
|
||||
@@ -34,6 +34,24 @@ results. NetKingdom/key-cape-compatible SSO should supply identity claims.
|
||||
External policy engines and enterprise directories should attach through
|
||||
provider-neutral adapters.
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
Implemented the Markitect-side enterprise integration layer without importing
|
||||
central authorization administration into this repo:
|
||||
|
||||
- `NetKingdomIdentityClaimsAdapter` for deterministic IAM-profile claim
|
||||
validation and `EnterpriseIdentity` normalization.
|
||||
- `EnterprisePolicyMap` and `LocalEnterprisePolicyMapper` for mapping groups,
|
||||
roles, and scopes into `PolicySubject` labels, trust zones, and actions.
|
||||
- `StaticDirectoryGroupResolver` for local group freshness/overage fixtures.
|
||||
- `FlexAuthResourceManifest` for Markitect resource registration manifests.
|
||||
- `LocalDecisionLogStore` for JSONL development/test decision logs.
|
||||
- `mkt policy subject` and `mkt policy resource-manifest`.
|
||||
- Examples for claims, policy maps, flex-auth resource manifests, external PDP
|
||||
request shapes, and policy-aware workflows.
|
||||
- Documentation updates for access-control, enterprise IAM, and workflow
|
||||
permission declarations.
|
||||
|
||||
## Background
|
||||
|
||||
`MKTT-WP-0009` implemented local labels, trust zones, path rules, query/search
|
||||
@@ -87,7 +105,7 @@ directory groups -> canonical roles/scopes/trust labels -> PolicySubject
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T001
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1894c50f-95c3-4e1a-bd4f-388f7624ebd7"
|
||||
```
|
||||
@@ -110,20 +128,21 @@ Output: schema, examples, diagnostics, and tests.
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T002
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "8a177375-09b3-4898-a053-7601f82fcb29"
|
||||
```
|
||||
|
||||
Implement an optional `IdentityClaimsAdapter` that consumes
|
||||
NetKingdom/key-cape-compatible OIDC discovery and JWTs.
|
||||
Implement an optional `IdentityClaimsAdapter` for
|
||||
NetKingdom/key-cape-compatible claims.
|
||||
|
||||
It must validate:
|
||||
|
||||
- issuer
|
||||
- audience
|
||||
- expiry and issued-at
|
||||
- signature through JWKS
|
||||
- signature verification provenance for trusted claims or explicit local JWT
|
||||
fixtures; live JWKS verification remains provider-adapter/flex-auth scope
|
||||
- authorized party/client id where required
|
||||
- MFA/assurance claims for privileged actions
|
||||
|
||||
@@ -133,7 +152,7 @@ Output: adapter, fixtures, negative tests, and clear diagnostics.
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T003
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6861d4bc-1bb8-440d-bb9e-33e20c7feb55"
|
||||
```
|
||||
@@ -150,7 +169,7 @@ administration remains flex-auth scope.
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T004
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "56d6bad6-d706-47b3-b321-1f0e870ecc0d"
|
||||
```
|
||||
@@ -165,7 +184,7 @@ Output: resolver contract, freshness metadata, overage handling, and tests.
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T005
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "f212662c-4ffc-4cac-ace2-a43777f4960c"
|
||||
```
|
||||
@@ -184,7 +203,7 @@ Output: storage adapter, CLI inspection path, and tests.
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T006
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "573a198f-df0b-470a-b11c-9ac839c0845e"
|
||||
```
|
||||
@@ -202,7 +221,7 @@ external PDP administration belongs in flex-auth.
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T007
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "c4650304-0e2b-49c5-8569-e69907c08ccc"
|
||||
```
|
||||
@@ -224,7 +243,7 @@ Output: workflow/context integration design, examples, and tests.
|
||||
|
||||
```task
|
||||
id: MKTT-WP-0014-T008
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "0486e0c2-2cb9-4902-9a09-9ec729e9e79f"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user