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
|
--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:
|
JSON and YAML outputs include:
|
||||||
|
|
||||||
- `policy`: mode, subject, action, allowed, denied, redacted, audit counts
|
- `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.
|
to `PolicySubject` labels, trust zones, and allowed actions.
|
||||||
- `DecisionLogStore` persists durable audit records for policy decisions.
|
- `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`:
|
Relationship policies use `RelationshipPolicyAdapter`:
|
||||||
|
|
||||||
```text
|
```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
|
now exposes protocol/data boundaries for this without taking a dependency on
|
||||||
Keycloak, Entra, LDAP, SCIM, OpenFGA, OPA, or Cedar client libraries.
|
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
|
## Canonical Subject Mapping
|
||||||
|
|
||||||
Recommended normalized shape:
|
Recommended normalized shape:
|
||||||
@@ -209,6 +229,13 @@ trust_zones:
|
|||||||
required_groups: [/markitect/readers]
|
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
|
## Data/Object Mapping
|
||||||
|
|
||||||
Markdown remains the source-friendly object labeling layer:
|
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:
|
For enterprise environments, object metadata should eventually include:
|
||||||
|
|
||||||
- content labels/classification
|
- content labels/classification
|
||||||
|
|||||||
@@ -272,6 +272,15 @@ permissions:
|
|||||||
write: [out]
|
write: [out]
|
||||||
network: false
|
network: false
|
||||||
assisted_generation: 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:
|
responsibilities:
|
||||||
human:
|
human:
|
||||||
approves_outputs: true
|
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-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-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-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-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. |
|
| `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
|
capability-gated, especially before assisted, external, file, or network
|
||||||
functions are allowed.
|
functions are allowed.
|
||||||
|
|
||||||
`MKTT-WP-0014` captures Markitect-side enterprise IAM integration for the
|
`MKTT-WP-0014` completed Markitect-side enterprise IAM integration for the
|
||||||
access-control gateway. Central authorization administration should live in the
|
access-control gateway. Central authorization administration remains
|
||||||
future `flex-auth` repo/service; Markitect should provide resource registration,
|
`flex-auth` scope; Markitect now provides resource registration, policy
|
||||||
policy request, decision, diagnostics, and local development adapter contracts.
|
request, decision, diagnostics, local development adapter contracts, workflow
|
||||||
It should follow `MKTT-WP-0009` and can run before or alongside
|
declarations, and CLI inspection/mapping commands. Production deployment of
|
||||||
security-sensitive context memory work. It does not block local `MKTT-WP-0008`
|
reactivatable agent context packages should still wait for a flex-auth-backed
|
||||||
research, but it should gate production deployment of reactivatable agent
|
enterprise policy service or equivalent.
|
||||||
context packages in enterprise environments.
|
|
||||||
|
|
||||||
## State Hub Mirror
|
## 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.literate import tangle_markdown, weave_markdown, write_tangle_files
|
||||||
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
|
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
|
||||||
from markitect_tool.processor import ProcessorContext, run_fenced_processors
|
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 (
|
from markitect_tool.query import (
|
||||||
InvalidQueryError,
|
InvalidQueryError,
|
||||||
extract_document,
|
extract_document,
|
||||||
@@ -791,6 +796,68 @@ def policy_check(
|
|||||||
raise click.exceptions.Exit(0 if decision.get("allowed") else 1)
|
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")
|
@main.group("class")
|
||||||
def class_group() -> None:
|
def class_group() -> None:
|
||||||
"""Resolve deterministic content classes."""
|
"""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')}")
|
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:
|
def _emit_metrics(data: dict, output_format: str) -> None:
|
||||||
if output_format == "json":
|
if output_format == "json":
|
||||||
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
||||||
|
|||||||
@@ -200,6 +200,9 @@ def _local_label_policy_descriptor() -> ExtensionDescriptor:
|
|||||||
capabilities=[
|
capabilities=[
|
||||||
ProcessingCapability(id="policy", kind="authorize"),
|
ProcessingCapability(id="policy", kind="authorize"),
|
||||||
ProcessingCapability(id="policy_filter", kind="filter"),
|
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="diagnostics", kind="emit"),
|
||||||
ProcessingCapability(id="provenance", kind="emit"),
|
ProcessingCapability(id="provenance", kind="emit"),
|
||||||
],
|
],
|
||||||
@@ -211,6 +214,8 @@ def _local_label_policy_descriptor() -> ExtensionDescriptor:
|
|||||||
cli={
|
cli={
|
||||||
"commands": [
|
"commands": [
|
||||||
"mkt policy check",
|
"mkt policy check",
|
||||||
|
"mkt policy subject",
|
||||||
|
"mkt policy resource-manifest",
|
||||||
"mkt cache query --policy",
|
"mkt cache query --policy",
|
||||||
"mkt search --policy",
|
"mkt search --policy",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -14,6 +14,18 @@ from markitect_tool.policy.adapters import (
|
|||||||
RulePolicyAdapter,
|
RulePolicyAdapter,
|
||||||
RulePolicyRequest,
|
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 (
|
from markitect_tool.policy.local import (
|
||||||
LocalLabelPolicy,
|
LocalLabelPolicy,
|
||||||
LocalLabelPolicyGateway,
|
LocalLabelPolicyGateway,
|
||||||
@@ -36,16 +48,26 @@ __all__ = [
|
|||||||
"DirectoryGroupResolutionRequest",
|
"DirectoryGroupResolutionRequest",
|
||||||
"DirectoryGroupResolver",
|
"DirectoryGroupResolver",
|
||||||
"EnterpriseIdentity",
|
"EnterpriseIdentity",
|
||||||
|
"EnterprisePolicyError",
|
||||||
|
"EnterprisePolicyMap",
|
||||||
"EnterprisePolicyMapRequest",
|
"EnterprisePolicyMapRequest",
|
||||||
"EnterprisePolicyMapper",
|
"EnterprisePolicyMapper",
|
||||||
|
"FlexAuthResource",
|
||||||
|
"FlexAuthResourceManifest",
|
||||||
"IdentityClaimsAdapter",
|
"IdentityClaimsAdapter",
|
||||||
|
"LocalDecisionLogStore",
|
||||||
|
"LocalEnterprisePolicyMapper",
|
||||||
"PolicyDecision",
|
"PolicyDecision",
|
||||||
"PolicyFilterResult",
|
"PolicyFilterResult",
|
||||||
"PolicyObject",
|
"PolicyObject",
|
||||||
"PolicySubject",
|
"PolicySubject",
|
||||||
|
"NetKingdomIdentityClaimsAdapter",
|
||||||
"RelationshipPolicyAdapter",
|
"RelationshipPolicyAdapter",
|
||||||
"RelationshipPolicyRequest",
|
"RelationshipPolicyRequest",
|
||||||
"RulePolicyAdapter",
|
"RulePolicyAdapter",
|
||||||
"RulePolicyRequest",
|
"RulePolicyRequest",
|
||||||
|
"StaticDirectoryGroupResolver",
|
||||||
|
"load_enterprise_identity_file",
|
||||||
|
"load_enterprise_policy_subject",
|
||||||
"policy_metadata_from_document",
|
"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} >= {
|
assert {capability.id for capability in descriptor.capabilities} >= {
|
||||||
"policy",
|
"policy",
|
||||||
"policy_filter",
|
"policy_filter",
|
||||||
|
"identity_claims",
|
||||||
|
"resource_manifest",
|
||||||
|
"decision_log",
|
||||||
}
|
}
|
||||||
assert "mkt policy check" in descriptor.cli["commands"]
|
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 "IdentityClaimsAdapter" in descriptor.metadata["external_adapters"]
|
||||||
assert "RelationshipPolicyAdapter" 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 (
|
from markitect_tool.policy import (
|
||||||
DirectoryGroupResolution,
|
DirectoryGroupResolution,
|
||||||
DirectoryGroupResolutionRequest,
|
DirectoryGroupResolutionRequest,
|
||||||
|
EnterprisePolicyError,
|
||||||
|
EnterprisePolicyMap,
|
||||||
EnterpriseIdentity,
|
EnterpriseIdentity,
|
||||||
EnterprisePolicyMapRequest,
|
EnterprisePolicyMapRequest,
|
||||||
|
FlexAuthResourceManifest,
|
||||||
|
LocalDecisionLogStore,
|
||||||
|
LocalEnterprisePolicyMapper,
|
||||||
LocalLabelPolicy,
|
LocalLabelPolicy,
|
||||||
LocalLabelPolicyGateway,
|
LocalLabelPolicyGateway,
|
||||||
|
NetKingdomIdentityClaimsAdapter,
|
||||||
|
StaticDirectoryGroupResolver,
|
||||||
)
|
)
|
||||||
|
from markitect_tool.policy.models import PolicyDecision
|
||||||
|
|
||||||
|
|
||||||
POLICY_TEXT = """id: example-policy
|
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")
|
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:
|
def _policy_mapping() -> dict:
|
||||||
return {
|
return {
|
||||||
"id": "example-policy",
|
"id": "example-policy",
|
||||||
|
|||||||
@@ -83,6 +83,39 @@ def test_load_workflow_file_preserves_standard_sections(tmp_path: Path):
|
|||||||
assert plan.steps[0]["id"] == "render"
|
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):
|
def test_workflow_runner_collects_sources_and_renders_output(tmp_path: Path):
|
||||||
workflow = _write_workflow_fixture(tmp_path)
|
workflow = _write_workflow_fixture(tmp_path)
|
||||||
plan = load_workflow_file(workflow)
|
plan = load_workflow_file(workflow)
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ id: MKTT-WP-0014
|
|||||||
type: workplan
|
type: workplan
|
||||||
title: "Enterprise IAM Access-Control Integration"
|
title: "Enterprise IAM Access-Control Integration"
|
||||||
domain: markitect
|
domain: markitect
|
||||||
status: todo
|
status: done
|
||||||
owner: markitect-tool
|
owner: markitect-tool
|
||||||
topic_slug: markitect
|
topic_slug: markitect
|
||||||
planning_priority: P2
|
planning_priority: complete
|
||||||
planning_order: 82
|
planning_order: 82
|
||||||
depends_on_workplans:
|
depends_on_workplans:
|
||||||
- MKTT-WP-0009
|
- 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
|
External policy engines and enterprise directories should attach through
|
||||||
provider-neutral adapters.
|
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
|
## Background
|
||||||
|
|
||||||
`MKTT-WP-0009` implemented local labels, trust zones, path rules, query/search
|
`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
|
```task
|
||||||
id: MKTT-WP-0014-T001
|
id: MKTT-WP-0014-T001
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1894c50f-95c3-4e1a-bd4f-388f7624ebd7"
|
state_hub_task_id: "1894c50f-95c3-4e1a-bd4f-388f7624ebd7"
|
||||||
```
|
```
|
||||||
@@ -110,20 +128,21 @@ Output: schema, examples, diagnostics, and tests.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MKTT-WP-0014-T002
|
id: MKTT-WP-0014-T002
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "8a177375-09b3-4898-a053-7601f82fcb29"
|
state_hub_task_id: "8a177375-09b3-4898-a053-7601f82fcb29"
|
||||||
```
|
```
|
||||||
|
|
||||||
Implement an optional `IdentityClaimsAdapter` that consumes
|
Implement an optional `IdentityClaimsAdapter` for
|
||||||
NetKingdom/key-cape-compatible OIDC discovery and JWTs.
|
NetKingdom/key-cape-compatible claims.
|
||||||
|
|
||||||
It must validate:
|
It must validate:
|
||||||
|
|
||||||
- issuer
|
- issuer
|
||||||
- audience
|
- audience
|
||||||
- expiry and issued-at
|
- 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
|
- authorized party/client id where required
|
||||||
- MFA/assurance claims for privileged actions
|
- MFA/assurance claims for privileged actions
|
||||||
|
|
||||||
@@ -133,7 +152,7 @@ Output: adapter, fixtures, negative tests, and clear diagnostics.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MKTT-WP-0014-T003
|
id: MKTT-WP-0014-T003
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6861d4bc-1bb8-440d-bb9e-33e20c7feb55"
|
state_hub_task_id: "6861d4bc-1bb8-440d-bb9e-33e20c7feb55"
|
||||||
```
|
```
|
||||||
@@ -150,7 +169,7 @@ administration remains flex-auth scope.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MKTT-WP-0014-T004
|
id: MKTT-WP-0014-T004
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "56d6bad6-d706-47b3-b321-1f0e870ecc0d"
|
state_hub_task_id: "56d6bad6-d706-47b3-b321-1f0e870ecc0d"
|
||||||
```
|
```
|
||||||
@@ -165,7 +184,7 @@ Output: resolver contract, freshness metadata, overage handling, and tests.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MKTT-WP-0014-T005
|
id: MKTT-WP-0014-T005
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "f212662c-4ffc-4cac-ace2-a43777f4960c"
|
state_hub_task_id: "f212662c-4ffc-4cac-ace2-a43777f4960c"
|
||||||
```
|
```
|
||||||
@@ -184,7 +203,7 @@ Output: storage adapter, CLI inspection path, and tests.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MKTT-WP-0014-T006
|
id: MKTT-WP-0014-T006
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "573a198f-df0b-470a-b11c-9ac839c0845e"
|
state_hub_task_id: "573a198f-df0b-470a-b11c-9ac839c0845e"
|
||||||
```
|
```
|
||||||
@@ -202,7 +221,7 @@ external PDP administration belongs in flex-auth.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MKTT-WP-0014-T007
|
id: MKTT-WP-0014-T007
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c4650304-0e2b-49c5-8569-e69907c08ccc"
|
state_hub_task_id: "c4650304-0e2b-49c5-8569-e69907c08ccc"
|
||||||
```
|
```
|
||||||
@@ -224,7 +243,7 @@ Output: workflow/context integration design, examples, and tests.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: MKTT-WP-0014-T008
|
id: MKTT-WP-0014-T008
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "0486e0c2-2cb9-4902-9a09-9ec729e9e79f"
|
state_hub_task_id: "0486e0c2-2cb9-4902-9a09-9ec729e9e79f"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user