From 6cb3b7b1728ebdc5af276f78cd65d03bed71ffc1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 4 May 2026 17:54:52 +0200 Subject: [PATCH] enterprise/flex-auth integration layer --- docs/access-control-policy-gateway.md | 28 + docs/enterprise-access-control-integration.md | 44 ++ docs/workflow-definition-standard.md | 9 + docs/workplan-planning-map.md | 17 +- examples/policy/enterprise-policy-map.yaml | 58 ++ examples/policy/external-pdp-requests.yaml | 28 + .../policy/flex-auth-resource-manifest.yaml | 34 + examples/policy/netkingdom-claims.yaml | 19 + .../workflows/policy-aware-review.workflow.md | 35 ++ src/markitect_tool/cli/main.py | 97 ++- src/markitect_tool/extension/builtins.py | 5 + src/markitect_tool/policy/__init__.py | 22 + src/markitect_tool/policy/enterprise.py | 579 ++++++++++++++++++ tests/test_builtin_extension_catalog.py | 5 + tests/test_policy_gateway.py | 205 +++++++ tests/test_workflow_engine.py | 33 + ...terprise-iam-access-control-integration.md | 45 +- 17 files changed, 1240 insertions(+), 23 deletions(-) create mode 100644 examples/policy/enterprise-policy-map.yaml create mode 100644 examples/policy/external-pdp-requests.yaml create mode 100644 examples/policy/flex-auth-resource-manifest.yaml create mode 100644 examples/policy/netkingdom-claims.yaml create mode 100644 examples/workflows/policy-aware-review.workflow.md create mode 100644 src/markitect_tool/policy/enterprise.py diff --git a/docs/access-control-policy-gateway.md b/docs/access-control-policy-gateway.md index 7bcf43f..28c94c8 100644 --- a/docs/access-control-policy-gateway.md +++ b/docs/access-control-policy-gateway.md @@ -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 diff --git a/docs/enterprise-access-control-integration.md b/docs/enterprise-access-control-integration.md index 2148f35..19b52ad 100644 --- a/docs/enterprise-access-control-integration.md +++ b/docs/enterprise-access-control-integration.md @@ -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 diff --git a/docs/workflow-definition-standard.md b/docs/workflow-definition-standard.md index a5412c6..850307c 100644 --- a/docs/workflow-definition-standard.md +++ b/docs/workflow-definition-standard.md @@ -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 diff --git a/docs/workplan-planning-map.md b/docs/workplan-planning-map.md index 8e338d0..1d68380 100644 --- a/docs/workplan-planning-map.md +++ b/docs/workplan-planning-map.md @@ -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 diff --git a/examples/policy/enterprise-policy-map.yaml b/examples/policy/enterprise-policy-map.yaml new file mode 100644 index 0000000..400956c --- /dev/null +++ b/examples/policy/enterprise-policy-map.yaml @@ -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 diff --git a/examples/policy/external-pdp-requests.yaml b/examples/policy/external-pdp-requests.yaml new file mode 100644 index 0000000..a102d6d --- /dev/null +++ b/examples/policy/external-pdp-requests.yaml @@ -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 diff --git a/examples/policy/flex-auth-resource-manifest.yaml b/examples/policy/flex-auth-resource-manifest.yaml new file mode 100644 index 0000000..376cb8d --- /dev/null +++ b/examples/policy/flex-auth-resource-manifest.yaml @@ -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 diff --git a/examples/policy/netkingdom-claims.yaml b/examples/policy/netkingdom-claims.yaml new file mode 100644 index 0000000..9f1614e --- /dev/null +++ b/examples/policy/netkingdom-claims.yaml @@ -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 diff --git a/examples/workflows/policy-aware-review.workflow.md b/examples/workflows/policy-aware-review.workflow.md new file mode 100644 index 0000000..676ecdd --- /dev/null +++ b/examples/workflows/policy-aware-review.workflow.md @@ -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 +``` diff --git a/src/markitect_tool/cli/main.py b/src/markitect_tool/cli/main.py index bb6c775..1a39448 100644 --- a/src/markitect_tool/cli/main.py +++ b/src/markitect_tool/cli/main.py @@ -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 ''}") + click.echo(f"labels: {', '.join(subject.get('allowed_labels', [])) or ''}") + click.echo(f"trust_zones: {', '.join(subject.get('trust_zones', [])) or ''}") + click.echo(f"actions: {', '.join(subject.get('allowed_actions', [])) or ''}") + + +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 "" + 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)) diff --git a/src/markitect_tool/extension/builtins.py b/src/markitect_tool/extension/builtins.py index 6ac537d..dcfa2e9 100644 --- a/src/markitect_tool/extension/builtins.py +++ b/src/markitect_tool/extension/builtins.py @@ -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", ] diff --git a/src/markitect_tool/policy/__init__.py b/src/markitect_tool/policy/__init__.py index 86c08ac..69f411e 100644 --- a/src/markitect_tool/policy/__init__.py +++ b/src/markitect_tool/policy/__init__.py @@ -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", ] diff --git a/src/markitect_tool/policy/enterprise.py b/src/markitect_tool/policy/enterprise.py new file mode 100644 index 0000000..0c16498 --- /dev/null +++ b/src/markitect_tool/policy/enterprise.py @@ -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] = "" + 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, [], {}, "") + } diff --git a/tests/test_builtin_extension_catalog.py b/tests/test_builtin_extension_catalog.py index 568b900..1f196be 100644 --- a/tests/test_builtin_extension_catalog.py +++ b/tests/test_builtin_extension_catalog.py @@ -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"] diff --git a/tests/test_policy_gateway.py b/tests/test_policy_gateway.py index c25ff98..011a394 100644 --- a/tests/test_policy_gateway.py +++ b/tests/test_policy_gateway.py @@ -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"] == "" + 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", diff --git a/tests/test_workflow_engine.py b/tests/test_workflow_engine.py index 2861a0e..65d1c37 100644 --- a/tests/test_workflow_engine.py +++ b/tests/test_workflow_engine.py @@ -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) diff --git a/workplans/MKTT-WP-0014-enterprise-iam-access-control-integration.md b/workplans/MKTT-WP-0014-enterprise-iam-access-control-integration.md index f2f28da..a5e02a7 100644 --- a/workplans/MKTT-WP-0014-enterprise-iam-access-control-integration.md +++ b/workplans/MKTT-WP-0014-enterprise-iam-access-control-integration.md @@ -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" ```