Compare commits

..

63 Commits

Author SHA1 Message Date
99869b39fb docs(scope): reflect baseline complete — all FLEX-WP-0001..0007 done
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Correct a stale Current State paragraph: FLEX-WP-0002 (standalone core),
0003 (Markitect integration), and 0004 (delegated PDP/directory adapters)
were completed in May 2026, not "planned". Record FLEX-WP-0007 closure:
ops-warden ran the joint OpenBao smoke (2026-06-29, decision
032b096c433ad80c allow; ttl_out_of_bounds deny), with production
policy.enabled deliberately left off while the ecosystem is build-stage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:40:19 +02:00
339c35e876 Close ops-warden policy gate deployment
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-06-30 00:52:56 +02:00
8124367e1d chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-06-30:
  - update .custodian-brief.md for flex-auth
2026-06-30 00:51:58 +02:00
b4520bd731 docs(intent/scope): align with ops-warden as first shipped consumer
Some checks failed
CI / Lint (push) Has been cancelled
CI / Build and Test (push) Has been cancelled
ops-warden's SSH signing policy gate (FLEX-WP-0006 finished, FLEX-WP-0007
deploying) makes it flex-auth's first shipped protected-system consumer.
Update the intent baseline to match the implemented reality:

- SCOPE Current State: standalone Go core + /v1/check is implemented;
  FLEX-WP-0001/0005/0006 complete, 0007 blocked only on T4 VAULT_TOKEN.
- SCOPE Related/Overlapping + Disjoint From: ops-warden is now a consumer,
  not merely disjoint; the once-hypothetical "agt as flex-auth subject"
  flow is realized through the signing gate. Disjointness narrowed to the
  identity surface (warden issues certs, flex-auth never does).
- INTENT Consumer Patterns: lead with the shipped action-gate shape
  (ops-warden), keep Markitect as the planned knowledge-pipeline consumer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 20:37:07 +02:00
941501c590 FLEX-WP-0007: production registry fixture, tests, and sync runbook
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Add production_registry_snapshot.json from ops-warden inventory with CI
coverage for real actors, IAM subject binding, ttl_out_of_bounds, and
unknown_actor_resource. Extend serve contract tests with /healthz and
publish the registry sync contract for operator deployment.
2026-06-24 14:52:35 +02:00
fae0f00a69 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-06-24:
  - update .custodian-brief.md for flex-auth
2026-06-24 13:11:03 +02:00
77bcd55ddb chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-06-24:
  - update .custodian-brief.md for flex-auth
2026-06-24 01:45:33 +02:00
f0d1afa237 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-06-23:
  - update .custodian-brief.md for flex-auth
2026-06-23 21:19:00 +02:00
0fde95a87c FLEX-WP-0006: implement ops-warden signing gate policy
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-06-23 21:17:42 +02:00
53e0d055c9 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-06-23:
  - update .custodian-brief.md for flex-auth
2026-06-23 17:35:12 +02:00
e1c141234a chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for flex-auth
2026-06-22 23:21:50 +02:00
8a913d6163 Normalize agent instructions and workplan frontmatter (STATE-WP-0067)
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
- Align agent files with on-disk workplan prefixes (infer from workplan ids)
- Set workplan domain to registered domain_slug; add topic_slug where applicable
- Repair frontmatter delimiter formatting; migrate legacy task status literals
- Regenerate AGENTS.md, CLAUDE.md, and .claude/rules from State Hub templates
2026-06-22 23:16:25 +02:00
1be449dae8 Human-review .repo-classification.yaml (CUST-WP-0050 follow-up)
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-06-22 17:56:17 +02:00
1b899cd41c Add .repo-classification.yaml (CUST-WP-0050 T11 agent first-pass) 2026-06-22 17:47:36 +02:00
2230163de1 Add credential routing instructions for all agent runtimes
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Propagate shared credential-routing section (Codex, Claude, Grok, llm-connect)
from state-hub template via scripts/propagate_credential_routing.py.
2026-06-18 22:48:37 +02:00
3247f5d357 Add capability registry with seed entry from reuse-surface
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Bootstrap registry layout and migrate helix_forge capability owned by
this repository (REUSE-WP-0014-T02).
2026-06-16 01:46:54 +02:00
aa8e3a4e34 Align IAM Profile consumption with v0.2
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-22 14:35:30 +02:00
8354485632 Make INTENT.md self-coherent
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Remove external reference points so the intent stands on its own at the
abstract, stable level: drop named identity/SSO systems, named PDP/policy
products, named directory/enterprise systems, the named first-consumer
project, and the external IAM-profile path. Keep all of flex-auth's own
substance — purpose, responsibility boundary (stated as abstract roles:
identity layer / authorization / enforcement points), design principles,
concepts, API shape, standalone vs delegated mode, non-goals, early work.

Relationships to other systems belong in interface contracts and the
orchestration responsibility map, not in intent.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 01:46:12 +02:00
12c4bed6f4 Refresh agent instruction files
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-18 16:55:41 +02:00
af3e8b2af2 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 07:28:16 +02:00
99a521e176 Document delegated mode operations
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 07:27:45 +02:00
1f5e9626e5 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 07:25:20 +02:00
32933c71f9 Add directory group resolver adapters
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 07:24:50 +02:00
ac9cf09545 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 07:19:17 +02:00
360025e38b Add Keycloak authorization adapter path
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 07:18:45 +02:00
3fdbc7acb7 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 07:13:56 +02:00
ad4895187b Add rule PDP adapter boundary
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 07:13:27 +02:00
4bb329c921 Add relationship PDP adapter boundary
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 07:06:14 +02:00
90021d16b6 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 07:04:59 +02:00
8a61e40bd6 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:58:50 +02:00
1ce0181e8f Implement Topaz adapter
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:58:04 +02:00
0fbb2a45c2 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:41:41 +02:00
184ce5a380 Document Markitect integration flow
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:41:07 +02:00
131fd2cd9b chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:37:27 +02:00
3d1967cb41 Add Markitect adapter contract tests
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:36:52 +02:00
7e09a21c5f Add Markitect check fixtures
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:32:05 +02:00
96e53bf1d9 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:31:11 +02:00
1c915f12d7 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:26:49 +02:00
b6712850c3 Define Markitect action vocabulary
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:26:13 +02:00
50e436093a chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:22:05 +02:00
9e2591c1f4 Import Markitect resource manifests
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:21:28 +02:00
dd4f688ab6 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:16:45 +02:00
12dbf52586 Mark core workplan completed
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:16:13 +02:00
a285959183 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:14:49 +02:00
6586adb4f5 Define Markitect resource namespace
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:14:04 +02:00
4c9f964425 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:08:42 +02:00
6bff4cd7c9 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:05:47 +02:00
18054bd160 Add CARING examples and coverage
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 06:05:18 +02:00
49655e40e0 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 06:00:25 +02:00
61e113f8b6 Add CLI and service skeleton
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 05:59:48 +02:00
ccf68332f8 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 05:52:12 +02:00
2b103ea70b Add local decision log
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 05:51:37 +02:00
4342f98d83 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Lint (push) Has been cancelled
CI / Build and Test (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 05:46:22 +02:00
faea068721 Implement list allowed and explain
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 05:45:36 +02:00
aa70dbebe1 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 05:39:41 +02:00
54984585e3 Implement deterministic check APIs
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 05:38:57 +02:00
fa1b42e678 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 05:31:20 +02:00
550d096cb2 Implement policy package loader
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 05:30:40 +02:00
2cce434d47 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 05:11:06 +02:00
3c4f8fc2b4 Implement local registry store
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 05:10:17 +02:00
4f4c290684 chore(consistency): sync task status from DB [auto]
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Updated by fix-consistency on 2026-05-17:
  - update .custodian-brief.md for flex-auth
2026-05-17 05:01:55 +02:00
7fdf6d63d5 Implement canonical schema foundation
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 04:59:18 +02:00
dd0b9663c4 Refine workplans for CARING profile
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-05-17 04:15:38 +02:00
156 changed files with 16484 additions and 245 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,50 @@
# Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=flex-auth` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes**`warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/FLEX-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone",
topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **flex-auth** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** flex-auth - (fill in purpose)
**Domain:** infotech
**Repo slug:** flex-auth
**Topic ID:** cee7bedf-2b48-46ef-8601-006474f2ad7a

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("infotech")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="flex-auth", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=flex-auth&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:flex-auth]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="cee7bedf-2b48-46ef-8601-006474f2ad7a", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"cee7bedf-2b48-46ef-8601-006474f2ad7a","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=flex-auth
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=flex-auth
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/FLEX-WP-NNNN-<slug>.md`
ID prefix: `FLEX-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-FLEX-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:flex-auth]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: FLEX-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -1,58 +1,18 @@
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief — flex-auth
**Domain:** netkingdom
**Last synced:** 2026-05-16 06:09 UTC
**Domain:** communication
**Last synced:** 2026-06-29 22:51 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
### Foundations and Topaz Alignment
Progress: 4/6 done | workstream_id: `e37d42a9-0018-4a67-a672-ff4e9716b338`
**Open tasks:**
- ► P5.4 - Topaz alignment spike `b8a314c3`
- · P5.5 - Cite NetKingdom IAM Profile and pin claim consumption `b31dab7b`
### Standalone Policy-as-Code Core
Progress: 0/8 done | workstream_id: `aa60e183-9a87-4e03-99b0-15786bfa11ae`
**Open tasks:**
- · P2.1 - Define canonical schemas `534e5251`
- · P2.2 - Implement local registry store `d8045124`
- · P2.3 - Implement policy package loader and validator `09be0f25`
- · P2.4 - Implement deterministic check and batch_check APIs `f6427575`
- · P2.5 - Implement list_allowed and explain `e8fcbabd`
- · P2.6 - Add local decision log `2def10c1`
- · P2.7 - Add CLI and service skeleton `ee9ae6dd`
- … and 1 more open tasks
### Markitect Consumer Integration
Progress: 0/6 done | workstream_id: `c0a6c9f6-bb6b-416d-b537-f30504c63d75`
**Open tasks:**
- · P3.1 - Define Markitect resource namespace `53f2fa67`
- · P3.2 - Import Markitect resource manifests `90082eaf`
- · P3.3 - Define Markitect action vocabulary `cfc78bbb`
- · P3.4 - Implement Markitect check fixtures `1d5de3b2`
- · P3.5 - Add Markitect adapter contract tests `f9297b0d`
- · P3.6 - Document integration flow `e34b0303`
### Delegated PDP and Directory Adapters
Progress: 0/6 done | workstream_id: `99a82976-d376-42b0-89cc-c44e01c0bec6`
**Open tasks:**
- · P4.1 - Evaluate Topaz as MVP delegated backend `9046418c`
- · P4.2 - Add relationship PDP adapter boundary `b77a0b70`
- · P4.3 - Add rule PDP adapter boundary `4e4e5e45`
- · P4.4 - Add Keycloak Authorization Services adapter path `8d3bbc28`
- · P4.5 - Add Entra/Graph and SCIM group resolver adapters `4fc3fb91`
- · P4.6 - Add delegated-mode operations docs `491260f9`
*(none — repo may need first-session setup)*
---
## MCP Orientation (when available)
If the state-hub MCP server is reachable, call:
`get_domain_summary("netkingdom")`
`get_domain_summary("communication")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

25
.repo-classification.yaml Normal file
View File

@@ -0,0 +1,25 @@
repo_classification:
standard: Repo Classification Standard
version: '1.0'
classified_at: '2026-06-22'
classified_by: human
category: product
domain: infotech
secondary_domains:
- government
capability_tags:
- identity
- access-control
- policy
- governance
- audit
business_stake:
- technology
- legal
- operations
- product
business_mechanics:
- control
- coordination
- adaptation
notes: Policy-as-code authorization registry; human corrected domain from communication→infotech.

219
AGENTS.md Normal file
View File

@@ -0,0 +1,219 @@
# flex-auth — Agent Instructions
## Repo Identity
**Purpose:** flex-auth - (fill in purpose)
**Domain:** infotech
**Repo slug:** flex-auth
**Topic ID:** `cee7bedf-2b48-46ef-8601-006474f2ad7a`
**Workplan prefix:** `FLEX-WP-`
---
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
| Context | URL |
|---------|-----|
| Local workstation | `http://127.0.0.1:8000` |
| Remote via tunnel | `http://127.0.0.1:18000` |
### Orient at session start
```bash
# Offline brief — works without hub connection
cat .custodian-brief.md
# Active workstreams for this domain
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \
| python3 -m json.tool
# Check inbox
curl -s "http://127.0.0.1:8000/messages/?to_agent=flex-auth&unread_only=true" \
| python3 -m json.tool
```
Mark a message read:
```bash
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
### Log progress (required at session close)
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{
"summary": "what was done",
"event_type": "note",
"author": "codex",
"workstream_id": "<uuid>",
"task_id": "<uuid>"
}'
```
Omit `workstream_id` / `task_id` when not applicable.
### Update task status
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"needs_human": true, "intervention_note": "reason"}'
```
---
## Session Protocol
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=flex-auth&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
- Record significant decisions via `POST /decisions/`
**Close:**
1. Update workplan file task statuses to reflect progress
2. Log: `POST /progress/` with a summary of what changed
3. Note for the custodian operator: after workplan file changes, run from
`~/state-hub`:
```bash
make fix-consistency REPO=flex-auth
```
This syncs task status from files into the hub DB.
---
## Credential and access routing
**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect**
for inference. Run this check **before** requesting secrets, API keys, SSH access,
login tokens, or database passwords — in any repo, not only `ops-warden`.
ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every
other credential need belongs to another subsystem. **Do not** message
`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key.
### Lookup (do this first)
```bash
warden route find "<describe your need>" --json
warden route show <catalog-id> --json
```
Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`).
| Agent runtime | How to orient |
| --- | --- |
| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=flex-auth` is for coordination, not secret vending |
| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership |
| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` |
### Quick routing table
| I need… | Owner | ops-warden executes? |
| --- | --- | --- |
| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` |
| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only |
| Login / OIDC / MFA | key-cape / Keycloak | No — route only |
| Authorization decision | flex-auth | No — route only |
| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` |
| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only |
### Anti-patterns (do not do these)
- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc.
- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist
- Pasting secrets into Git, State Hub, workplans, logs, or chat
### Other capabilities (reuse-surface)
Non-credential capabilities are usually discovered through **reuse-surface** federation
(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in
every repo's agent instructions because it is high-frequency, high-risk, and easy to
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
Work items originate as files in this repo — not in the hub. The hub is a
read/cache/index layer that rebuilds from files.
**File location:** `workplans/FLEX-WP-NNNN-<slug>.md`
**Archived location:** finished workplans may move to
`workplans/archived/YYMMDD-FLEX-WP-NNNN-<slug>.md`. The `YYMMDD` prefix is
the completion/archive date; the frontmatter `id` does not change.
**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use
`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use
this only for low-risk work completed directly; create a normal workplan for
anything needing analysis, design, approval, dependencies, or multiple phases.
**Frontmatter:**
```yaml
---
id: FLEX-WP-NNNN
type: workplan
title: "..."
domain: infotech
repo: flex-auth
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
created: "YYYY-MM-DD"
updated: "YYYY-MM-DD"
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
---
```
Use `proposed` for a new draft, `ready` after review against current repo
state, and `finished` after implementation. `stalled` and `needs_review` are
derived health labels, not frontmatter statuses.
**Task block format** (one per `##` section):
```
## Task Title
` ` `task
id: FLEX-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` `
Task description text.
```
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above
2. Notify the custodian operator to run `make fix-consistency REPO=flex-auth`
(or send a message to the hub agent via `POST /messages/`)

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# flex-auth — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

122
INTENT.md
View File

@@ -1,6 +1,9 @@
# Flex-Auth Intent
Date: 2026-05-04
> This file captures **why this repository exists**, the **direction it is
> moving toward**, and the **kind of system it is meant to become**.
> It is intentionally **aspirational and stable**, not a description of
> current implementation.
## Intent
@@ -9,20 +12,19 @@ organizations that want to grow from simple access rules into enterprise-grade
authorization without giving up clear ownership, local development ergonomics,
or inspectable policy decisions.
It belongs in the NetKingdom tooling landscape as the authorization counterpart
to key-cape/NetKingdom identity:
It is the **authorization layer** in the path from verified identity to
protected resources:
```text
key-cape / NetKingdom SSO
-> verified OIDC/SAML identity claims
verified identity claims
-> flex-auth policy-as-code and authorization registry
-> protected systems and knowledge tools
-> protected systems and their resources
```
Flex-auth should run usefully on its own, but it should also delegate to or
coordinate with established authorization engines such as Topaz, OpenFGA,
SpiceDB, OPA, Cedar, Keycloak Authorization Services, and enterprise directory
systems.
Flex-auth should run usefully on its own, and should also be able to delegate
to or coordinate established authorization engines — relationship/graph
engines, rule and attribute policy engines, and directory systems — without
binding its own model to any one of them.
## Why This Exists
@@ -36,7 +38,7 @@ conditionals. Over time they need richer policy:
- emergency/break-glass controls
- policy tests and reviewable changes
- durable decision logs and explainability
- integration with SSO, MFA, service accounts, and directory groups
- integration with identity, MFA, service accounts, and directory groups
Flex-auth should give those organizations a path that starts small and grows
cleanly instead of forcing an early leap into a large IAM platform or letting
@@ -44,19 +46,17 @@ authorization logic sprawl across applications.
## Responsibility Boundary
The identity contract flex-auth consumes is the **NetKingdom IAM Profile**
(`~/the-custodian/canon/standards/iam-profile_v0.1.md`), implemented by
key-cape in lightweight mode and by Keycloak in heavy mode. flex-auth
treats the profile as normative input and never re-defines it.
Flex-auth consumes **verified identity claims** as normative input and never
re-defines them. Identity proves who an actor is and how they were
authenticated; flex-auth decides what that actor is allowed to do.
### key-cape / NetKingdom Owns Identity
### The Identity Layer Owns Identity
- OIDC discovery and token issuance.
- Human login, MFA, PKCE, service accounts, token lifecycle.
- Canonical IAM profile and required claims.
- Coarse app roles/scopes and assurance claims.
- Authentication, login, MFA, and token issuance and lifecycle.
- The canonical identity claim contract and required claims.
- Coarse roles, scopes, and assurance claims.
### flex-auth Owns Authorization
### Flex-Auth Owns Authorization
- Protected-system registration.
- Resource namespaces and resource hierarchy.
@@ -70,17 +70,17 @@ treats the profile as normative input and never re-defines it.
### Protected Systems Own Enforcement
Applications such as Markitect remain policy enforcement points. They extract
resource metadata, call flex-auth for decisions, enforce allow/deny/redact
results, and emit local diagnostics. They do not own central enterprise policy
administration.
Applications remain policy enforcement points. They extract resource
metadata, call flex-auth for decisions, enforce allow/deny/redact results,
and emit local diagnostics. They do not own central policy administration.
## Design Principles
- Policy is code: versioned, reviewed, tested, and explainable.
- Identity is not authorization: SSO claims are inputs, not final decisions.
- Identity is not authorization: identity claims are inputs, not final
decisions.
- Start standalone, scale outward: a local flex-auth deployment should be
useful before Topaz/OpenFGA/OPA integrations are available.
useful before any external policy engine integration is available.
- Backend-neutral core: flex-auth has its own resource, action, request,
decision, and audit vocabulary.
- Pluggable PDPs: relationship, rule, and directory engines are adapters, not
@@ -88,7 +88,7 @@ administration.
- Fail visibly: denied, redacted, stale, partial, and uncertain decisions must
produce useful diagnostics.
- Grow into enterprise: the same model should support local dev, small teams,
NetKingdom-managed deployments, and larger Keycloak/Entra environments.
and larger enterprise environments.
## First-Class Concepts
@@ -131,40 +131,62 @@ Standalone flex-auth should provide:
- deterministic check and batch-check APIs
- local decision log
- CLI and service mode
- test fixtures for Keycloak/key-cape-like claims
- test fixtures for representative identity claims
Standalone mode should be enough for development, smaller deployments, and
integration tests.
## Delegated Mode
Delegated mode should let flex-auth coordinate established systems:
Delegated mode should let flex-auth coordinate established systems without
adopting their models as its own:
- Topaz for a local directory plus OPA/Rego policy evaluation.
- OpenFGA or SpiceDB for graph-heavy relationship authorization.
- OPA or Cedar for attribute/rule policies.
- Keycloak Authorization Services for Keycloak-centric deployments.
- Microsoft Graph or SCIM/LDAP/Keycloak APIs for directory group resolution.
- relationship/graph engines for relationship-heavy authorization
- rule and attribute policy engines for attribute/rule policies
- directory systems for group resolution
- a local directory plus policy-evaluation engine for self-contained
delegated setups
Flex-auth remains the stable control plane even when the PDP backend changes.
Flex-auth remains the stable control plane even when the backend changes.
## First Consumer: Markitect
## Consumer Patterns
Markitect is the first concrete consumer:
Two consumer shapes drive flex-auth, and the first one to ship deliberately
is not a document pipeline — proving the control plane stays generic.
- Markitect registers knowledge bases, repositories, documents, sections,
context packages, workflow artifacts, and exports.
- Markitect sends policy checks before returning query/search/context results.
- Markitect can redact or drop results based on decisions.
- flex-auth owns central policy administration and durable audit.
**First shipped consumer — an action gate (ops-warden SSH signing).** A
protected system asks flex-auth a single "may this actor perform this action
now?" question before doing irreversible work:
This first consumer should shape flex-auth around real Markdown knowledge
pipelines without making the policy service Markitect-specific.
- it registers a protected system, a resource type (`ssh-certificate`), and an
action (`sign`)
- it sends one policy check per request, passing subject, resource, and
context (actor type, principals, TTL, key fingerprint)
- it enforces the allow/deny decision and records the decision id for audit
- flex-auth owns the policy and durable decision log; the protected system
keeps custody of its own keys and secrets
This first consumer validated that flex-auth's resource/action/context model,
`POST /v1/check` contract, and decision envelope work for a non-document,
high-stakes gate without any consumer-specific routes.
**First knowledge-pipeline consumer (planned) — a document and knowledge
pipeline (Markitect):**
- it registers knowledge bases, repositories, documents, sections, context
packages, workflow artifacts, and exports
- it sends policy checks before returning query/search/context results
- it can redact or drop results based on decisions
- flex-auth owns central policy administration and durable audit
Together these shape flex-auth around real authorization needs — both
point-in-time action gates and result-filtering pipelines — without making the
policy service consumer-specific.
## Non-Goals
- Flex-auth is not an identity provider.
- Flex-auth is not a replacement for key-cape or NetKingdom SSO.
- Flex-auth is not a replacement for an identity or SSO system.
- Flex-auth is not a mandatory dependency for every local development use case.
- Flex-auth should not force one PDP backend.
- Flex-auth should not hide policy complexity behind opaque admin toggles.
@@ -174,7 +196,7 @@ pipelines without making the policy service Markitect-specific.
1. Define the resource/action/decision model.
2. Define policy package structure and test fixtures.
3. Implement standalone registry and check API.
4. Add Markitect resource manifest and policy adapter.
5. Evaluate Topaz as the first delegated backend.
6. Add OpenFGA/SpiceDB and OPA adapter spikes.
7. Add Keycloak/key-cape and Entra integration examples.
4. Add a first protected-system resource manifest and policy adapter.
5. Evaluate a delegated directory-plus-policy backend.
6. Add relationship-engine and rule-engine adapter spikes.
7. Add identity and enterprise-directory integration examples.

View File

@@ -14,5 +14,8 @@ The 2026-05-15 pre-implementation assessment that shapes the current
sequencing is in
[docs/pre-implementation-assessment.md](docs/pre-implementation-assessment.md).
The CARING reference-implementation approach is captured in
[docs/caring-architecture-blueprint.md](docs/caring-architecture-blueprint.md).
Workplans live in [workplans/](workplans/), with sequencing captured in
[docs/workplan-planning-map.md](docs/workplan-planning-map.md).

View File

@@ -72,15 +72,30 @@ can be coordinated behind a stable flex-auth API.
## Current State
The repository contains the intent baseline, authorization landscape
research, initial workplans, and the pre-implementation assessment and
ADR set produced on 2026-05-15. `FLEX-WP-0001` is complete. Implementation
now proceeds through `FLEX-WP-0005 Foundations and Topaz Alignment`
which lands the Go skeleton, pins the `FlexAuthResourceManifest` schema,
runs the Topaz mapping spike, and records ADR-001/002/003 — before the
standalone policy-as-code core in `FLEX-WP-0002`. Markitect consumer
integration and delegated PDP/directory adapters are planned after the
core contracts stabilize.
The standalone core is implemented and **all seven baseline workplans
(`FLEX-WP-0001` through `FLEX-WP-0007`) are complete.** The repository carries
the intent baseline, authorization landscape research, ADR set, and a working
Go service (`cmd/flex-auth`) with `validate`, `load-registry`, `serve`, and
`POST /v1/check` plus registry, policy, decision, audit, Markitect, and
delegated-adapter internals. The standalone policy-as-code core (`FLEX-WP-0002`),
Markitect consumer integration (`FLEX-WP-0003`, manifest ingest, decisions, and
fixtures), and the delegated PDP/directory adapter shapes (`FLEX-WP-0004`,
Topaz/OpenFGA/OPA/Cedar/Keycloak/Entra tradeoffs documented with at least one
controlled adapter shape) all landed in May 2026.
The **first shipped protected-system consumer is ops-warden**: its opt-in
pre-sign gate calls `POST /v1/check` for `resource.type: ssh-certificate`,
`action: sign` decisions (`examples/ops-warden/`, policy package, allow/deny
fixtures, and tests). `FLEX-WP-0006` published that gate and `FLEX-WP-0007`
deployed flex-auth as a reachable production runtime for it. The joint
OpenBao-backed smoke is verified (2026-06-29: vault-backed allow recorded
`decision:032b096c433ad80c`; TTL-over-max denied `ttl_out_of_bounds` by
flex-auth before OpenBao). Production `policy.enabled` is **deliberately left
off** for now — the ecosystem is still build-stage/pre-testing, so the gate is
verified and banked for later live enforcement rather than forced into premature
production rigor. With the baseline complete, new work (live enforcement
rollout, additional consumers, deeper delegated backends) will open as fresh
workplans.
State Hub integration is present through:
@@ -126,8 +141,18 @@ local diagnostics.
- key-cape / NetKingdom SSO: identity source and coarse claims provider;
flex-auth consumes the **NetKingdom IAM Profile**
(`~/the-custodian/canon/standards/iam-profile_v0.1.md`).
- Markitect: first protected-system consumer and policy enforcement point.
(`~/net-kingdom/canon/standards/iam-profile_v0.2.md`).
- ops-warden: first **shipped** protected-system consumer. Its opt-in
pre-sign gate calls flex-auth for `ssh-certificate` / `sign` decisions
before issuing a short-lived SSH certificate (`FLEX-WP-0006`,
`FLEX-WP-0007`). ops-warden owns the SSH CA, OpenBao signing, and actor
inventory; flex-auth owns the policy decision. ops-warden's routing
charter names flex-auth as the owner of every "may I perform action X?"
question.
- Markitect: first **knowledge-pipeline** consumer. Integration is complete on
the flex-auth side (`FLEX-WP-0003` — resource-manifest ingest, Markitect-
compatible decisions, and fixtures); a live Markitect runtime calling the gate
in production is the next consumer milestone after ops-warden.
- Topaz: aligned evaluator. Per ADR-003 the standalone core is shaped
to match Topaz's Rego + directory model from day one; the Topaz
adapter in `FLEX-WP-0004` is therefore a small step rather than a
@@ -143,12 +168,13 @@ local diagnostics.
## Disjoint From
- **ops-warden** signs short-lived SSH certificates for ops actors
(`adm`/`agt`/`atm`). That is a separate identity surface — SSH certs,
not OIDC subjects — and ops-warden disclaims being a resource-policy
engine. flex-auth and ops-warden therefore do not overlap. (A future
flow could surface an `agt` actor as a flex-auth subject; nothing in
the current design requires it.)
- **ops-warden** is a flex-auth *consumer*, not an overlap (see Related /
Overlapping). The two remain disjoint on **identity surface**: ops-warden
issues SSH certificates for ops actors (`adm`/`agt`/`atm`) and is not a
resource-policy engine; flex-auth decides whether a given sign request is
allowed and never issues certificates. The once-hypothetical flow of
surfacing an `agt` actor as a flex-auth subject is now realized through
the signing policy gate.
- **ops-bridge** owns SSH reverse-tunnel connectivity and explicitly
disclaims being a credential authority or policy engine. No overlap.

View File

@@ -1,30 +1,505 @@
// Command flex-auth is the CLI entry point for the flex-auth authorization
// registry and control plane.
//
// At skeleton stage this binary only reports its version. Subcommands
// (validate, load, test, check, batch-check, explain) are added in
// FLEX-WP-0002.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"strings"
"gopkg.in/yaml.v3"
"github.com/netkingdom/flex-auth/internal/audit"
decisioncore "github.com/netkingdom/flex-auth/internal/decision"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
// version is set at build time via -ldflags "-X main.version=".
// version is set at build time via -ldflags "-X main.version=...".
var version = "0.0.0-dev"
func main() {
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
if *showVersion || (flag.NArg() > 0 && flag.Arg(0) == "version") {
fmt.Println(version)
return
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return 64
}
fmt.Fprintln(os.Stderr, "flex-auth: no subcommand yet (skeleton stage — see workplans/FLEX-WP-0002).")
fmt.Fprintln(os.Stderr, "Try: flex-auth --version")
os.Exit(64) // EX_USAGE
switch args[0] {
case "version", "--version", "-version":
fmt.Fprintln(stdout, version)
return 0
case "validate":
return runValidate(args[1:], stdout, stderr)
case "load-registry":
return runLoadRegistry(args[1:], stdout, stderr)
case "test-policy":
return runTestPolicy(args[1:], stdout, stderr)
case "check":
return runCheck(args[1:], stdout, stderr)
case "batch-check":
return runBatchCheck(args[1:], stdout, stderr)
case "list-allowed":
return runListAllowed(args[1:], stdout, stderr)
case "explain":
return runExplain(args[1:], stdout, stderr)
case "serve":
return runServe(args[1:], stdout, stderr)
case "help", "-h", "--help":
printUsage(stdout)
return 0
default:
fmt.Fprintf(stderr, "flex-auth: unknown subcommand %q\n", args[0])
printUsage(stderr)
return 64
}
}
func runValidate(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("validate", stderr)
kind := fs.String("kind", "", "resource-manifest, subject-manifest, protected-system, relationship, access-descriptor, or policy")
file := fs.String("file", "", "YAML/JSON file to validate")
if err := fs.Parse(args); err != nil {
return 64
}
if *kind == "" || *file == "" {
fmt.Fprintln(stderr, "validate requires --kind and --file")
return 64
}
switch *kind {
case "resource-manifest":
var manifest api.ResourceManifest
if err := readYAML(*file, &manifest); err != nil {
return fail(stderr, err)
}
if err := registry.NewStore().ImportResourceManifest(manifest); err != nil {
return fail(stderr, err)
}
case "subject-manifest":
var manifest api.SubjectManifest
if err := readYAML(*file, &manifest); err != nil {
return fail(stderr, err)
}
if err := registry.NewStore().ImportSubjectManifest(manifest); err != nil {
return fail(stderr, err)
}
case "protected-system":
var manifest api.ProtectedSystemManifest
if err := readYAML(*file, &manifest); err != nil {
return fail(stderr, err)
}
if err := registry.NewStore().PutProtectedSystem(manifest); err != nil {
return fail(stderr, err)
}
case "relationship":
var fact api.RelationshipFact
if err := readYAML(*file, &fact); err != nil {
return fail(stderr, err)
}
if err := registry.NewStore().PutRelationship(fact); err != nil {
return fail(stderr, err)
}
case "access-descriptor":
var descriptor api.CaringAccessDescriptor
if err := readYAML(*file, &descriptor); err != nil {
return fail(stderr, err)
}
if err := validateDescriptor(descriptor); err != nil {
return fail(stderr, err)
}
case "policy", "policy-package":
pkg, err := policy.LoadAndValidateFile(context.Background(), *file)
if err != nil {
return fail(stderr, err)
}
if err := writeJSON(stdout, pkg.Validation); err != nil {
return fail(stderr, err)
}
if !pkg.Valid {
return 1
}
return 0
default:
fmt.Fprintf(stderr, "unsupported validate kind %q\n", *kind)
return 64
}
return writeStatus(stdout, "valid", map[string]any{"kind": *kind, "file": *file})
}
func runLoadRegistry(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("load-registry", stderr)
file := fs.String("file", "", "registry snapshot JSON file")
if err := fs.Parse(args); err != nil {
return 64
}
if *file == "" {
fmt.Fprintln(stderr, "load-registry requires --file")
return 64
}
store, err := registry.LoadFile(*file)
if err != nil {
return fail(stderr, err)
}
snapshot := store.Snapshot()
return writeStatus(stdout, "loaded", map[string]any{
"file": *file,
"systems": len(snapshot.Systems),
"resource_manifests": len(snapshot.ResourceManifests),
"subjects": len(snapshot.Subjects),
"groups": len(snapshot.Groups),
"teams": len(snapshot.Teams),
"tenants": len(snapshot.Tenants),
"relationships": len(snapshot.Relationships),
})
}
func runTestPolicy(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("test-policy", stderr)
file := fs.String("file", "", "policy package Markdown file")
if err := fs.Parse(args); err != nil {
return 64
}
if *file == "" {
fmt.Fprintln(stderr, "test-policy requires --file")
return 64
}
pkg, err := policy.LoadAndValidateFile(context.Background(), *file)
if err != nil {
return fail(stderr, err)
}
if err := writeJSON(stdout, pkg.Validation); err != nil {
return fail(stderr, err)
}
if !pkg.Valid {
return 1
}
return 0
}
func runCheck(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("check", stderr)
registryPath := fs.String("registry", "", "registry snapshot JSON file")
policyPath := fs.String("policy", "", "policy package Markdown file")
requestPath := fs.String("request", "", "check request YAML/JSON file")
logPath := fs.String("log", "", "optional JSONL decision log path")
if err := fs.Parse(args); err != nil {
return 64
}
if *registryPath == "" || *policyPath == "" || *requestPath == "" {
fmt.Fprintln(stderr, "check requires --registry, --policy, and --request")
return 64
}
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
if err != nil {
return fail(stderr, err)
}
var request api.CheckRequest
if err := readYAML(*requestPath, &request); err != nil {
return fail(stderr, err)
}
decision, err := engine.Check(context.Background(), request)
if err != nil {
return fail(stderr, err)
}
if err := writeJSON(stdout, decision); err != nil {
return fail(stderr, err)
}
return 0
}
func runBatchCheck(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("batch-check", stderr)
registryPath := fs.String("registry", "", "registry snapshot JSON file")
policyPath := fs.String("policy", "", "policy package Markdown file")
requestPath := fs.String("request", "", "batch check request YAML/JSON file")
logPath := fs.String("log", "", "optional JSONL decision log path")
if err := fs.Parse(args); err != nil {
return 64
}
if *registryPath == "" || *policyPath == "" || *requestPath == "" {
fmt.Fprintln(stderr, "batch-check requires --registry, --policy, and --request")
return 64
}
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
if err != nil {
return fail(stderr, err)
}
var request api.BatchCheckRequest
if err := readYAML(*requestPath, &request); err != nil {
return fail(stderr, err)
}
decisions, err := engine.BatchCheck(context.Background(), request)
if err != nil {
return fail(stderr, err)
}
if err := writeJSON(stdout, decisions); err != nil {
return fail(stderr, err)
}
return 0
}
func runListAllowed(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("list-allowed", stderr)
registryPath := fs.String("registry", "", "registry snapshot JSON file")
policyPath := fs.String("policy", "", "policy package Markdown file")
subject := fs.String("subject", "", "subject id")
action := fs.String("action", "", "action name")
system := fs.String("system", "", "protected system id")
resourceType := fs.String("resource-type", "", "resource type")
logPath := fs.String("log", "", "optional JSONL decision log path")
var filters keyValueFlags
fs.Var(&filters, "filter", "resource filter as key=value; may be repeated")
if err := fs.Parse(args); err != nil {
return 64
}
if *registryPath == "" || *policyPath == "" || *subject == "" || *action == "" {
fmt.Fprintln(stderr, "list-allowed requires --registry, --policy, --subject, and --action")
return 64
}
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
if err != nil {
return fail(stderr, err)
}
decisions, err := engine.ListAllowed(context.Background(), decisioncore.ListAllowedRequest{
Subject: api.SubjectRef{ID: *subject},
Action: *action,
System: *system,
ResourceType: *resourceType,
Filters: filters.Map(),
})
if err != nil {
return fail(stderr, err)
}
if err := writeJSON(stdout, decisions); err != nil {
return fail(stderr, err)
}
return 0
}
func runExplain(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("explain", stderr)
logPath := fs.String("decision-log", "", "JSONL decision log path")
decisionID := fs.String("decision-id", "", "decision id to explain")
if err := fs.Parse(args); err != nil {
return 64
}
if *logPath == "" || *decisionID == "" {
fmt.Fprintln(stderr, "explain requires --decision-log and --decision-id")
return 64
}
log := audit.NewJSONLDecisionLog(*logPath)
envelope, ok, err := log.Find(*decisionID)
if err != nil {
return fail(stderr, err)
}
if !ok {
return fail(stderr, fmt.Errorf("decision %q not found", *decisionID))
}
if err := writeJSON(stdout, decisioncore.ExplainEnvelope(envelope)); err != nil {
return fail(stderr, err)
}
return 0
}
func runServe(args []string, stdout, stderr io.Writer) int {
fs := newFlagSet("serve", stderr)
addr := fs.String("addr", "127.0.0.1:8080", "HTTP listen address")
registryPath := fs.String("registry", "", "registry snapshot JSON file")
policyPath := fs.String("policy", "", "policy package Markdown file")
logPath := fs.String("log", "", "optional JSONL decision log path")
if err := fs.Parse(args); err != nil {
return 64
}
if *registryPath == "" || *policyPath == "" {
fmt.Fprintln(stderr, "serve requires --registry and --policy")
return 64
}
engine, err := buildEngine(context.Background(), *registryPath, *policyPath, *logPath)
if err != nil {
return fail(stderr, err)
}
mux := newServeMux(engine)
fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr)
if err := http.ListenAndServe(*addr, mux); err != nil {
return fail(stderr, err)
}
return 0
}
func newServeMux(engine *decisioncore.Engine) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
mux.HandleFunc("/v1/check", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var request api.CheckRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decision, err := engine.Check(r.Context(), request)
writeHTTP(w, decision, err)
})
mux.HandleFunc("/v1/batch_check", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var request api.BatchCheckRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
decisions, err := engine.BatchCheck(r.Context(), request)
writeHTTP(w, decisions, err)
})
return mux
}
func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) {
store, err := registry.LoadFile(registryPath)
if err != nil {
return nil, err
}
pkg, err := policy.LoadAndValidateFile(ctx, policyPath)
if err != nil {
return nil, err
}
engine, err := decisioncore.NewEngine(store, pkg)
if err != nil {
return nil, err
}
if logPath != "" {
engine.SetDecisionLog(audit.NewJSONLDecisionLog(logPath))
}
return engine, nil
}
func validateDescriptor(descriptor api.CaringAccessDescriptor) error {
if descriptor.Profile != api.CaringProfileCaring040RC2 {
return fmt.Errorf("unsupported CARING profile %q", descriptor.Profile)
}
if descriptor.SubjectType == "" {
return fmt.Errorf("subject_type is required")
}
if descriptor.OrganizationRelation == "" {
return fmt.Errorf("organization_relation is required")
}
if descriptor.CanonicalRole == "" {
return fmt.Errorf("canonical_role is required")
}
if descriptor.Scope.Level == "" || descriptor.Scope.ID == "" {
return fmt.Errorf("scope.level and scope.id are required")
}
if len(descriptor.Planes) == 0 {
return fmt.Errorf("at least one plane is required")
}
if len(descriptor.Capabilities) == 0 {
return fmt.Errorf("at least one capability is required")
}
return nil
}
func readYAML(path string, out any) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
if err := yaml.Unmarshal(data, out); err != nil {
return fmt.Errorf("unmarshal %s: %w", path, err)
}
return nil
}
func writeJSON(w io.Writer, value any) error {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
if err := encoder.Encode(value); err != nil {
return err
}
return nil
}
func writeStatus(w io.Writer, status string, extra map[string]any) int {
out := map[string]any{"status": status}
for key, value := range extra {
out[key] = value
}
if err := writeJSON(w, out); err != nil {
return fail(w, err)
}
return 0
}
func writeHTTP(w http.ResponseWriter, value any, err error) {
w.Header().Set("content-type", "application/json")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(value)
}
func newFlagSet(name string, stderr io.Writer) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
return fs
}
func fail(stderr io.Writer, err error) int {
fmt.Fprintln(stderr, "flex-auth:", err)
return 1
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage: flex-auth <command> [options]")
fmt.Fprintln(w, "commands: version, validate, load-registry, test-policy, check, batch-check, list-allowed, explain, serve")
}
type keyValueFlags []string
func (f *keyValueFlags) String() string {
return strings.Join(*f, ",")
}
func (f *keyValueFlags) Set(value string) error {
if !strings.Contains(value, "=") {
return fmt.Errorf("filter must be key=value")
}
*f = append(*f, value)
return nil
}
func (f keyValueFlags) Map() map[string]any {
out := make(map[string]any, len(f))
for _, item := range f {
key, value, _ := strings.Cut(item, "=")
out[key] = value
}
return out
}

View File

@@ -1,9 +1,340 @@
package main
import "testing"
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
func TestVersionDefault(t *testing.T) {
if version == "" {
t.Fatal("version must not be empty")
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestRunVersion(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"version"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
if strings.TrimSpace(stdout.String()) == "" {
t.Fatal("version output is empty")
}
}
func TestRunTestPolicy(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"test-policy", "--file", examplePath("policy_package.md")}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
if !strings.Contains(stdout.String(), `"valid": true`) {
t.Fatalf("stdout = %s; want valid policy result", stdout.String())
}
}
func TestRunCheck(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{
"check",
"--registry", examplePath("registry_snapshot.json"),
"--policy", examplePath("policy_package.md"),
"--request", examplePath("check_request.yaml"),
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
var decision api.DecisionEnvelope
if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil {
t.Fatalf("unmarshal decision: %v\n%s", err, stdout.String())
}
if decision.Effect != api.DecisionEffectAllow {
t.Fatalf("decision.Effect = %q; want allow", decision.Effect)
}
}
func TestRunBatchCheck(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{
"batch-check",
"--registry", examplePath("registry_snapshot.json"),
"--policy", examplePath("policy_package.md"),
"--request", examplePath("batch_check_request.yaml"),
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
var decisions []api.DecisionEnvelope
if err := json.Unmarshal(stdout.Bytes(), &decisions); err != nil {
t.Fatalf("unmarshal decisions: %v\n%s", err, stdout.String())
}
if len(decisions) != 2 || decisions[0].Effect != api.DecisionEffectAllow || decisions[1].Effect != api.DecisionEffectDeny {
t.Fatalf("decisions = %+v; want allow then deny", decisions)
}
}
func TestRunCheckOpsWarden(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{
"check",
"--registry", opsPath("registry_snapshot.json"),
"--policy", opsPath("policy_package.md"),
"--request", opsPath("check_request_allow_adm.json"),
}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
var decision api.DecisionEnvelope
if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil {
t.Fatalf("unmarshal decision: %v\n%s", err, stdout.String())
}
if decision.Effect != api.DecisionEffectAllow {
t.Fatalf("decision.Effect = %q; want allow", decision.Effect)
}
if decision.ID == "" {
t.Fatal("decision.ID is empty; ops-warden needs a policy_decision_id")
}
}
func TestServeOpsWardenCheckContract(t *testing.T) {
logPath := filepath.Join(t.TempDir(), "decisions.jsonl")
engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), logPath)
if err != nil {
t.Fatalf("buildEngine: %v", err)
}
server := httptest.NewServer(newServeMux(engine))
defer server.Close()
resp, err := http.Get(server.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /healthz status = %d; want 200", resp.StatusCode)
}
allow := postCheck(t, server.URL+"/v1/check", opsPath("check_request_allow_adm.json"))
if allow.Effect != api.DecisionEffectAllow || allow.ID == "" {
t.Fatalf("allow decision = %+v; want allow with id", allow)
}
deny := postCheck(t, server.URL+"/v1/check", opsPath("check_request_deny_ttl_above_max.json"))
if deny.Effect != api.DecisionEffectDeny || deny.Reason != "ttl_out_of_bounds" {
t.Fatalf("deny decision = %+v; want ttl_out_of_bounds deny", deny)
}
resp, err = http.Get(server.URL + "/v1/check")
if err != nil {
t.Fatalf("GET /v1/check: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("GET /v1/check status = %d; want 405", resp.StatusCode)
}
resp, err = http.Post(server.URL+"/v1/check", "application/json", strings.NewReader(`{"subject":`))
if err != nil {
t.Fatalf("POST malformed /v1/check: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("malformed POST status = %d; want 400", resp.StatusCode)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read decision log: %v", err)
}
if !strings.Contains(string(logData), allow.ID) || !strings.Contains(string(logData), deny.ID) {
t.Fatalf("decision log does not contain both decision ids\nlog: %s\nallow: %s deny: %s", string(logData), allow.ID, deny.ID)
}
}
func TestRunLoadRegistryOpsWardenProduction(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"load-registry", "--file", opsPath("production_registry_snapshot.json")}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
var result map[string]any
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("unmarshal load-registry output: %v; stdout = %s", err, stdout.String())
}
if result["subjects"] != float64(4) || result["relationships"] != float64(4) || result["resource_manifests"] != float64(1) {
t.Fatalf("load-registry result = %+v; want production actor registry counts", result)
}
}
func TestOpsWardenProductionRegistryActors(t *testing.T) {
engine, err := buildEngine(context.Background(), opsPath("production_registry_snapshot.json"), opsPath("policy_package.md"), "")
if err != nil {
t.Fatalf("buildEngine: %v", err)
}
cases := []struct {
name string
subjectID string
actor string
actorType string
principal string
ttlHours float64
wantEffect api.DecisionEffect
wantReason string
}{
{
name: "state hub bridge agent allow",
subjectID: "agt-state-hub-bridge",
actor: "agt-state-hub-bridge",
actorType: "agt",
principal: "agt-task-bridge",
ttlHours: 1,
wantEffect: api.DecisionEffectAllow,
},
{
name: "state hub bridge IAM subject allow",
subjectID: "iam:agt-state-hub-bridge",
actor: "agt-state-hub-bridge",
actorType: "agt",
principal: "agt-task-bridge",
ttlHours: 1,
wantEffect: api.DecisionEffectAllow,
},
{
name: "codex interhub bootstrap agent allow",
subjectID: "agt-codex-interhub-bootstrap",
actor: "agt-codex-interhub-bootstrap",
actorType: "agt",
principal: "agt-interhub-bootstrap",
ttlHours: 1,
wantEffect: api.DecisionEffectAllow,
},
{
name: "admin actor allow",
subjectID: "adm-example",
actor: "adm-example",
actorType: "adm",
principal: "adm-full",
ttlHours: 4,
wantEffect: api.DecisionEffectAllow,
},
{
name: "automation actor allow",
subjectID: "atm-backup-daily",
actor: "atm-backup-daily",
actorType: "atm",
principal: "atm-backup-daily",
ttlHours: 1,
wantEffect: api.DecisionEffectAllow,
},
{
name: "ttl above production max denies",
subjectID: "agt-state-hub-bridge",
actor: "agt-state-hub-bridge",
actorType: "agt",
principal: "agt-task-bridge",
ttlHours: 999,
wantEffect: api.DecisionEffectDeny,
wantReason: "ttl_out_of_bounds",
},
{
name: "unregistered production actor denies",
subjectID: "agt-missing",
actor: "agt-missing",
actorType: "agt",
principal: "agt-missing",
ttlHours: 1,
wantEffect: api.DecisionEffectDeny,
wantReason: "unknown_actor_resource",
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
decision, err := engine.Check(context.Background(), opsWardenProductionSignRequest(tt.subjectID, tt.actor, tt.actorType, tt.principal, tt.ttlHours))
if err != nil {
t.Fatalf("Check: %v", err)
}
if decision.Effect != tt.wantEffect {
t.Fatalf("decision.Effect = %q; want %q; decision: %+v", decision.Effect, tt.wantEffect, decision)
}
if tt.wantReason != "" && decision.Reason != tt.wantReason {
t.Fatalf("decision.Reason = %q; want %q; decision: %+v", decision.Reason, tt.wantReason, decision)
}
if tt.wantEffect == api.DecisionEffectAllow && decision.ID == "" {
t.Fatal("allow decision ID is empty")
}
})
}
}
func TestRunValidateAccessDescriptor(t *testing.T) {
var stdout, stderr bytes.Buffer
code := run([]string{"validate", "--kind", "access-descriptor", "--file", examplePath("access_descriptor.yaml")}, &stdout, &stderr)
if code != 0 {
t.Fatalf("code = %d, stderr = %s", code, stderr.String())
}
if !strings.Contains(stdout.String(), `"status": "valid"`) {
t.Fatalf("stdout = %s; want valid status", stdout.String())
}
}
func examplePath(name string) string {
return filepath.Join("..", "..", "examples", "caring", name)
}
func opsPath(name string) string {
return filepath.Join("..", "..", "examples", "ops-warden", name)
}
func opsWardenProductionSignRequest(subjectID, actor, actorType, principal string, ttlHours float64) api.CheckRequest {
return api.CheckRequest{
ID: "check:ops-warden-production-" + actor,
Tenant: "tenant:platform",
Subject: api.SubjectRef{
ID: subjectID,
Type: api.SubjectType(actorType),
},
Action: "sign",
Resource: api.ResourceRef{
ID: "ssh-cert:actor/" + actor,
Type: "ssh-certificate",
System: "ops-warden",
},
Context: map[string]any{
"principals": []string{principal},
"actor_type": actorType,
"ttl_hours": ttlHours,
"pubkey_fingerprint": "SHA256:example-production-fingerprint",
},
}
}
func postCheck(t *testing.T, url, path string) api.DecisionEnvelope {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("POST %s status = %d; want 200", path, resp.StatusCode)
}
var decision api.DecisionEnvelope
if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil {
t.Fatalf("decode %s response: %v", path, err)
}
return decision
}

View File

@@ -0,0 +1,203 @@
# CARING Architecture Blueprint For Flex-Auth
Date: 2026-05-17
Status: Draft architecture blueprint
Source standard: CARING 0.4.0-RC2 (`/home/worsch/helix-forge/wiki/CaringStandardRc2.md`)
## Purpose
This blueprint describes how flex-auth can serve as the practical,
efficient reference implementation of the CARING standard while keeping
the core authorization path small enough to build and operate.
CARING remains the semantic standard. Flex-auth implements the subset
needed to make CARING descriptors, policy conformance, decisions,
explanations, and audit events executable.
## Architecture Position
CARING answers:
```text
What access semantics should a system expose, validate, explain, and audit?
```
Flex-auth answers:
```text
Given a subject, action, resource, context, policy package, and registry
state, what decision should protected systems enforce?
```
The reference implementation relationship is:
```text
CARING standard
-> flex-auth CARING profile schemas
-> policy packages and conformance checks
-> check/batch/list/explain APIs
-> decision and exposure-event audit records
```
## Minimal CARING Profile
The first implementation should pin a `caring_profile` version and define
the following canonical fields in flex-auth API and schema artifacts:
```text
subject_type
organization_relation
canonical_role
scope
plane
capabilities
exposure_mode
conditions
lifecycle_state
restrictions
exposure_event
derived_capabilities
access_path
```
The core does not need to implement every CARING benchmark or native-system
mapping immediately. It only needs the descriptor shape, enums, validation,
and a place in decisions and audit records.
## Core Data Types
`pkg/api` should expose:
```text
CaringProfile
CaringAccessDescriptor
CaringConformanceFinding
CaringExposureEvent
CaringRestriction
CaringDerivedCapability
```
These types should be referenced by:
```text
SubjectManifest
ResourceManifest
RelationshipFact
PolicyPackageMetadata
CheckRequest
DecisionEnvelope
AuditEvent
```
The `ResourceManifest` remains consumer-owned and lightweight. CARING
classification should be optional on resources at first, with policy
packages allowed to require specific fields for a protected system.
## Decision Pipeline
The efficient runtime path is:
```text
1. Normalize identity claims into a Subject.
2. Load registry facts for resource, relationships, tenant, and groups.
3. Build a CheckRequest with CARING context.
4. Evaluate restrictions first.
5. Evaluate scoped policy package rules.
6. Produce a DecisionEnvelope.
7. Attach CARING conformance findings and provenance.
8. Persist audit/exposure-event records when required.
```
CARING conformance should not block the fast allow/deny path unless a
policy marks a finding as enforcement-grade. Most early findings should
be diagnostics, warnings, or audit requirements.
## Conformance Model
Flex-auth should support two CARING modes:
```text
descriptive
Map existing local roles, policy objects, and grants into CARING
descriptors. Report ambiguity, bundling, missing scope, missing plane,
induced access, and exposure gaps.
prescriptive
Validate new policy packages and manifests against a CARING profile.
Fail on required fields, restriction precedence, tenant-boundary
violations, and explicit policy-plane or secret-plane violations.
```
The conformance output should use stable severities:
```text
info
warning
violation
blocked
```
## Policy Package Shape
Rego-in-Markdown policy packages should carry CARING frontmatter:
```yaml
caring:
profile: caring-0.4.0-rc2
canonical_roles: [Doer]
organization_relations: [Customer]
planes: [Data]
capabilities: [View]
exposure_modes: [Masked, Plaintext]
conditions: [PurposeBound, Logged]
restrictions: [ExportBlocked, CrossTenantBlocked]
```
The policy evaluator should provide this metadata to Rego and copy the
matched CARING descriptor into the decision envelope.
## Reference Implementation Boundaries
Flex-auth should implement:
```text
CARING descriptor schemas
CARING-aware policy package validation
CARING-aware decision envelopes
CARING explain output
CARING exposure-event audit records
CARING conformance fixtures
```
Flex-auth should not own:
```text
The CARING standard text
The NetKingdom IAM Profile
Identity-provider role issuance
Consumer-specific product semantics
Full benchmark mappings for every native access system
```
## Implementation Slices
1. Pin the CARING profile and descriptors in `FLEX-WP-0002 P2.1`.
2. Add registry fields and validation in `FLEX-WP-0002 P2.2`.
3. Add policy frontmatter and diagnostics in `FLEX-WP-0002 P2.3`.
4. Attach CARING metadata to `check` and `batch_check` in `FLEX-WP-0002 P2.4`.
5. Use CARING language in `list_allowed` and `explain` in `FLEX-WP-0002 P2.5`.
6. Persist CARING exposure events in `FLEX-WP-0002 P2.6`.
7. Map Markitect fixtures as the first consumer benchmark in `FLEX-WP-0003`.
8. Require delegated adapters in `FLEX-WP-0004` to preserve CARING envelope
fields even when backend-native semantics differ.
## Open Design Choices
The first implementation should decide:
```text
Whether CARING enum values are strict or extension-friendly.
Whether conformance findings are always non-blocking by default.
How much derived-capability analysis belongs in core versus adapters.
How to version profile changes after CARING leaves RC status.
```

View File

@@ -0,0 +1,133 @@
# Delegated Mode Operations
Status: implemented for FLEX-WP-0004 P4.6.
## Purpose
Delegated mode lets flex-auth coordinate external authorization systems
without changing the protected-system-facing API. Protected systems keep
using flex-auth check, batch, list, explanation, and audit contracts.
Topaz, relationship PDPs, rule PDPs, Keycloak Authorization Services,
and directory resolvers stay behind adapter boundaries.
## Deployment Order
1. Load protected-system manifests and resource manifests into the
flex-auth registry.
2. Load subject manifests or configure directory resolvers.
3. Import resources and relationships into the delegated backend.
4. Import policy artifacts or backend-native policy mirrors.
5. Run adapter health checks.
6. Start serving delegated checks only after the backend reports healthy
and the latest import has a recorded consistency token.
For Topaz, the local reference topology is `examples/topaz`. For
Keycloak, register resources from flex-auth manifests before enabling
UMA permission checks. For OpenFGA/SpiceDB-style systems, import tuples
from the canonical registry snapshot. For OPA/Cedar-style systems,
import policy artifacts from validated flex-auth policy packages.
## Health Checks
Each delegated adapter exposes a health boundary. Runtime health should
be tracked separately for:
- backend reachability
- policy import freshness
- directory or tuple import freshness
- resolver freshness
- last successful decision time
Health failures should not silently fall back to stale allow decisions.
They should produce observable diagnostics and deny fail-closed unless a
deployment has explicitly accepted a narrower fail-open mode for a
non-sensitive action.
## Fail-Closed Default
The default behavior is fail-closed:
| Condition | Decision effect | Typical reason |
| --- | --- | --- |
| backend unavailable | deny | `topaz_unavailable`, `relationship_backend_unavailable`, `rule_backend_unavailable`, `keycloak_unavailable` |
| stale directory or tuple data | deny | `topaz_directory_stale`, `relationship_data_stale` |
| stale policy | deny | `rule_policy_stale`, `keycloak_policy_stale` |
| partial backend result | deny | `*_partial_result` |
| request cannot be translated | deny | `*_request_incomplete` |
Fail-open is only acceptable for explicitly classified low-risk
capabilities and must be recorded as a policy decision, not an adapter
default. Data, identity, policy, secret, audit, and commercial planes
should remain fail-closed.
## Caching
Recommended cache layers:
- resource manifest snapshot cache
- subject/group resolver cache
- delegated backend import token cache
- decision-result cache for idempotent low-risk checks
Cache entries must record source, retrieval time, max age, and expiry.
Directory resolver results already expose `Freshness`; tuple and
Topaz/Keycloak imports expose consistency metadata through the decision
provenance fields. Cache hits should still include provenance so audit
readers can tell whether evidence came from a fresh backend call or a
bounded cache.
## Consistency
flex-auth uses `DecisionEnvelope.provenance.directory_etag` as the
adapter-neutral consistency token field:
- Topaz: relation etag when available
- OpenFGA/SpiceDB-style backends: model id, zedtoken, or tuple-store
freshness token
- directory resolvers: freshness metadata attached to subject metadata
- Keycloak/rule PDPs: policy version in `provenance.policy_version`
Adapters should deny or mark stale when a request demands a newer token
than the backend can satisfy. Read-your-writes paths should import
registry data, record the returned token, and require that token for the
first protected-system checks after import.
## Audit Behavior
Delegated decisions must log the same canonical envelope as standalone
decisions:
- subject and resource references
- effect, reason, matched rule, matched policy version
- obligations
- diagnostics
- evaluator and mode
- consistency token
- CARING descriptor, restrictions, exposure modes, derived
capabilities, exposure events, and conformance findings
Backend-native traces can appear in diagnostics, but they must not
replace the canonical fields. Explanations should be generated from the
flex-auth envelope so switching backends does not change protected-system
or auditor vocabulary.
## Adapter Notes
- Topaz operations: `docs/topaz-adapter-operations.md`
- Relationship PDPs: `docs/relationship-pdp-adapter-boundary.md`
- Rule PDPs: `docs/rule-pdp-adapter-boundary.md`
- Keycloak Authorization Services:
`docs/keycloak-authz-adapter-path.md`
- Directory resolvers: `docs/directory-group-resolver-adapters.md`
## Operational Checklist
- Backend configured and reachable.
- Policy/resource/tuple import completed.
- Latest import token recorded.
- Directory resolver freshness thresholds configured.
- Fail-closed behavior tested for unavailable, stale, partial, and
invalid-request cases.
- Decision log captures delegated provenance and CARING metadata.
- Rollback path can switch a protected system back to standalone mode or
to another delegated adapter without changing the protected-system API.

View File

@@ -0,0 +1,65 @@
# Directory Group Resolver Adapters
Status: implemented for FLEX-WP-0004 P4.5.
## Role
Directory resolvers enrich flex-auth subjects with group and role
evidence from external identity systems. They do not decide access by
themselves. They feed the standalone, relationship, rule, Topaz, or
Keycloak decision paths with fresh subject metadata and provenance.
## Resolver Sources
Implemented resolver boundaries:
- Microsoft Graph group overage (`GraphResolver`)
- SCIM provisioning (`SCIMResolver`)
- LDAP/Active Directory (`LDAPResolver`)
- Keycloak admin API (`KeycloakResolver`)
Each resolver returns `ResolveResult` with normalized groups, roles,
freshness, overage, and metadata.
## Freshness
Every result carries:
- source
- retrieval time
- max age
- expiry time
- stale flag
Decision adapters can use this metadata to deny stale directory evidence
or to include freshness diagnostics in the decision envelope.
## Overage
Graph tokens can omit groups and instead emit overage indicators such
as `_claim_names.groups` or `hasgroups=true`. The Graph resolver records
that condition in `OverageMetadata` so downstream policy can distinguish
"no groups" from "groups omitted, lookup required".
## CARING Provenance
Each `GroupGrant` and `RoleGrant` identifies:
- source provider
- originating claim name
- organization relation
- subject type
- optional CARING descriptor
This makes it possible to explain that a canonical role or group came
from Graph, SCIM, LDAP/AD, or Keycloak rather than from an opaque token
claim. The source remains inspectable in CARING conformance reviews.
## Subject Enrichment
`MergeResults` deduplicates groups and roles across providers while
preserving freshness, overage, and descriptors. `ApplyToSubject` returns
a subject with resolved groups/roles and directory metadata attached.
The enriched subject can then flow through any flex-auth decision path
without changing protected-system request or decision contracts.

View File

@@ -1,14 +1,16 @@
# NetKingdom IAM Profile — flex-auth Consumption Surface
Date: 2026-05-16
Status: Draft for FLEX-WP-0005 P5.5; binds the input contract for the
standalone evaluator (FLEX-WP-0002) and every PDP adapter (FLEX-WP-0004).
Upstream: `~/the-custodian/canon/standards/iam-profile_v0.1.md`.
Date: 2026-05-22
Status: Aligned with NetKingdom IAM Profile v0.2; binds the input contract
for the standalone evaluator (FLEX-WP-0002) and every PDP adapter
(FLEX-WP-0004).
Upstream: `~/net-kingdom/canon/standards/iam-profile_v0.2.md`.
## Boundary
The NetKingdom IAM Profile defines the OIDC contract shared across hubs
and services. flex-auth **consumes verified claims**; it does not
The NetKingdom IAM Profile defines the OIDC contract shared across
platform, tenant, service, and agent principals. flex-auth **consumes
verified claims**; it does not
verify token signatures, fetch JWKS, or terminate OIDC sessions. Those
responsibilities belong upstream:
@@ -32,17 +34,25 @@ envelope identical to Markitect's `EnterpriseIdentity` shape:
```yaml
issuer: <oidc issuer URL> # required
subject: <stable subject id> # required
principal_type: human | service | emergency
tenant: tenant:platform | tenant:<id> # required
principal_type: human | service | agent
audience: [<aud>, ...] # required, non-empty
authorized_party: <azp or client_id, optional>
preferred_username: <string> # required for humans
roles: [<role>, ...] # required, non-empty
scopes: [<scope>, ...] # required, non-empty
groups: [<group>, ...] # optional; resolved by directory layer
groups: [<group>, ...] # required, may be empty
assurance:
level: aal0 | aal1 | aal2 | aal3 | break_glass
methods: [<method>, ...]
mfa: <bool>
source: <identity or MFA evidence source>
at: <unix timestamp, optional>
acr: <oidc acr value, optional>
amr: [<oidc amr value>, ...] # e.g. pwd, otp, mfa, hwk
mfa: <bool — derived from amr>
amr: [<oidc amr value>, ...] # tolerated provider-native input
agent:
id: <agent id, optional>
mode: autonomous | delegated
directory:
groups_claim_present: <bool>
group_overage: <bool> # Microsoft Entra-style group overage
@@ -55,7 +65,7 @@ provenance:
This is the envelope every check API call receives, regardless of
which upstream identity provider produced the token.
## Required Claims (per IAM Profile §"Required Claims")
## Required Claims (per IAM Profile v0.2 "Core Claims")
flex-auth treats the following as hard requirements. Missing any
produces a `validation_error` before the request reaches a policy
@@ -68,9 +78,13 @@ package.
| `aud` | `audience` | Must include the flex-auth instance or the protected system. |
| `exp` | (validated upstream) | flex-auth tolerates ≤60s clock skew per profile §"Token Lifecycle". |
| `iat` | (validated upstream) | Same. |
| `tenant` | `tenant` | Required for platform/tenant boundary decisions. |
| `principal_type` | `principal_type` | `human`, `service`, or `agent`; emergency is a role plus `assurance.level=break_glass`. |
| `groups` | `groups` | Required, possibly empty; overage is handled by directory resolvers. |
| `scope` or `scp` | `scopes` | At least one scope required. Empty scope is a hard fail. |
| `preferred_username` | `preferred_username` | Required for `principal_type=human`. Optional for service accounts. |
| `roles` or `realm_access.roles` or `resource_access.<client>.roles` | `roles` | Union of all three sources. At least one role required. |
| `roles` | `roles` | Canonical role source. At least one role required by current flex-auth policy fixtures. |
| `assurance` | `assurance` | Required normalized evidence object with level, methods, mfa, and source. |
| `preferred_username` | `preferred_username` | Required for `principal_type=human`. Optional for service and agent principals. |
## Recommended Claims
@@ -78,7 +92,6 @@ package.
| --- | --- | --- |
| `email` | `claims.email` | Contact identity; **never** used for authorization decisions. |
| `name` | `claims.name` | Display only. |
| `groups` | `groups` (after resolution) | Authorization input; subject to freshness/overage. |
| `azp` | `authorized_party` | Distinguishes service-account client from impersonating client. |
| `acr` | `assurance.acr` | Authentication context class; gates high-trust scopes. |
| `amr` | `assurance.amr` | Authentication methods; `otp`/`mfa`/`hwk` lift `assurance.mfa` to true. |
@@ -87,29 +100,31 @@ package.
flex-auth normalizes — protected systems never see the variation.
- **Role claim location.** Three OIDC providers ship roles in three
places: top-level `roles`, Keycloak's `realm_access.roles`, and
Keycloak's per-client `resource_access.<client>.roles`. flex-auth
unions all three.
- **Role claim location.** IAM Profile v0.2 makes top-level `roles`
canonical. During migration, flex-auth may also accept Keycloak's
`realm_access.roles` and `resource_access.<client>.roles`, but those
are provider-native compatibility inputs.
- **Scope encoding.** `scope` (space-separated string) and `scp`
(array) both accepted; both produce the same `scopes` array.
- **Audience encoding.** `aud` as a single string or as an array;
flex-auth always normalizes to an array.
- **MFA signal.** Either an explicit `mfa: true` claim or any of
`otp`/`mfa`/`hwk` in `amr` produces `assurance.mfa = true`.
- **MFA signal.** IAM Profile v0.2 uses `assurance.mfa` and
`assurance.level`. Legacy/provider-native `amr` and `acr` are
tolerated as inputs to the normalized assurance object.
## Principal-Type Detection
flex-auth classifies the principal by:
IAM Profile v0.2 supplies `principal_type` directly. flex-auth uses that
claim as normative input. Legacy fixtures may be classified by:
1. If `client_id` is set and `service` is in `roles``service`.
2. If `azp` starts with `svc-` or `service` is in `roles``service`.
3. If `emergency` is in `roles``emergency`.
3. If agent metadata is present`agent`.
4. Otherwise → `human`.
This matches Markitect's `NetKingdomIdentityClaimsAdapter._principal_type`
and follows IAM Profile §"Hub-to-Hub Service Account Pattern" (service
accounts named `svc-*` and carrying the `service` role).
as a compatibility path. New claim envelopes should not force flex-auth
to infer principal type.
## Group Overage and Freshness
@@ -140,7 +155,9 @@ Per IAM Profile §"Local Development Profile":
Per IAM Profile §"Human Override and Emergency Access":
- `emergency` is a first-class `principal_type`.
- Emergency access is represented as a human, service, or agent
principal with an `emergency`/`break-glass` role and
`assurance.level: break_glass`.
- Every decision involving an emergency principal MUST record a
`record_emergency` obligation in the decision envelope.
- Policy packages MAY allow emergency principals; flex-auth's audit
@@ -154,11 +171,11 @@ the validation steps above in Python. flex-auth's Go implementation
(FLEX-WP-0002 P2.4) mirrors its behavior and stays in sync via
contract tests against the fixtures in `examples/claims/`.
## Open Items
## Compatibility Notes
- Whether `roles` becomes canonical and `realm_access.roles` becomes
legacy is still listed as an open question in IAM Profile v0.1. As
of 2026-05-16 flex-auth normalizes both with no preference.
- `Workload identity` (Kubernetes service-account tokens, GCP/AWS
workload-identity federation) is not yet in the IAM Profile.
flex-auth's service-account handling is currently OIDC-only.
- `roles` is canonical in IAM Profile v0.2. `realm_access.roles` and
`resource_access.<client>.roles` remain tolerated provider-native inputs
while Keycloak mappings are updated.
- Workload identity may enter through a documented token-exchange path,
but the normalized envelope still carries `principal_type: service` or
`principal_type: agent`, `tenant`, and `assurance`.

View File

@@ -0,0 +1,78 @@
# Keycloak Authorization Services Adapter Path
Status: implemented for FLEX-WP-0004 P4.4.
## Role
The Keycloak path is for deployments that already use Keycloak as the
identity provider and want to evaluate some authorization decisions
through Keycloak Authorization Services. flex-auth still remains the
source of truth for protected-system resources, CARING descriptors, and
decision envelopes.
## Mapping
flex-auth maps a check to Keycloak's UMA permission shape:
| flex-auth | Keycloak |
| --- | --- |
| protected system | resource server / audience |
| resource id | resource id |
| action | scope |
| subject | requesting party |
| context and CARING descriptor | claim token |
The adapter builds a permission as `resource_id#scope`, for example
`document:internal-note#read`.
## Resource Registration
`ResourceRegistrationsFromManifest` converts a
`ResourceManifest` into Keycloak resource registrations:
- resource id and type are preserved;
- manifest actions become scopes;
- path becomes the URI;
- labels, trust zone, owner, parent, system, and type are stored as
resource attributes.
Keycloak can mirror these resources, but flex-auth keeps the original
manifest as the canonical record.
## Decision Wrapping
Keycloak allow/deny results are wrapped into the standard
`DecisionEnvelope`:
- `provenance.evaluator=keycloak-authz`
- `provenance.mode=delegated`
- Keycloak RPT token id and permission appear in diagnostics
- CARING descriptor and conformance findings are preserved
Backend-native Keycloak policy names do not replace CARING canonical
roles in protected-system responses.
## Failure Behavior
The adapter fails closed for:
- Keycloak unavailable: `keycloak_unavailable`
- stale policy state: `keycloak_policy_stale`
- partial result: `keycloak_partial_result`
- untranslatable request: `keycloak_request_incomplete`
Each failure returns a deny envelope with `diagnostics.keycloak_failure`
and a CARING conformance finding.
## Boundaries
This path intentionally does not make Keycloak the only policy source of
truth. flex-auth continues to own:
- resource manifests from protected systems;
- CARING descriptors and conformance findings;
- audit and explanation envelope shape;
- adapter-neutral request/decision APIs.
Keycloak is a delegated evaluator and resource mirror for Keycloak-heavy
installations, not the canonical model for the whole product.

View File

@@ -0,0 +1,24 @@
# Markitect Action Vocabulary
This document defines the action vocabulary for Markitect as a flex-auth
protected system. Actions are normalized before policy evaluation so Markitect
local behavior maps cleanly to CARING capabilities and exposure modes.
| Action | Markitect policy-gateway meaning | CARING capabilities | CARING planes | Exposure modes | Decision effects |
| --- | --- | --- | --- | --- | --- |
| `read` | Render or fetch one document/resource. | `View` | `Data` | `Metadata`, `Masked`, `Plaintext` | `allow`, `deny`, `redact` |
| `query` | Answer over a bounded resource set. | `ViewCollection`, `Observe` | `Data` | `Metadata`, `Aggregated`, `Masked` | `allow`, `deny`, `redact` |
| `search` | Search index or metadata across resources. | `ViewCollection`, `Observe` | `Data` | `Metadata`, `Aggregated`, `Masked` | `allow`, `deny`, `redact` |
| `package` | Build a context package from selected resources. | `Create`, `Bind`, `ViewCollection` | `Intent`, `Data` | `Metadata`, `Masked` | `allow`, `deny`, `audit_only` |
| `activate_context` | Activate a prepared context package for model/tool use. | `Use`, `Execute` | `Intent`, `Policy` | `Metadata`, `Masked` | `allow`, `deny`, `audit_only` |
| `export` | Materialize or transfer content outside Markitect. | `Export` | `Data`, `Audit` | `Exportable`, `Plaintext` | `allow`, `deny`, `audit_only` |
| `workflow_run` | Execute a workflow using Markitect resources. | `Execute`, `Operate` | `Execution`, `Data`, `Audit` | `Metadata`, `Masked`, `Plaintext` | `allow`, `deny`, `audit_only` |
| `admin` | Configure Markitect policy, identity, or resource controls. | `Configure`, `Grant`, `Revoke`, `Audit` | `Configuration`, `Identity`, `Policy`, `Audit` | `Metadata`, `Plaintext` | `allow`, `deny`, `audit_only` |
`read`, `query`, and `search` never imply `Export`. Export is separate because
it changes the exposure mode to `Exportable` and usually requires explicit
conditions such as MFA and logging.
The code-level source of truth is `internal/markitect/actions.go`. The pinned
manifest example in `examples/markitect/protected_system_manifest.yaml` mirrors
that vocabulary as protected-system action definitions.

View File

@@ -0,0 +1,119 @@
# Markitect Integration Flow
This document describes how Markitect should use flex-auth as its first
protected-system consumer integration.
## 1. Publish Resources
Markitect emits a `FlexAuthResourceManifest` for each knowledge base or
resource slice it wants flex-auth to authorize. The emitted manifest should use
the namespace in `docs/markitect-resource-namespace.md` and include:
- stable resource ids such as `document:internal-note`
- resource type and parent links
- path, labels, trust zone, owner, and durable backend metadata
- `metadata.flex_auth_contract: resource-registration-v0`
- `caring_profile: caring-0.4.0-rc2` when the emitter can provide it
flex-auth imports the manifest through `internal/markitect.ImportResourceManifest`.
The importer enriches resources with CARING scope and plane classification and
returns diagnostics when a resource type, trust zone, label set, or CARING
profile is missing or ambiguous.
## 2. Submit Check Requests
For one resource, Markitect submits `CheckRequest`:
```text
subject + action + resource + context + optional caring_context
```
For repeated checks, Markitect submits `BatchCheckRequest` with the same
subject/action/context and a resource list. Resource order is preserved in the
response.
Markitect should pass local frontmatter and backend metadata as resource
attributes when they affect policy:
- `labels`
- `trust_zone`
- `markitect_path`
- `frontmatter_visibility`
- `source_revision`
- `workflow_state`
- `freshness_seconds`
- `data_classes`
Subject roles and groups should be normalized into subject attributes or loaded
into the registry as subject/group/team manifests. CARING dimensions should be
attached as `caring_context` when Markitect already knows the intended
descriptor; otherwise flex-auth can derive descriptors from relationship facts.
## 3. Enforce Decisions
Markitect maps flex-auth decision envelopes into its gateway contract with
`internal/markitect.ToGatewayDecision`.
Expected effects:
- `allow`: render, answer, package, activate, export, or run workflow.
- `deny`: block the operation.
- `redact`: continue with returned obligations such as `mask_fields`.
- `audit_denied`: block or quarantine while preserving an audit-grade record.
Markitect should enforce obligations before exposing data. For example, a
`mask_fields` obligation must be applied before rendering or model use, and a
`record_context_activation` obligation must be logged when a context package is
activated.
## 4. Record Decision IDs
Every gateway operation should persist the flex-auth decision id alongside the
Markitect request id, workflow id, rendered artifact id, or export id.
At minimum, always record:
- denies
- redactions
- exports
- context package activations
- workflow runs
- support, break-glass, or other CARING exposure events
The local flex-auth JSONL log is suitable for local development. Markitect may
also write the same decision id into its own event log.
## 5. Explain Decisions
When users need an explanation, Markitect can call flex-auth `explain` or use
the decision id to retrieve the persisted envelope and project it with
`ToGatewayDecision`.
Explanations should preserve CARING language:
- subject relation and canonical role, for example `Customer Doer`
- scope, for example `Resource document:internal-note`
- plane, for example `Data`
- capability, for example `View` or `Export`
- exposure mode, for example `Masked`, `Plaintext`, or `Exportable`
- restrictions, conditions, and conformance findings
Example explanation:
```text
Customer Doer may View Data Plane resource document:internal-note because reader_group.
```
## Local Mapping Summary
| Markitect local concept | flex-auth field | CARING dimension |
| --- | --- | --- |
| frontmatter visibility | `resource.attributes.frontmatter_visibility` | Exposure mode hint |
| document labels | `resource.attributes.labels` | Scope and exposure hint |
| owner/steward | `resource.owner`, subject groups/roles | Canonical role and relation |
| workflow state | `resource.attributes.workflow_state`, request context | Lifecycle/condition hint |
| context freshness | `request.context.freshness_seconds` | Condition and conformance finding |
| export request | `action: export` | `Export` capability and `Exportable` exposure |
The examples in `examples/markitect/` are the executable contract fixtures for
this flow.

View File

@@ -0,0 +1,76 @@
# Markitect Resource Namespace
This document defines the Markitect protected-system namespace consumed by
flex-auth. It is the P3.1 contract between Markitect resource metadata and the
generic flex-auth registry.
The namespace is intentionally Markitect-specific at the edge and generic once
registered. Markitect may keep its local frontmatter and backend metadata
names, but emitted resource manifests should normalize them into the resource
types and CARING dimensions below.
## Hierarchy
```text
knowledge_base
-> repository
-> document
-> section
-> span
-> context_package
-> workflow_artifact
-> export
```
Markitect may emit a partial tree. For example, a document can be parented
directly to a knowledge base when the repository boundary is not material to a
policy decision. flex-auth treats `parent` as a stable relationship hint; P3.2
and P3.4 add importer and check fixtures that make inherited behavior explicit.
## CARING Mapping
| Markitect resource type | Parent types | CARING scope | CARING planes | Notes |
| --- | --- | --- | --- | --- |
| `knowledge_base` | none | `Workspace` | `Intent`, `Data` | Top-level user-visible knowledge container. |
| `repository` | `knowledge_base` | `Project` | `Build`, `Data` | Versioned source or storage boundary behind a knowledge base. |
| `document` | `repository`, `knowledge_base` | `Resource` | `Data` | Renderable document or page. Markitect `path` maps to resource `path`. |
| `section` | `document` | `Subresource` | `Data` | Stable heading or block region inside a document. |
| `span` | `section`, `document` | `Field` | `Data` | Fine-grained text range, cell, token span, or field-level surface. |
| `context_package` | `knowledge_base`, `repository`, `document` | `Dataset` | `Intent`, `Data`, `Policy` | Bundled context prepared for model/tool use. |
| `workflow_artifact` | `context_package`, `document` | `Process` | `Execution`, `Data`, `Audit` | Generated workflow output, review artifact, or intermediate. |
| `export` | `workflow_artifact`, `context_package`, `document` | `Record` | `Data`, `Audit` | Materialized package, file, archive, or external transfer. |
## Frontmatter Compatibility
Markitect document frontmatter can remain local, but manifests should preserve
the following mappings:
- `id` or stable slug -> `resources[].id`
- document kind -> `resources[].type`
- source path -> `resources[].path`
- parent knowledge base, repository, or document -> `resources[].parent`
- labels, classification, or visibility -> `resources[].labels`
- tenant/customer boundary -> `resources[].attributes.tenant` when it is not
already represented by the request subject/resource tenant
- owner team or steward -> `resources[].owner`
- freshness, workflow state, and source revision -> `resources[].attributes`
## Backend Metadata Compatibility
Backend metadata can be richer than the flex-auth contract. The manifest should
keep durable values in `attributes` and avoid embedding backend-only transient
state in resource ids.
Recommended backend metadata keys:
- `markitect_path`
- `frontmatter_visibility`
- `source_revision`
- `workflow_state`
- `freshness_seconds`
- `data_classes`
- `tenant`
The examples in `examples/markitect/protected_system_manifest.yaml` and
`examples/markitect/namespace_resource_manifest.yaml` are the pinned schema
examples for this namespace.

View File

@@ -0,0 +1,114 @@
# Ops-Warden Policy Gate Handoff
Date: 2026-06-23
Workplan: FLEX-WP-0006
Ops-warden unblocker: WARDEN-WP-0009 T01
## Published flex-auth assets
- Policy package: examples/ops-warden/policy_package.md
- Policy fixtures: examples/ops-warden/policy_fixtures.yaml
- Combined registry fixture: examples/ops-warden/registry_snapshot.json
- Protected-system manifest: examples/ops-warden/protected_system_manifest.yaml
- Resource manifest: examples/ops-warden/resource_manifest.yaml
- Subject manifest: examples/ops-warden/subject_manifest.yaml
- Service request fixtures: examples/ops-warden/check_request_*.json
## Local service command
flex-auth serve --addr 127.0.0.1:8080 --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /tmp/flex-auth-ops-warden-decisions.jsonl
Ops-warden can point policy.flex_auth_url at that base URL for local smoke.
Production should keep policy.fail_closed true unless an explicit break-glass
procedure exists.
## Fixture coverage
Allow fixtures:
- fixture:ops-warden-adm-sign-allow
- fixture:ops-warden-agt-sign-allow
- fixture:ops-warden-atm-sign-allow
Deny fixtures:
- fixture:ops-warden-unknown-subject-deny
- fixture:ops-warden-actor-type-mismatch-deny
- fixture:ops-warden-ttl-above-max-deny
- fixture:ops-warden-disallowed-principal-deny
- fixture:ops-warden-missing-fingerprint-deny
## Non-secret smoke evidence
CLI validation on 2026-06-23:
- protected-system manifest: valid
- resource manifest: valid
- subject manifest: valid
- registry snapshot: loaded 1 system, 1 resource manifest, 3 subjects,
3 groups, 3 relationships, and 1 tenant
- policy package: valid with 8 passing fixtures
Local /v1/check service smoke on 2026-06-23:
- allow request: effect allow, reason signing_policy_matched,
decision id decision:706efe49f68d9ef1
- deny request: effect deny, reason ttl_out_of_bounds,
decision id decision:b69bdc25a988f367
- GET /v1/check: HTTP 405
- malformed POST /v1/check: HTTP 400
- decision log contained both decision ids
## Production sequence for ops-warden
1. Deploy the flex-auth registry and policy package above to the selected
flex-auth runtime.
2. Configure ops-warden policy.flex_auth_url to the flex-auth base URL.
3. Set policy.enabled: true.
4. Keep policy.tenant as tenant:platform unless a tenant-specific policy package
is introduced.
5. Run one allow-path sign smoke and confirm signatures.log includes
policy_decision_id.
6. Run one deny-path smoke with fail_closed true and preserve only non-secret
evidence.
## Ownership boundary
flex-auth owns the authorization decision for the signing request. ops-warden
continues to own actor inventory, SSH CA operation, OpenBao SSH engine
integration, host documentation, and signatures.log production evidence.
No SSH private keys, OpenBao tokens, database credentials, or real public-key
material are stored in these fixtures.
## FLEX-WP-0007 Production Update
Additional published assets:
- Production registry fixture: examples/ops-warden/production_registry_snapshot.json
- Registry sync runbook: docs/ops-warden-registry-sync.md
Production runtime command:
flex-auth serve --addr 0.0.0.0:8080 --registry examples/ops-warden/production_registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /var/log/flex-auth/ops-warden-decisions.jsonl
Use http://flex-auth.flex-auth.svc.cluster.local:8080 when cluster DNS is
reachable from warden workstations. Otherwise use the approved operator tunnel
or ingress URL. Always pre-flight GET /healthz from the same workstation before
enabling policy.enabled with fail_closed true.
Production actor coverage now verifies agt-state-hub-bridge,
agt-codex-interhub-bootstrap, adm-example, atm-backup-daily, ttl_out_of_bounds,
unknown_actor_resource, and the iam:agt-state-hub-bridge subject path used by
WARDEN_POLICY_SUBJECT.
## FLEX-WP-0007 Closeout Update
On 2026-06-29 ops-warden reported the production policy-gate smoke as passed
against the deployed flex-auth runtime at `127.0.0.1:18090` from CoulombCore.
Non-secret evidence: allow decision `decision:032b096c433ad80c` for
`agt-state-hub-bridge`, deny reason `ttl_out_of_bounds` for an excessive TTL,
and backend `vault` for the scoped OpenBao signing path. The operator is
keeping `policy.enabled` off during build-stage/pre-testing; this is a maturity
posture decision, not a missing flex-auth artifact.

View File

@@ -0,0 +1,128 @@
# Ops-Warden Registry Sync
Date: 2026-06-23
Workplan: FLEX-WP-0007
This is the flex-auth side of the production policy gate runbook for ops-warden
SSH signing. ops-warden owns actor inventory and generated registry content;
flex-auth hosts that registry, evaluates the policy package, and returns the
decision envelope used by warden sign.
## Production Runtime Target
Use the NetKingdom operator-reachable service URL as the canonical
policy.flex_auth_url. The preferred target is an in-cluster flex-auth Service
fronted by the existing operator access path:
http://flex-auth.flex-auth.svc.cluster.local:8080
If cluster DNS is not reachable from the workstation that runs warden sign, use
an approved operator tunnel or ingress URL with the same base path semantics. Do
not turn on policy.enabled with fail_closed true until this pre-flight succeeds
from the same workstation:
curl -fsS <policy.flex_auth_url>/healthz
Start the runtime with the production registry snapshot and the ops-warden
policy package:
flex-auth serve --addr 0.0.0.0:8080 --registry examples/ops-warden/production_registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /var/log/flex-auth/ops-warden-decisions.jsonl
The checked-in production snapshot is a non-secret fixture and initial load
target. Regenerate it from ops-warden inventory whenever actors, principals, or
TTL defaults change.
## Current Operator Tunnel
As of 2026-06-24, the reachable operator-tunnel URL for CoulombCore is:
http://127.0.0.1:18090
The tunnel name is flex-auth-coulombcore. It forwards CoulombCore
127.0.0.1:18090 to the local flex-auth runtime on 127.0.0.1:18090. Verified
checks from CoulombCore:
- GET /healthz returned HTTP 200.
- POST /v1/check for agt-state-hub-bridge returned allow with decision:873c6c682a52bebc.
This is an operator tunnel pattern, not a substitute for a future in-cluster
Service if flex-auth should run inside the cluster.
## Ownership Contract
| Concern | Owner | Notes |
| --- | --- | --- |
| Actor names and actor types | ops-warden | inventory.yaml defines adm, agt, and atm actors. |
| Default principals and TTLs | ops-warden | Used by warden sign and by generated registry attributes. |
| Registry hosting and reload | flex-auth | Runtime serves the generated snapshot and evaluates it with the policy package. |
| Policy package semantics | flex-auth | examples/ops-warden/policy_package.md owns allow and deny reasons. |
| OpenBao SSH signing | ops-warden | flex-auth never receives SSH private keys or Vault tokens. |
| Production policy.enabled flip | ops-warden operator | Only after healthz and allow/deny smoke pass. |
## Sync Procedure
1. In ops-warden, update the managed inventory source or ~/.config/warden/inventory.yaml.
2. Regenerate the flex-auth snapshot from ops-warden:
python scripts/build_flex_auth_registry.py ~/.config/warden/inventory.yaml -o registry/flex-auth/production_registry_snapshot.json
3. Validate the generated file before handoff:
flex-auth load-registry --file registry/flex-auth/production_registry_snapshot.json
4. Copy or promote the snapshot to the flex-auth runtime. For repo-level drift
coverage, update examples/ops-warden/production_registry_snapshot.json when
the intended production fixture changes.
5. Restart or reload the flex-auth runtime with the new snapshot.
6. From the workstation that runs warden sign, verify:
curl -fsS <policy.flex_auth_url>/healthz
7. Run one allow smoke and one deny smoke. Record only non-secret evidence:
actor name, decision id, effect, reason, backend, and whether a certificate
was issued.
## Current Production Fixture
The initial fixture mirrors ops-warden production inventory as of 2026-06-23.
It registers:
| Actor | Type | Principal | Max TTL hours | Allowed subjects |
| --- | --- | --- | --- | --- |
| adm-example | adm | adm-full | 48 | adm-example, iam:adm-example |
| agt-codex-interhub-bootstrap | agt | agt-interhub-bootstrap | 2 | agt-codex-interhub-bootstrap, iam:agt-codex-interhub-bootstrap |
| agt-state-hub-bridge | agt | agt-task-bridge | 24 | agt-state-hub-bridge, iam:agt-state-hub-bridge |
| atm-backup-daily | atm | atm-backup-daily | 8 | atm-backup-daily, iam:atm-backup-daily |
The IAM subject form is intended for WARDEN_POLICY_SUBJECT. If that environment
variable is unset, ops-warden sends the actor name and the same policy path
continues to work.
## Smoke Expectations
Allow path:
warden sign agt-state-hub-bridge
Expected non-secret evidence: decision effect allow, reason
signing_policy_matched, signatures.log includes policy_decision_id.
Deny path:
warden sign agt-state-hub-bridge --ttl 999
Expected non-secret evidence: effect deny, reason ttl_out_of_bounds, no
certificate issued. With fail_closed true, unreachable flex-auth must also block
signing.
OpenBao-backed signing remains an operator smoke because it requires a scoped
VAULT_TOKEN. The previous session returned HTTP 403 on 2026-06-23; retry with:
SMOKE_VAULT=1 ~/ops-warden/scripts/policy_gate_production_smoke.sh
## References
- docs/ops-warden-policy-gate-handoff.md
- examples/ops-warden/production_registry_snapshot.json
- ~/ops-warden/wiki/PolicyGatedSigning.md
- ~/ops-warden/history/2026-06-23-flex-auth-policy-gate-production-smoke.md

View File

@@ -0,0 +1,105 @@
# Relationship PDP Adapter Boundary
Status: implemented for FLEX-WP-0004 P4.2.
## Role
The relationship PDP adapter is the common boundary for OpenFGA,
SpiceDB, and other tuple-oriented authorization systems. These backends
answer questions like:
```text
is subject S related to object O through relation R?
```
flex-auth keeps the protected-system API stable. The adapter translates
`CheckRequest`, `BatchCheckRequest`, registry relationships, and
`list_allowed` queries into tuple checks, then wraps backend responses
back into the canonical `DecisionEnvelope`.
## Tuple Mapping
Canonical flex-auth relationship facts map to:
| flex-auth | tuple field |
| --- | --- |
| resource type | `object_type` |
| resource id | `object_id` |
| action or relation | `relation` |
| subject type | `subject_type` |
| subject id | `subject_id` |
| group membership indirection | `subject_relation=member` |
Registry group and team membership become `group#member` tuples.
Resource parent edges become `parent` tuples. Resource owners become
`owner_team` tuples. Relationship facts keep conditions, provenance,
metadata, and CARING descriptors on the imported tuple so backend
results can preserve explanatory context.
## Inheritance
Relationship backends represent inheritance differently:
- OpenFGA usually models it in the authorization model through rewrites.
- SpiceDB usually models it through relation definitions and caveats.
- flex-auth records inherited evidence in
`TupleCheckResult.InheritedFrom`.
The envelope does not expose backend-native rewrite syntax. It records
the fact that inheritance participated through diagnostics and preserves
the matched CARING descriptor from direct or inherited tuples.
## Batch And List
`BatchCheck` preserves request order. If the backend returns a partial
batch, flex-auth emits fail-closed deny envelopes for the affected
resources.
`ListAllowed` returns a `ListAllowedResult` containing allow envelopes,
the backend consistency token, and diagnostics. It intentionally returns
envelopes instead of raw resource ids so downstream consumers keep the
same audit and CARING metadata they receive from single checks.
## Consistency Metadata
Tuple backends expose different consistency tokens:
- OpenFGA: model id plus optional tuple-store continuation/freshness
metadata.
- SpiceDB: zedtoken.
The adapter stores the backend token in
`DecisionEnvelope.provenance.directory_etag`. The field name is kept for
compatibility with the existing flex-auth envelope; for relationship PDPs
it means "relationship backend consistency token".
## Failure Behavior
The adapter fails closed for:
- backend unavailable: `relationship_backend_unavailable`
- stale consistency token: `relationship_data_stale`
- partial backend result: `relationship_partial_result`
- untranslatable request: `relationship_request_incomplete`
Each failure is a deny envelope with `diagnostics.relationship_failure`
and a CARING conformance finding. This keeps delegated tuple behavior
aligned with standalone fail-closed behavior.
## CARING Preservation
Tuple systems do not understand CARING directly. flex-auth therefore
keeps CARING metadata at the adapter boundary:
- request descriptor wins when supplied;
- backend result descriptor is next;
- matched tuple descriptor is next;
- inherited tuple descriptor is next;
- otherwise the envelope includes
`RELATIONSHIP-CARING-DESCRIPTOR-MISSING`.
The adapter copies scope, planes, capabilities, exposure modes,
restrictions, derived capabilities, conformance findings, and
exposure-event hooks into the decision envelope. Backend-native role or
relation names should not leak into protected systems as a replacement
for CARING canonical roles.

View File

@@ -0,0 +1,103 @@
# Rule PDP Adapter Boundary
Status: implemented for FLEX-WP-0004 P4.3.
## Role
The rule PDP adapter is the common boundary for OPA/Rego, Cedar-style
policy services, and other engines that evaluate a policy language over
a structured request. It is separate from the relationship-PDP boundary:
relationship backends answer tuple reachability questions, while rule
backends evaluate policy logic over subject, action, resource, context,
and CARING metadata.
## Canonical Input
All rule backends receive the same canonical input shape:
```text
input.subject
input.action
input.resource
input.context
input.caring_context
input.policy.package
input.policy.version
```
OPA/Rego can consume this shape directly. Cedar adapters translate the
same fields into principal/action/resource/context entities at the
backend boundary. Protected systems do not see backend-native input
syntax.
## Policy Artifacts
`PolicyArtifactFromPackage` converts a validated Rego-in-Markdown
package into a delegated artifact:
- `language=rego`
- package id and version from frontmatter
- extracted Rego module unchanged
- test blocks and fixtures preserved
- CARING policy metadata preserved
Cedar and other rule engines use the same `PolicyArtifact` envelope,
but may reject unsupported artifacts with `rule_policy_unsupported`.
## Fixtures
`EvaluateFixtures` runs `api.PolicyFixture` values through the delegated
adapter and compares the returned effect, reason, and obligations. This
keeps delegated backends honest against the same fixtures used by the
standalone evaluator.
## Obligations And Diagnostics
Rule backends can return obligations such as masking, audit, or approval
requirements. The adapter copies them into the canonical
`DecisionEnvelope`. Backend diagnostics are preserved and supplemented
with:
- `adapter=rule`
- backend name
- delegated mode
- language
- policy package and version
- fail-closed reason when present
## Versioning
The envelope records backend policy version in
`matched_policy_version` and `provenance.policy_version`. A backend may
return a newer concrete revision than the request asked for; the adapter
records what actually matched.
## Failure Behavior
The adapter fails closed for:
- backend unavailable: `rule_backend_unavailable`
- stale policy: `rule_policy_stale`
- partial result: `rule_partial_result`
- invalid input: `rule_request_incomplete`
- unsupported policy artifact: `rule_policy_unsupported`
Each failure returns a deny envelope with `diagnostics.rule_failure` and
a CARING conformance finding.
## CARING Preservation
Rule engines vary in how much of CARING they can represent natively.
flex-auth keeps CARING outside the backend-specific language contract:
- request descriptor wins;
- backend result descriptor is next;
- policy frontmatter supplies profile and expected dimensions;
- gaps become `RULE-CARING-METADATA-GAP` or
`RULE-CARING-DESCRIPTOR-MISSING` findings.
The decision envelope preserves descriptor, scope, planes,
capabilities, exposure modes, restrictions, derived capabilities,
conformance findings, exposure-event hooks, obligations, and diagnostics.
Backend-native policy names should never replace canonical CARING roles
in protected-system responses.

View File

@@ -0,0 +1,107 @@
# Topaz Adapter Operations
Status: implemented for FLEX-WP-0004 P4.1.
## Role
The Topaz adapter is a delegated PDP and directory adapter behind the
stable flex-auth API. Protected systems still send `CheckRequest`,
`BatchCheckRequest`, registry snapshots, and Rego-in-Markdown policy
packages to flex-auth. The adapter translates those into Topaz directory
objects, relations, permission checks, and an OPA bundle, then wraps the
result back into the same `DecisionEnvelope` used by standalone mode.
## Wire Protocol
The production recommendation remains gRPC because it is Topaz's native
API and gives the strongest typed client surface for authorizer,
reader, writer, and model operations. The implementation added in P4.1
keeps that choice behind `internal/adapters/topaz.Client` and ships an
HTTP REST client for the runnable `examples/topaz` topology.
This split is deliberate:
- `Client` is the stable flex-auth boundary.
- `HTTPClient` speaks the same REST endpoints used by the spike
(`/api/v3/directory/check`, `/object`, `/relation`, `/manifest`).
- A future gRPC client can replace `HTTPClient` without changing
protected-system contracts or CARING envelope behavior.
- Embedded Topaz remains out of scope because it would couple
flex-auth releases to Topaz internals.
## Startup
1. Start Topaz with the manifest and bundle paths mounted. The
`examples/topaz/docker-compose.yml` file is the local reference.
2. Create a `topaz.HTTPClient` with the directory REST gateway URL.
3. Configure a `topaz.FileBundleSink` that points at the mounted bundle
directory.
4. Build a `topaz.Adapter` with the client and policy metadata.
5. Call `ImportManifest`, `ImportDirectory`, and `ImportPolicy` before
accepting delegated checks.
For local verification:
```sh
cd examples/topaz
docker compose up --abort-on-container-exit --exit-code-from probe
```
The Go integration test is present but skipped by default. Run it with:
```sh
FLEX_AUTH_RUN_TOPAZ_INTEGRATION=1 go test ./internal/adapters/topaz
```
## Directory Consistency
`ImportDirectory` converts the canonical registry snapshot into Topaz
objects and relations. Subjects and service accounts become Topaz
`user` objects. Each subject also receives an `identity:<subject>` object
with an `identifier` relation to the user. Groups and teams become Topaz
`group` objects; teams keep the `team:` prefix. Resources keep their
canonical type names, labels, trust zone, path, owner, and system in
properties.
Relation writes return an optional etag. The adapter records the latest
etag in `DecisionEnvelope.provenance.directory_etag` when Topaz returns
one. Reads may be served from flex-auth's local registry for explanation
or from Topaz for authorization. The decision envelope must say which
backend produced the answer through `provenance.evaluator` and
`provenance.mode`.
## Policy Import
`ImportPolicy` extracts the validated Rego module from a
Rego-in-Markdown package without translation. `FileBundleSink` writes:
- `.manifest` with the package root.
- `policy/<package/path>.rego` with the exact extracted module.
This matches the local-bundle mode used by the Topaz example. Clustered
Topaz deployments can replace the bundle sink with a remote bundle
publisher without changing the adapter contract.
## Fail-Closed Defaults
Delegated checks do not leak backend errors to protected systems as
ambiguous success. The adapter returns a deny envelope for:
- Topaz unavailable: `reason=topaz_unavailable`.
- Stale directory: `reason=topaz_directory_stale`.
- Partial result: `reason=topaz_partial_result`.
- Untranslatable request: `reason=topaz_request_incomplete`.
Each failure includes a CARING conformance finding and
`diagnostics.topaz_failure`. This keeps delegated mode behavior
compatible with standalone fail-closed decisions and makes backend
health visible to audits.
## CARING Preservation
The adapter preserves CARING descriptors from the request or backend
result. It copies descriptor, restrictions, exposure modes, derived
capabilities, conformance findings, and exposure-event hooks into the
decision envelope. If no descriptor is available, the decision still
contains a `TOPAZ-CARING-DESCRIPTOR-MISSING` warning so conformance
checks can distinguish an authorization deny from a metadata gap.

View File

@@ -1,6 +1,6 @@
# Flex-Auth Workplan Planning Map
Date: 2026-05-15
Date: 2026-06-30
## Purpose
@@ -20,10 +20,12 @@ This document captures the current sequencing view for flex-auth workplans.
| Workplan | Priority | Status | Depends On | Current View |
| --- | --- | --- | --- | --- |
| `FLEX-WP-0001` | complete | done | none | Repo intent, boundaries, and authorization landscape research are complete. |
| `FLEX-WP-0005` | P0 | todo | `FLEX-WP-0001` | Foundations and Topaz alignment: ADR-001/002/003, Go skeleton, `FlexAuthResourceManifest` schema pin, Topaz mapping spike, IAM Profile citation, ops-warden boundary clarification. |
| `FLEX-WP-0002` | P0 | blocked | `FLEX-WP-0001`, `FLEX-WP-0005` | Standalone policy-as-code core: schemas, local registry, Rego-in-Markdown policy packages, check APIs, explanations, decision log, CLI/service skeleton, tests. |
| `FLEX-WP-0003` | P1 | blocked | `FLEX-WP-0002` | Markitect consumer integration: resource namespace, manifest import, action vocabulary, decision fixtures, integration docs. |
| `FLEX-WP-0004` | P2 | blocked | `FLEX-WP-0002`, `FLEX-WP-0005` | Delegated PDP and directory adapters: Topaz adapter implementation (evaluation already done in `0005`), OpenFGA/SpiceDB, OPA/Cedar, Keycloak Authorization Services, Entra/Graph/SCIM. |
| `FLEX-WP-0005` | complete | done | `FLEX-WP-0001` | Foundations and Topaz alignment are complete: ADR-001/002/003, Go skeleton, `FlexAuthResourceManifest` schema pin, Topaz mapping spike, IAM Profile citation, ops-warden boundary clarification. |
| `FLEX-WP-0002` | complete | completed | `FLEX-WP-0001`, `FLEX-WP-0005` | Standalone policy-as-code core is complete: schemas, local registry, CARING profile/descriptors, Rego-in-Markdown policy packages, check APIs, explanations, decision log, CLI/service skeleton, tests. |
| `FLEX-WP-0003` | complete | completed | `FLEX-WP-0002` | Markitect consumer integration and first CARING benchmark are complete: resource namespace, manifest import, action vocabulary, descriptor fixtures, decision fixtures, integration docs. |
| `FLEX-WP-0004` | complete | completed | `FLEX-WP-0002`, `FLEX-WP-0005` | Delegated PDP and directory adapter boundary work is complete: Topaz adapter shape, OpenFGA/SpiceDB, OPA/Cedar, Keycloak Authorization Services, Entra/Graph/SCIM, CARING envelope preservation. |
| `FLEX-WP-0006` | complete | finished | `FLEX-WP-0002`, `FLEX-WP-0005` | Ops-warden unblocker is complete: flex-auth publishes `ssh-certificate` / `sign` policies, fixtures, and `/v1/check` smoke evidence for the opt-in pre-sign gate shipped in ops-warden `WARDEN-WP-0007` and tracked for production in `WARDEN-WP-0009`. |
| `FLEX-WP-0007` | complete | finished | `FLEX-WP-0006` | Production registry fixture, sync contract, runtime command, healthz coverage, real actor/IAM tests, operator tunnel reachability, and vault-backed joint smoke are complete. `policy.enabled` remains off by maturity decision until testing/production posture calls for live enforcement. |
## Dependency Notes
@@ -33,18 +35,38 @@ It pulls forward the decisions the original `0002` left implicit (language,
policy format, evaluator alignment) and runs the Topaz mapping spike
before the core's schemas and check API are written.
`docs/caring-architecture-blueprint.md` adds the 2026-05-17 CARING
refinement: CARING remains the semantic standard, while flex-auth becomes
the practical reference implementation for descriptors, conformance
findings, decision metadata, explain output, and exposure-event audit
records. This refinement changes the shape of `FLEX-WP-0002` but does not
add a new predecessor workplan.
`FLEX-WP-0002` comes after `0005` so the standalone evaluator embeds the
OPA Rego library and produces decision envelopes shaped to match the
delegated-mode envelopes added later.
delegated-mode envelopes added later. It now also pins the executable
CARING profile in the same schema slice.
`FLEX-WP-0003` follows the core. Markitect has already completed its
side of the contract in `MKTT-WP-0014`; flex-auth pins the manifest in
`FLEX-WP-0005 T03` and implements the service-side registry and decision
behavior in `0003`.
It also becomes the first consumer benchmark for proving local roles and
resource semantics can map cleanly into CARING dimensions.
`FLEX-WP-0004` waits for the standalone core for the same reason as
before, but its Topaz evaluation task moved to `0005 T04`; this workplan
now implements the Topaz adapter against the spike's output.
Delegated adapters must preserve flex-auth's CARING descriptor and
conformance fields even when backend-native role semantics differ.
`FLEX-WP-0006` was the cross-repo integration unblocker for
ops-warden. ops-warden already implements the opt-in policy call
(`policy.enabled: true`) and production OpenBao signing works without the
gate. flex-auth now publishes the protected-system manifest,
`ssh-certificate` / `sign` policy package, allow/deny fixtures, and
`POST /v1/check` evidence that ops-warden can use before enabling
`policy.enabled` in production.
## State Hub Mirror
@@ -56,3 +78,9 @@ Native State Hub dependency edges:
- `FLEX-WP-0003 -> FLEX-WP-0002`
- `FLEX-WP-0004 -> FLEX-WP-0002`
- `FLEX-WP-0004 -> FLEX-WP-0005` (Topaz adapter consumes the spike)
- `FLEX-WP-0006 -> FLEX-WP-0002`
- `FLEX-WP-0006 -> FLEX-WP-0005`
- ops-warden: `WARDEN-WP-0009` finished (caller + registry smoke). FLEX-WP-0007
is also finished; production `policy.enabled: true` waits for a later
maturity/posture decision, not for repo-side flex-auth artifacts.
- `FLEX-WP-0007 -> FLEX-WP-0006`

View File

@@ -9,8 +9,12 @@ FLEX-WP-0005):
examples/
claims/ # key-cape lightweight-mode and Keycloak heavy-mode
# claim envelopes (P5.5)
caring/ # executable CARING descriptor, request,
# decision, registry, and audit fixtures (P2.1)
markitect/ # FlexAuthResourceManifest fixtures, decision
# fixtures, and Rego-in-Markdown policy packages
ops-warden/ # SSH certificate signing policy-gate fixtures
# for ops-warden policy.enabled smoke checks
topaz/ # docker-compose + sample directory and policy
# for the Topaz alignment spike (P5.4)
policies/ # generic Rego-in-Markdown packages used by

12
examples/caring/README.md Normal file
View File

@@ -0,0 +1,12 @@
# CARING examples
Small fixtures for the executable CARING 0.4.0-RC2 profile used by
`FLEX-WP-0002`.
These are intentionally compact. They prove that the canonical descriptor,
request, decision, registry, audit, and Rego-in-Markdown policy package
shapes can round-trip through `pkg/api` and `internal/policy`.
The set includes local subjects, groups, teams, project resources, inherited
relationship facts, exposure events, allow/deny fixtures, and a
redact-with-obligation policy package.

View File

@@ -0,0 +1,26 @@
id: descriptor:tenant-alpha-document-reader
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Doer
scope:
level: Resource
id: document:internal-note
tenant: tenant:alpha
resource: document:internal-note
planes:
- Data
capabilities:
- View
exposure_modes:
- Masked
- Plaintext
conditions:
- PurposeBound
- Logged
lifecycle_state: Operate
restrictions:
- ExportBlocked
access_path: direct
metadata:
source: examples/caring

View File

@@ -0,0 +1,22 @@
{
"id": "audit:decision:tenant-alpha-internal-note",
"type": "decision",
"decision_id": "decision:tenant-alpha-internal-note",
"subject": {
"id": "user:alice",
"type": "Human",
"tenant": "tenant:alpha"
},
"resource": {
"id": "document:internal-note",
"type": "document",
"system": "markitect-tool",
"tenant": "tenant:alpha"
},
"action": "read",
"effect": "allow",
"timestamp": "2026-05-17T00:00:00Z",
"metadata": {
"profile": "caring-0.4.0-rc2"
}
}

View File

@@ -0,0 +1,12 @@
id: batch:tenant-alpha-documents
subject:
id: user:alice
type: Human
tenant: tenant:alpha
action: read
resources:
- id: document:internal-note
system: markitect-tool
- id: document:missing
type: document
system: markitect-tool

View File

@@ -0,0 +1,41 @@
id: check:tenant-alpha-internal-note
subject:
id: user:alice
type: Human
tenant: tenant:alpha
action: read
resource:
id: document:internal-note
type: document
system: markitect-tool
tenant: tenant:alpha
context:
purpose: knowledge-base-read
assurance:
mfa: true
caring_context:
id: descriptor:tenant-alpha-document-reader
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Doer
scope:
level: Resource
id: document:internal-note
tenant: tenant:alpha
resource: document:internal-note
planes:
- Data
capabilities:
- View
exposure_modes:
- Masked
- Plaintext
conditions:
- PurposeBound
- Logged
lifecycle_state: Operate
restrictions:
- ExportBlocked
access_path: direct
policy_version: markitect.documents.v1

View File

@@ -0,0 +1,69 @@
{
"id": "decision:tenant-alpha-internal-note",
"request_id": "check:tenant-alpha-internal-note",
"effect": "allow",
"reason": "reader_relation",
"matched_policy_version": "markitect.documents.v1",
"matched_rule": "allow_document_read",
"resource": {
"id": "document:internal-note",
"type": "document",
"system": "markitect-tool",
"tenant": "tenant:alpha"
},
"subject": {
"id": "user:alice",
"type": "Human",
"tenant": "tenant:alpha"
},
"obligations": [
{
"type": "log_access",
"parameters": {
"level": "standard"
}
}
],
"diagnostics": {
"policy_package": "examples/caring"
},
"provenance": {
"evaluator": "flex-auth",
"mode": "standalone",
"policy_package": "markitect.documents",
"policy_version": "v1",
"decision_time": "2026-05-17T00:00:00Z"
},
"caring": {
"profile": "caring-0.4.0-rc2",
"descriptor": {
"id": "descriptor:tenant-alpha-document-reader",
"profile": "caring-0.4.0-rc2",
"subject_type": "Human",
"organization_relation": "Customer",
"canonical_role": "Doer",
"scope": {
"level": "Resource",
"id": "document:internal-note",
"tenant": "tenant:alpha",
"resource": "document:internal-note"
},
"planes": ["Data"],
"capabilities": ["View"],
"exposure_modes": ["Masked", "Plaintext"],
"conditions": ["PurposeBound", "Logged"],
"lifecycle_state": "Operate",
"restrictions": ["ExportBlocked"],
"access_path": "direct"
},
"restrictions_evaluated": ["ExportBlocked"],
"exposure_modes": ["Masked", "Plaintext"],
"conformance_findings": [
{
"code": "CARING-EXPORT-SEPARATION",
"severity": "info",
"message": "View is allowed, but Exportable exposure remains separately blocked."
}
]
}
}

View File

@@ -0,0 +1,20 @@
{
"id": "exposure:tenant-alpha-support-001",
"type": "X-Support",
"actor": "user:alice",
"subject": "user:bob",
"scope": {
"level": "Resource",
"id": "document:alpha-plan",
"tenant": "tenant:alpha",
"resource": "document:alpha-plan"
},
"planes": ["Data"],
"exposure_modes": ["Masked"],
"reason": "Support review of masked project plan",
"decision_id": "decision:tenant-alpha-support-001",
"timestamp": "2026-05-17T00:00:00Z",
"metadata": {
"source": "examples/caring/exposure_event.json"
}
}

View File

@@ -0,0 +1,38 @@
- id: rel:reviewers-project-reviewer
system: markitect-tool
subject: team:project-reviewers
relation: reviewer
object: project:alpha-redesign
tenant: tenant:alpha
conditions:
- Logged
caring:
id: descriptor:tenant-alpha-project-reviewer
profile: caring-0.4.0-rc2
subject_type: Group
organization_relation: Customer
canonical_role: Verifier
scope:
level: Project
id: project:alpha-redesign
tenant: tenant:alpha
resource: project:alpha-redesign
planes:
- Data
capabilities:
- Review
exposure_modes:
- Masked
conditions:
- Logged
restrictions:
- ExportBlocked
- id: rel:alpha-plan-inherits-project-reviewer
system: markitect-tool
subject: document:alpha-plan
relation: inherits
object: project:alpha-redesign
tenant: tenant:alpha
metadata:
inheritance: parent
source: examples/caring/inherited_relationships.yaml

View File

@@ -0,0 +1,45 @@
id: fixture:markitect-internal-read-allow
request:
id: check:tenant-alpha-internal-note
subject:
id: user:alice
type: Human
tenant: tenant:alpha
action: read
resource:
id: document:internal-note
type: document
system: markitect-tool
tenant: tenant:alpha
caring_context:
id: descriptor:tenant-alpha-document-reader
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Doer
scope:
level: Resource
id: document:internal-note
tenant: tenant:alpha
resource: document:internal-note
planes:
- Data
capabilities:
- View
exposure_modes:
- Masked
- Plaintext
conditions:
- PurposeBound
- Logged
restrictions:
- ExportBlocked
expect:
effect: allow
reason: reader_relation
conformance_findings:
- code: CARING-EXPORT-SEPARATION
severity: info
message: View is allowed, but Exportable exposure remains separately blocked.
metadata:
source: examples/caring

View File

@@ -0,0 +1,137 @@
---
id: markitect.documents.internal-read
name: Markitect internal document read
namespace: markitect:document
version: v1
status: draft
package: flexauth.markitect.documents
actions:
- read
owner: team:platform-architecture
fixtures:
- policy_fixture.yaml
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles:
- Doer
organization_relations:
- Customer
scopes:
- level: Resource
id: document:internal-note
tenant: tenant:alpha
planes:
- Data
capabilities:
- View
exposure_modes:
- Masked
- Plaintext
conditions:
- PurposeBound
- Logged
restrictions:
- ExportBlocked
activation:
mode: local
metadata:
source: examples/caring/policy_package.md
---
# Markitect Internal Document Read
This package authorizes read access to an internal Markitect document when
the request carries a CARING descriptor for a customer Doer with View
capability on the document resource and an explicit ExportBlocked restriction.
## Rules
```rego
import future.keywords.if
import future.keywords.in
default decision := {"effect": "deny", "reason": "no_matching_rule"}
decision := {
"effect": "allow",
"reason": "reader_relation",
"conformance_findings": [{
"code": "CARING-EXPORT-SEPARATION",
"severity": "info",
"message": "View is allowed, but Exportable exposure remains separately blocked."
}]
} if {
input.action == "read"
input.resource.system == "markitect-tool"
input.resource.type == "document"
input.caring_context.profile == "caring-0.4.0-rc2"
input.caring_context.organization_relation == "Customer"
input.caring_context.canonical_role == "Doer"
"View" in input.caring_context.capabilities
"ExportBlocked" in input.caring_context.restrictions
}
```
## Tests
```rego test
package flexauth.markitect.documents_test
import future.keywords.if
import data.flexauth.markitect.documents
test_reader_relation_allows if {
documents.decision.effect == "allow" with input as {
"action": "read",
"resource": {
"id": "document:internal-note",
"type": "document",
"system": "markitect-tool",
"tenant": "tenant:alpha"
},
"caring_context": {
"profile": "caring-0.4.0-rc2",
"organization_relation": "Customer",
"canonical_role": "Doer",
"capabilities": ["View"],
"restrictions": ["ExportBlocked"]
}
}
}
test_missing_caring_context_denies if {
documents.decision.effect == "deny" with input as {
"action": "read",
"resource": {
"id": "document:internal-note",
"type": "document",
"system": "markitect-tool",
"tenant": "tenant:alpha"
}
}
}
```
## Fixtures
```yaml fixture
id: fixture:markitect-internal-read-deny
request:
id: check:tenant-alpha-internal-note-deny
subject:
id: user:bob
type: Human
tenant: tenant:alpha
action: read
resource:
id: document:internal-note
type: document
system: markitect-tool
tenant: tenant:alpha
expect:
effect: deny
reason: no_matching_rule
metadata:
source: examples/caring/policy_package.md
```

View File

@@ -0,0 +1,29 @@
id: markitect.documents.internal-read
name: Markitect internal document read
version: v1
status: draft
package: flexauth.markitect.documents
caring:
profile: caring-0.4.0-rc2
canonical_roles:
- Doer
organization_relations:
- Customer
scopes:
- level: Resource
id: document:internal-note
tenant: tenant:alpha
planes:
- Data
capabilities:
- View
exposure_modes:
- Masked
- Plaintext
conditions:
- PurposeBound
- Logged
restrictions:
- ExportBlocked
metadata:
source: examples/caring

View File

@@ -0,0 +1,27 @@
id: markitect-project-resources
system: markitect-tool
resources:
- id: project:alpha-redesign
type: project
path: /projects/alpha-redesign
labels:
- project
trust_zone: internal
owner: team:project-reviewers
- id: document:alpha-plan
type: document
path: /projects/alpha-redesign/plan
parent: project:alpha-redesign
labels:
- internal
- pii
trust_zone: internal
owner: team:project-reviewers
actions:
- read
- review
- export
caring_profile: caring-0.4.0-rc2
metadata:
flex_auth_contract: resource-registration-v0
source: examples/caring/project_resource_manifest.yaml

View File

@@ -0,0 +1,132 @@
---
id: markitect.documents.mask-pii
name: Markitect masked PII read
namespace: markitect:document
version: v1
status: draft
package: flexauth.markitect.redact
actions:
- read
owner: team:project-reviewers
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles:
- Verifier
organization_relations:
- Customer
scopes:
- level: Resource
id: document:alpha-plan
tenant: tenant:alpha
planes:
- Data
capabilities:
- View
- Mask
exposure_modes:
- Masked
conditions:
- Logged
restrictions:
- ExportBlocked
metadata:
source: examples/caring/redact_policy_package.md
---
# Markitect Masked PII Read
This package returns a redaction decision when a verifier may inspect a
document only through masked fields.
## Rules
```rego
import future.keywords.if
import future.keywords.in
default decision := {"effect": "deny", "reason": "no_matching_rule"}
decision := {
"effect": "redact",
"reason": "masked_pii",
"obligations": [{
"type": "mask_fields",
"parameters": {"fields": ["email", "phone"]}
}]
} if {
input.action == "read"
input.resource.id == "document:alpha-plan"
"Mask" in input.caring_context.capabilities
"Masked" in input.caring_context.exposure_modes
}
```
## Tests
```rego test
package flexauth.markitect.redact_test
import future.keywords.if
import data.flexauth.markitect.redact
test_masked_reader_gets_redaction if {
redact.decision.effect == "redact" with input as {
"action": "read",
"resource": {"id": "document:alpha-plan"},
"caring_context": {
"capabilities": ["View", "Mask"],
"exposure_modes": ["Masked"]
}
}
}
```
## Fixtures
```yaml fixture
id: fixture:masked-pii-redact
request:
id: check:masked-pii
subject:
id: user:bob
type: Human
tenant: tenant:alpha
action: read
resource:
id: document:alpha-plan
type: document
system: markitect-tool
tenant: tenant:alpha
caring_context:
id: descriptor:tenant-alpha-masked-pii-reviewer
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Verifier
scope:
level: Resource
id: document:alpha-plan
tenant: tenant:alpha
resource: document:alpha-plan
planes:
- Data
capabilities:
- View
- Mask
exposure_modes:
- Masked
conditions:
- Logged
restrictions:
- ExportBlocked
expect:
effect: redact
reason: masked_pii
obligations:
- type: mask_fields
parameters:
fields:
- email
- phone
```

View File

@@ -0,0 +1,99 @@
{
"systems": [
{
"id": "markitect-tool",
"name": "Markitect Tool",
"resource_types": [
{
"name": "document",
"scope_level": "Resource",
"planes": ["Data"]
}
],
"actions": [
{
"name": "read",
"capabilities": ["View"],
"planes": ["Data"],
"exposure_modes": ["Masked", "Plaintext"]
}
],
"caring_profiles": ["caring-0.4.0-rc2"]
}
],
"resource_manifests": [
{
"id": "markitect-example-knowledge-base",
"system": "markitect-tool",
"resources": [
{
"id": "document:internal-note",
"type": "document",
"parent": "knowledge-base:markitect-example",
"labels": ["internal"],
"trust_zone": "internal",
"owner": "team:platform"
}
],
"actions": ["read", "query", "search", "package", "export"],
"caring_profile": "caring-0.4.0-rc2",
"metadata": {
"flex_auth_contract": "resource-registration-v0"
}
}
],
"tenants": [
{
"id": "tenant:alpha",
"name": "Tenant Alpha"
}
],
"subjects": [
{
"id": "user:alice",
"type": "Human",
"display_name": "Alice Example",
"organization_relation": "Customer",
"roles": ["Doer"],
"groups": ["group:platform-architecture"],
"tenant": "tenant:alpha"
}
],
"groups": [
{
"id": "group:platform-architecture",
"display_name": "Platform Architecture",
"members": ["user:alice"],
"tenant": "tenant:alpha"
}
],
"relationships": [
{
"id": "rel:alice-reader-internal-note",
"system": "markitect-tool",
"subject": "group:platform-architecture",
"relation": "reader",
"object": "document:internal-note",
"tenant": "tenant:alpha",
"conditions": ["Logged"],
"caring": {
"id": "descriptor:tenant-alpha-document-reader",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "Customer",
"canonical_role": "Doer",
"scope": {
"level": "Resource",
"id": "document:internal-note",
"tenant": "tenant:alpha",
"resource": "document:internal-note"
},
"planes": ["Data"],
"capabilities": ["View"],
"exposure_modes": ["Masked", "Plaintext"],
"conditions": ["Logged"],
"restrictions": ["ExportBlocked"]
}
}
]
}

View File

@@ -0,0 +1,32 @@
id: rel:alice-reader-internal-note
system: markitect-tool
subject: group:platform-architecture
relation: reader
object: document:internal-note
tenant: tenant:alpha
conditions:
- Logged
caring:
id: descriptor:tenant-alpha-document-reader
profile: caring-0.4.0-rc2
subject_type: Group
organization_relation: Customer
canonical_role: Doer
scope:
level: Resource
id: document:internal-note
tenant: tenant:alpha
resource: document:internal-note
planes:
- Data
capabilities:
- View
exposure_modes:
- Masked
- Plaintext
conditions:
- Logged
restrictions:
- ExportBlocked
provenance:
source: examples/caring

View File

@@ -0,0 +1,22 @@
id: subjects:tenant-alpha
subjects:
- id: user:alice
type: Human
display_name: Alice Example
organization_relation: Customer
roles:
- Doer
groups:
- group:platform-architecture
tenant: tenant:alpha
groups:
- id: group:platform-architecture
display_name: Platform Architecture
members:
- user:alice
tenant: tenant:alpha
tenants:
- id: tenant:alpha
name: Tenant Alpha
metadata:
source: examples/caring

View File

@@ -0,0 +1,37 @@
id: tenant-alpha-project-team
tenants:
- id: tenant:alpha
name: Tenant Alpha
subjects:
- id: user:alice
type: Human
display_name: Alice Example
organization_relation: Customer
roles:
- Doer
groups:
- group:platform-architecture
tenant: tenant:alpha
- id: user:bob
type: Human
display_name: Bob Example
organization_relation: Customer
roles:
- Verifier
groups:
- team:project-reviewers
tenant: tenant:alpha
groups:
- id: group:platform-architecture
display_name: Platform Architecture
members:
- user:alice
tenant: tenant:alpha
teams:
- id: team:project-reviewers
display_name: Project Reviewers
members:
- user:bob
tenant: tenant:alpha
metadata:
source: examples/caring/team_subject_manifest.yaml

View File

@@ -1,6 +1,6 @@
# examples/claims/
Contract fixtures for the NetKingdom IAM Profile v0.1 claim shapes
Contract fixtures for the NetKingdom IAM Profile v0.2 claim shapes
flex-auth must accept. Each file is the *raw verified claim map* as
flex-auth receives it from the upstream identity layer (key-cape or
Keycloak); flex-auth's normalization produces the same
@@ -11,10 +11,10 @@ surface.
| Fixture | Provider | Demonstrates |
| --- | --- | --- |
| `key-cape-lightweight.yaml` | key-cape lightweight mode | Profile-conformant minimum: single audience, top-level `roles` array, single-factor `amr=pwd`. |
| `keycloak-heavy.yaml` | Keycloak production | Full variation set: `realm_access.roles` + `resource_access.<client>.roles`, scope as space-separated string, MFA via `amr=otp`, multiple audiences. |
| `service-account.yaml` | Either provider | Hub-to-hub service account; `service` + `operator` roles, no `preferred_username`, narrow scope. |
| `emergency.yaml` | Either provider | Break-glass human identity; `emergency` role, short expiry, hardware MFA, audit-trail metadata in an `emergency` claim. |
| `key-cape-lightweight.yaml` | key-cape lightweight mode | Profile-conformant minimum: single audience, top-level `roles` array, explicit tenant/principal/assurance. |
| `keycloak-heavy.yaml` | Keycloak production | Full variation set: canonical `roles`, provider-native role sources, scope as space-separated string, MFA assurance, multiple audiences. |
| `service-account.yaml` | Either provider | Service account; `principal_type: service`, `service` + `operator` roles, no `preferred_username`, narrow scope. |
| `emergency.yaml` | Either provider | Break-glass human identity; `emergency` role, `assurance.level: break_glass`, short expiry, audit-trail metadata in an `emergency` claim. |
| `keycloak-group-overage.yaml` | Entra/Keycloak | Group-claim overage signal (`hasgroups: true`); flex-auth's directory resolver fetches the full set. |
These fixtures are loaded by the standalone evaluator's contract tests

View File

@@ -2,9 +2,9 @@
# expiry, emergency role, requires MFA per the profile, and triggers
# durable audit recording on every flex-auth decision that involves it.
#
# Reference: NetKingdom IAM Profile v0.1 §"Human Override and Emergency
# Access". flex-auth maps this to principal_type=emergency and emits a
# `record_emergency` obligation on every decision.
# Reference: NetKingdom IAM Profile v0.2 "Emergency And Break-Glass
# Access". flex-auth maps the emergency role plus break_glass assurance to
# a `record_emergency` obligation on every decision.
iss: https://sso.netkingdom.example/realms/netkingdom
sub: f1c4f64e-2c0c-4cda-8c9f-9f3f8f3a2b0e
@@ -13,6 +13,8 @@ aud:
exp: 1767226200 # iat + 10 minutes; emergency tokens are short-lived
iat: 1767225600
auth_time: 1767225595
tenant: tenant:platform
principal_type: human
azp: ops-console
preferred_username: ada
email: ada@netkingdom.example
@@ -20,11 +22,22 @@ scope: openid profile hub:admin
roles:
- emergency
- admin
groups:
- /platform/stewards
amr:
- pwd
- otp
- hwk
acr: "3"
assurance:
level: break_glass
methods:
- pwd
- otp
- hwk
mfa: true
source: keycloak
at: 1767225595
emergency:
incident_id: INC-2026-0042
authorized_by: "team:platform-stewards"

View File

@@ -2,8 +2,8 @@
# authenticated human user. Profile-conformant minimum: required claims
# only, single audience, simple roles list, OIDC standard amr values.
#
# Reference: docs/iam-profile-consumption.md, NetKingdom IAM Profile v0.1
# §"Required Claims" and §"Local Development Profile".
# Reference: docs/iam-profile-consumption.md, NetKingdom IAM Profile v0.2
# "Core Claims" and "Local Development Profile".
iss: https://idp.netkingdom.local/keycape
sub: user-7f9e2b
@@ -11,6 +11,8 @@ aud:
- flex-auth
exp: 4102444800 # 2100-01-01, kept far-future for stable fixtures
iat: 1767225600 # 2026-01-01
tenant: tenant:platform
principal_type: human
preferred_username: ada
email: ada@netkingdom.local
name: Ada Lovelace
@@ -22,3 +24,10 @@ amr:
acr: "1"
groups:
- /markitect/readers
assurance:
level: aal1
methods:
- pwd
mfa: false
source: key-cape
at: 1767225600

View File

@@ -14,6 +14,8 @@ aud:
exp: 4102444800
iat: 1767225600
auth_time: 1767225590
tenant: tenant:platform
principal_type: human
azp: markitect-cli
preferred_username: ada
email: ada@netkingdom.example
@@ -22,6 +24,8 @@ name: Ada Lovelace
given_name: Ada
family_name: Lovelace
scope: openid profile email hub:read hub:write hub:capability
roles:
- operator
realm_access:
roles:
- default-roles-netkingdom
@@ -40,4 +44,12 @@ amr:
- pwd
- otp
acr: "2"
assurance:
level: aal2
methods:
- pwd
- otp
mfa: true
source: keycloak
at: 1767225590
sid: 4c0a3a8a-3a47-4f2f-8e89-9e5f9b0a0a0a

View File

@@ -3,8 +3,7 @@
# operation it performs. No preferred_username (service identities are
# named after the service and environment per the profile).
#
# Reference: NetKingdom IAM Profile v0.1 §"Service Account Flow" and
# §"Hub-to-Hub Service Account Pattern".
# Reference: NetKingdom IAM Profile v0.2 "Service Account Flow".
iss: https://sso.netkingdom.example/realms/netkingdom
sub: svc-markitect-tool-prod
@@ -12,9 +11,19 @@ aud:
- flex-auth
exp: 4102444800
iat: 1767225600
tenant: tenant:platform
principal_type: service
azp: svc-markitect-tool-prod
client_id: svc-markitect-tool-prod
scope: hub:read hub:capability
roles:
- service
- operator
groups: []
assurance:
level: aal1
methods:
- client_secret
mfa: false
source: keycloak
at: 1767225600

View File

@@ -0,0 +1,13 @@
id: markitect-ambiguous-example
system: markitect-tool
caring_profile: caring-0.4.0-rc2
resources:
- id: document:ambiguous-note
type: document
parent: knowledge-base:markitect-example
path: examples/policy/ambiguous-note.md
actions:
- read
metadata:
source: examples/markitect/ambiguous_resource_manifest.yaml
flex_auth_contract: resource-registration-v0

View File

@@ -0,0 +1,239 @@
- id: fixture:markitect-public-document-allow
request:
id: check:markitect-public-document
subject:
id: user:visitor
type: Human
tenant: tenant:alpha
action: read
resource:
id: document:public-note
type: document
system: markitect-tool
tenant: tenant:alpha
attributes:
labels:
- public
trust_zone: public
caring_context:
id: descriptor:public-document-reader
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Doer
scope:
level: Resource
id: document:public-note
tenant: tenant:alpha
planes:
- Data
capabilities:
- View
exposure_modes:
- Plaintext
conditions:
- Logged
expect:
effect: allow
reason: public_document
metadata:
expected_caring_descriptor: descriptor:public-document-reader
expected_conformance_findings: []
expected_exposure_modes:
- Plaintext
expected_audit_behavior: sampled_allow
- id: fixture:markitect-internal-document-deny
request:
id: check:markitect-internal-document-deny
subject:
id: user:visitor
type: Human
tenant: tenant:alpha
attributes:
groups: []
action: read
resource:
id: document:internal-note
type: document
system: markitect-tool
tenant: tenant:alpha
attributes:
labels:
- internal
trust_zone: internal
expect:
effect: deny
reason: no_matching_rule
metadata:
expected_caring_descriptor: null
expected_conformance_findings: []
expected_exposure_modes:
- None
expected_audit_behavior: always_record
- id: fixture:markitect-internal-document-reader-allow
request:
id: check:markitect-internal-document-reader
subject:
id: user:alice
type: Human
tenant: tenant:alpha
attributes:
groups:
- group:platform-architecture
action: read
resource:
id: document:internal-note
type: document
system: markitect-tool
tenant: tenant:alpha
attributes:
labels:
- internal
trust_zone: internal
caring_context:
id: descriptor:internal-document-reader
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Doer
scope:
level: Resource
id: document:internal-note
tenant: tenant:alpha
planes:
- Data
capabilities:
- View
exposure_modes:
- Masked
- Plaintext
conditions:
- Logged
restrictions:
- ExportBlocked
expect:
effect: allow
reason: reader_group
metadata:
expected_caring_descriptor: descriptor:internal-document-reader
expected_conformance_findings: []
expected_exposure_modes:
- Masked
- Plaintext
expected_audit_behavior: sampled_allow
- id: fixture:markitect-restricted-export-steward-mfa
request:
id: check:markitect-restricted-export
subject:
id: user:steward
type: Human
tenant: tenant:alpha
attributes:
roles:
- steward
action: export
resource:
id: export:internal-note-review-bundle
type: export
system: markitect-tool
tenant: tenant:alpha
attributes:
labels:
- export
trust_zone: external
context:
mfa: true
reason: customer-approved export
caring_context:
id: descriptor:restricted-export-steward
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Maintainer
scope:
level: Record
id: export:internal-note-review-bundle
tenant: tenant:alpha
planes:
- Data
- Audit
capabilities:
- Export
exposure_modes:
- Exportable
- Plaintext
conditions:
- MFARequired
- Logged
expect:
effect: allow
reason: steward_export_mfa
conformance_findings:
- code: MARKITECT-EXPORT-MFA-LOGGED
severity: info
message: Export is allowed only with steward role, MFA, and logging.
metadata:
expected_caring_descriptor: descriptor:restricted-export-steward
expected_exposure_modes:
- Exportable
- Plaintext
expected_audit_behavior: always_record
- id: fixture:markitect-context-package-activation
request:
id: check:markitect-context-package-activation
subject:
id: user:alice
type: Human
tenant: tenant:alpha
action: activate_context
resource:
id: context-package:internal-note-review
type: context_package
system: markitect-tool
tenant: tenant:alpha
attributes:
labels:
- internal
- generated
context:
freshness_seconds: 600
policy_version: markitect-gateway-v1
caring_context:
id: descriptor:context-package-activation
profile: caring-0.4.0-rc2
subject_type: Human
organization_relation: Customer
canonical_role: Verifier
scope:
level: Dataset
id: context-package:internal-note-review
tenant: tenant:alpha
planes:
- Intent
- Policy
capabilities:
- Use
- Execute
exposure_modes:
- Metadata
- Masked
conditions:
- PurposeBound
- Logged
expect:
effect: allow
reason: fresh_context_package
obligations:
- type: record_context_activation
parameters:
freshness_seconds: 600
conformance_findings:
- code: MARKITECT-CONTEXT-FRESHNESS
severity: info
message: Context package activation includes policy version and freshness metadata.
metadata:
expected_caring_descriptor: descriptor:context-package-activation
expected_exposure_modes:
- Metadata
- Masked
expected_audit_behavior: always_record

View File

@@ -0,0 +1,152 @@
---
id: markitect.gateway.check-fixtures
name: Markitect gateway check fixtures
namespace: markitect:gateway
version: v1
status: draft
package: flexauth.markitect.gateway
actions:
- read
- export
- activate_context
owner: team:platform-architecture
fixtures:
- check_fixtures.yaml
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles:
- Doer
- Maintainer
- Verifier
organization_relations:
- Customer
scopes:
- level: Resource
id: document:public-note
tenant: tenant:alpha
- level: Resource
id: document:internal-note
tenant: tenant:alpha
- level: Dataset
id: context-package:internal-note-review
tenant: tenant:alpha
planes:
- Intent
- Data
- Audit
capabilities:
- View
- Export
- Use
- Execute
exposure_modes:
- Metadata
- Masked
- Plaintext
- Exportable
conditions:
- MFARequired
- PurposeBound
- Logged
restrictions:
- ExportBlocked
metadata:
source: examples/markitect/check_policy_package.md
---
# Markitect Gateway Check Fixtures
This package captures the first Markitect gateway scenarios as executable Rego
and external fixtures.
## Rules
```rego
import future.keywords.if
import future.keywords.in
default decision := {"effect": "deny", "reason": "no_matching_rule"}
decision := {"effect": "allow", "reason": "public_document"} if {
input.action == "read"
input.resource.type == "document"
"public" in object.get(input.resource.attributes, "labels", [])
}
decision := {"effect": "allow", "reason": "reader_group"} if {
input.action == "read"
input.resource.type == "document"
"internal" in object.get(input.resource.attributes, "labels", [])
"group:platform-architecture" in object.get(input.subject.attributes, "groups", [])
"View" in input.caring_context.capabilities
}
decision := {
"effect": "allow",
"reason": "steward_export_mfa",
"conformance_findings": [{
"code": "MARKITECT-EXPORT-MFA-LOGGED",
"severity": "info",
"message": "Export is allowed only with steward role, MFA, and logging."
}]
} if {
input.action == "export"
"steward" in object.get(input.subject.attributes, "roles", [])
input.context.mfa == true
"Export" in input.caring_context.capabilities
"Exportable" in input.caring_context.exposure_modes
}
decision := {
"effect": "allow",
"reason": "fresh_context_package",
"obligations": [{
"type": "record_context_activation",
"parameters": {"freshness_seconds": input.context.freshness_seconds}
}],
"conformance_findings": [{
"code": "MARKITECT-CONTEXT-FRESHNESS",
"severity": "info",
"message": "Context package activation includes policy version and freshness metadata."
}]
} if {
input.action == "activate_context"
input.resource.type == "context_package"
input.policy_version != ""
input.context.freshness_seconds <= 900
"Use" in input.caring_context.capabilities
"Execute" in input.caring_context.capabilities
}
```
## Tests
```rego test
package flexauth.markitect.gateway_test
import future.keywords.if
import data.flexauth.markitect.gateway
test_public_document_allows if {
gateway.decision.effect == "allow" with input as {
"action": "read",
"resource": {
"type": "document",
"attributes": {"labels": ["public"]}
}
}
}
test_export_requires_mfa if {
gateway.decision.effect == "deny" with input as {
"action": "export",
"subject": {"attributes": {"roles": ["steward"]}},
"context": {"mfa": false},
"caring_context": {
"capabilities": ["Export"],
"exposure_modes": ["Exportable"]
}
}
}
```

View File

@@ -0,0 +1,83 @@
id: markitect-namespace-example
system: markitect-tool
caring_profile: caring-0.4.0-rc2
resources:
- id: knowledge-base:markitect-example
type: knowledge_base
labels:
- internal
trust_zone: internal
owner: team:platform-architecture
- id: repository:markitect-policy
type: repository
parent: knowledge-base:markitect-example
path: repos/markitect-policy
labels:
- internal
trust_zone: internal
owner: team:platform-architecture
- id: document:internal-note
type: document
parent: repository:markitect-policy
path: examples/policy/private/internal-note.md
labels:
- internal
- pii
trust_zone: internal
owner: team:platform-architecture
attributes:
markitect_path: examples/policy/private/internal-note.md
frontmatter_visibility: internal
source_revision: rev:example
- id: section:internal-note#risk
type: section
parent: document:internal-note
path: examples/policy/private/internal-note.md#risk
labels:
- internal
trust_zone: internal
- id: span:internal-note#risk:customer-email
type: span
parent: section:internal-note#risk
labels:
- pii
trust_zone: restricted
attributes:
data_classes:
- email
- id: context-package:internal-note-review
type: context_package
parent: document:internal-note
labels:
- internal
- generated
trust_zone: internal
attributes:
freshness_seconds: 900
workflow_state: prepared
- id: workflow-artifact:internal-note-review-run
type: workflow_artifact
parent: context-package:internal-note-review
labels:
- generated
trust_zone: internal
attributes:
workflow_state: completed
- id: export:internal-note-review-bundle
type: export
parent: workflow-artifact:internal-note-review-run
labels:
- export
trust_zone: external
actions:
- read
- query
- search
- package
- activate_context
- export
- workflow_run
- admin
metadata:
source: examples/markitect/namespace_resource_manifest.yaml
flex_auth_contract: resource-registration-v0

View File

@@ -0,0 +1,155 @@
id: markitect-tool
name: Markitect Tool
description: Markitect protected-system namespace for flex-auth.
caring_profiles:
- caring-0.4.0-rc2
actions:
- name: read
capabilities:
- View
planes:
- Data
exposure_modes:
- Metadata
- Masked
- Plaintext
- name: query
capabilities:
- ViewCollection
- Observe
planes:
- Data
exposure_modes:
- Metadata
- Aggregated
- Masked
- name: search
capabilities:
- ViewCollection
- Observe
planes:
- Data
exposure_modes:
- Metadata
- Aggregated
- Masked
- name: package
capabilities:
- Create
- Bind
- ViewCollection
planes:
- Intent
- Data
exposure_modes:
- Metadata
- Masked
- name: activate_context
capabilities:
- Use
- Execute
planes:
- Intent
- Policy
exposure_modes:
- Metadata
- Masked
- name: export
capabilities:
- Export
planes:
- Data
- Audit
exposure_modes:
- Exportable
- Plaintext
- name: workflow_run
capabilities:
- Execute
- Operate
planes:
- Execution
- Data
- Audit
exposure_modes:
- Metadata
- Masked
- Plaintext
- name: admin
capabilities:
- Configure
- Grant
- Revoke
- Audit
planes:
- Configuration
- Identity
- Policy
- Audit
exposure_modes:
- Metadata
- Plaintext
resource_types:
- name: knowledge_base
scope_level: Workspace
planes:
- Intent
- Data
- name: repository
parent_types:
- knowledge_base
scope_level: Project
planes:
- Build
- Data
- name: document
parent_types:
- repository
- knowledge_base
scope_level: Resource
planes:
- Data
- name: section
parent_types:
- document
scope_level: Subresource
planes:
- Data
- name: span
parent_types:
- section
- document
scope_level: Field
planes:
- Data
- name: context_package
parent_types:
- knowledge_base
- repository
- document
scope_level: Dataset
planes:
- Intent
- Data
- Policy
- name: workflow_artifact
parent_types:
- context_package
- document
scope_level: Process
planes:
- Execution
- Data
- Audit
- name: export
parent_types:
- workflow_artifact
- context_package
- document
scope_level: Record
planes:
- Data
- Audit
metadata:
source: examples/markitect/protected_system_manifest.yaml
namespace_doc: docs/markitect-resource-namespace.md

View File

@@ -0,0 +1,49 @@
# Ops-Warden SSH Signing Policy Gate
This example is the flex-auth side of ops-warden's opt-in pre-sign gate.
When `policy.enabled: true`, ops-warden calls `POST /v1/check` before signing
or issuing an SSH certificate.
Files:
- `protected_system_manifest.yaml` declares the `ops-warden` protected system,
`ssh-certificate` resource type, and `sign` action.
- `resource_manifest.yaml` declares fixture SSH certificate actor resources and
non-secret policy attributes such as allowed principals and TTL maxima.
- `subject_manifest.yaml` declares non-secret fixture actors for `adm`, `agt`,
and `atm` signing paths.
- `registry_snapshot.json` is the combined local registry used by the CLI and
service examples.
- `policy_package.md` is the Rego-in-Markdown policy package.
- `policy_fixtures.yaml` contains allow and deny expectations for package
validation.
- `check_request_*.json` files are ops-warden-shaped `/v1/check` requests.
Run locally:
```bash
flex-auth validate --kind protected-system --file examples/ops-warden/protected_system_manifest.yaml
flex-auth validate --kind resource-manifest --file examples/ops-warden/resource_manifest.yaml
flex-auth validate --kind subject-manifest --file examples/ops-warden/subject_manifest.yaml
flex-auth load-registry --file examples/ops-warden/registry_snapshot.json
flex-auth test-policy --file examples/ops-warden/policy_package.md
flex-auth check --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --request examples/ops-warden/check_request_allow_adm.json
```
The fixture public-key fingerprints are examples only. Do not put real keys,
OpenBao tokens, or private signing material in these files.
## Production Registry Fixture
production_registry_snapshot.json is a non-secret fixture generated by
ops-warden for FLEX-WP-0007 coverage. It mirrors the current production actor
names used by ops-warden inventory and should be refreshed when that inventory
changes.
Validate both registries locally:
flex-auth load-registry --file examples/ops-warden/registry_snapshot.json
flex-auth load-registry --file examples/ops-warden/production_registry_snapshot.json
The production sync contract is documented in docs/ops-warden-registry-sync.md.

View File

@@ -0,0 +1,23 @@
{
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "platform-steward",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"platform",
"root"
],
"actor_type": "adm",
"ttl_hours": 4,
"pubkey_fingerprint": "SHA256:example-adm-fingerprint"
}
}

View File

@@ -0,0 +1,22 @@
{
"id": "check:ops-warden-ci-deploy-agent-agt",
"tenant": "tenant:platform",
"subject": {
"id": "ci-deploy-agent",
"type": "agt"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/ci-deploy-agent",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"deploy"
],
"actor_type": "agt",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-agt-fingerprint"
}
}

View File

@@ -0,0 +1,22 @@
{
"id": "check:ops-warden-backup-automation-atm",
"tenant": "tenant:platform",
"subject": {
"id": "backup-automation",
"type": "atm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/backup-automation",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"backup"
],
"actor_type": "atm",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-atm-fingerprint"
}
}

View File

@@ -0,0 +1,22 @@
{
"id": "check:ops-warden-ci-deploy-agent-agt",
"tenant": "tenant:platform",
"subject": {
"id": "ci-deploy-agent",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/ci-deploy-agent",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"deploy"
],
"actor_type": "agt",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-agt-fingerprint"
}
}

View File

@@ -0,0 +1,22 @@
{
"id": "check:ops-warden-ci-deploy-agent-agt",
"tenant": "tenant:platform",
"subject": {
"id": "ci-deploy-agent",
"type": "agt"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/ci-deploy-agent",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"root"
],
"actor_type": "agt",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-agt-fingerprint"
}
}

View File

@@ -0,0 +1,21 @@
{
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "platform-steward",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"platform"
],
"actor_type": "adm",
"ttl_hours": 4
}
}

View File

@@ -0,0 +1,22 @@
{
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "platform-steward",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"platform"
],
"actor_type": "adm",
"ttl_hours": 12,
"pubkey_fingerprint": "SHA256:example-adm-fingerprint"
}
}

View File

@@ -0,0 +1,22 @@
{
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "unknown-actor",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden"
},
"context": {
"principals": [
"platform"
],
"actor_type": "adm",
"ttl_hours": 4,
"pubkey_fingerprint": "SHA256:example-adm-fingerprint"
}
}

View File

@@ -0,0 +1,337 @@
[
{
"id": "fixture:ops-warden-adm-sign-allow",
"request": {
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "platform-steward",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "platform-steward",
"actor_type": "adm",
"allowed_subjects": [
"platform-steward",
"iam:platform-steward"
],
"allowed_principals": [
"platform",
"root"
],
"max_ttl_hours": 8
}
},
"context": {
"principals": [
"platform",
"root"
],
"actor_type": "adm",
"ttl_hours": 4,
"pubkey_fingerprint": "SHA256:example-adm-fingerprint"
}
},
"expect": {
"effect": "allow",
"reason": "signing_policy_matched"
}
},
{
"id": "fixture:ops-warden-agt-sign-allow",
"request": {
"id": "check:ops-warden-ci-deploy-agent-agt",
"tenant": "tenant:platform",
"subject": {
"id": "ci-deploy-agent",
"type": "agt"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/ci-deploy-agent",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "ci-deploy-agent",
"actor_type": "agt",
"allowed_subjects": [
"ci-deploy-agent",
"iam:ci-deploy-agent"
],
"allowed_principals": [
"deploy",
"git"
],
"max_ttl_hours": 2
}
},
"context": {
"principals": [
"deploy"
],
"actor_type": "agt",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-agt-fingerprint"
}
},
"expect": {
"effect": "allow",
"reason": "signing_policy_matched"
}
},
{
"id": "fixture:ops-warden-atm-sign-allow",
"request": {
"id": "check:ops-warden-backup-automation-atm",
"tenant": "tenant:platform",
"subject": {
"id": "backup-automation",
"type": "atm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/backup-automation",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "backup-automation",
"actor_type": "atm",
"allowed_subjects": [
"backup-automation",
"iam:backup-automation"
],
"allowed_principals": [
"backup"
],
"max_ttl_hours": 1
}
},
"context": {
"principals": [
"backup"
],
"actor_type": "atm",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-atm-fingerprint"
}
},
"expect": {
"effect": "allow",
"reason": "signing_policy_matched"
}
},
{
"id": "fixture:ops-warden-unknown-subject-deny",
"request": {
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "unknown-actor",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "platform-steward",
"actor_type": "adm",
"allowed_subjects": [
"platform-steward",
"iam:platform-steward"
],
"allowed_principals": [
"platform",
"root"
],
"max_ttl_hours": 8
}
},
"context": {
"principals": [
"platform"
],
"actor_type": "adm",
"ttl_hours": 4,
"pubkey_fingerprint": "SHA256:example-adm-fingerprint"
}
},
"expect": {
"effect": "deny",
"reason": "unknown_subject"
}
},
{
"id": "fixture:ops-warden-actor-type-mismatch-deny",
"request": {
"id": "check:ops-warden-ci-deploy-agent-agt",
"tenant": "tenant:platform",
"subject": {
"id": "ci-deploy-agent",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/ci-deploy-agent",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "ci-deploy-agent",
"actor_type": "agt",
"allowed_subjects": [
"ci-deploy-agent",
"iam:ci-deploy-agent"
],
"allowed_principals": [
"deploy",
"git"
],
"max_ttl_hours": 2
}
},
"context": {
"principals": [
"deploy"
],
"actor_type": "agt",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-agt-fingerprint"
}
},
"expect": {
"effect": "deny",
"reason": "actor_type_mismatch"
}
},
{
"id": "fixture:ops-warden-ttl-above-max-deny",
"request": {
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "platform-steward",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "platform-steward",
"actor_type": "adm",
"allowed_subjects": [
"platform-steward",
"iam:platform-steward"
],
"allowed_principals": [
"platform",
"root"
],
"max_ttl_hours": 8
}
},
"context": {
"principals": [
"platform"
],
"actor_type": "adm",
"ttl_hours": 12,
"pubkey_fingerprint": "SHA256:example-adm-fingerprint"
}
},
"expect": {
"effect": "deny",
"reason": "ttl_out_of_bounds"
}
},
{
"id": "fixture:ops-warden-disallowed-principal-deny",
"request": {
"id": "check:ops-warden-ci-deploy-agent-agt",
"tenant": "tenant:platform",
"subject": {
"id": "ci-deploy-agent",
"type": "agt"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/ci-deploy-agent",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "ci-deploy-agent",
"actor_type": "agt",
"allowed_subjects": [
"ci-deploy-agent",
"iam:ci-deploy-agent"
],
"allowed_principals": [
"deploy",
"git"
],
"max_ttl_hours": 2
}
},
"context": {
"principals": [
"root"
],
"actor_type": "agt",
"ttl_hours": 1,
"pubkey_fingerprint": "SHA256:example-agt-fingerprint"
}
},
"expect": {
"effect": "deny",
"reason": "disallowed_principal"
}
},
{
"id": "fixture:ops-warden-missing-fingerprint-deny",
"request": {
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {
"id": "platform-steward",
"type": "adm"
},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "platform-steward",
"actor_type": "adm",
"allowed_subjects": [
"platform-steward",
"iam:platform-steward"
],
"allowed_principals": [
"platform",
"root"
],
"max_ttl_hours": 8
}
},
"context": {
"principals": [
"platform"
],
"actor_type": "adm",
"ttl_hours": 4
}
},
"expect": {
"effect": "deny",
"reason": "missing_pubkey_fingerprint"
}
}
]

View File

@@ -0,0 +1,257 @@
---
id: ops-warden.ssh-certificate.sign
name: Ops-Warden SSH certificate signing
namespace: ops-warden:ssh-certificate
version: v1
status: ready
package: flexauth.ops_warden.ssh_signing
actions:
- sign
owner: team:platform-security
fixtures:
- policy_fixtures.yaml
caring:
profile: caring-0.4.0-rc2
enforce: false
canonical_roles:
- Operator
organization_relations:
- ServiceProvider
scopes:
- level: Platform
id: platform:ssh-signing
tenant: tenant:platform
planes:
- Identity
- Secret
- Audit
capabilities:
- Use
- Operate
- Audit
exposure_modes:
- Metadata
conditions:
- TimeLimited
- Logged
restrictions:
- PrivilegeEscalationBlocked
- SecretAccessBlocked
activation:
mode: local
metadata:
source: examples/ops-warden/policy_package.md
ops_warden_policy_gate: v2
---
# Ops-Warden SSH Certificate Signing
This package authorizes ops-warden's opt-in pre-sign policy gate. The caller
keeps SSH CA custody, actor inventory, and OpenBao signing; flex-auth decides
whether a specific `sign` request is allowed now.
## Rules
```rego
import future.keywords.contains
import future.keywords.if
import future.keywords.in
actor_types := {"adm", "agt", "atm"}
decision := {"effect": "allow", "reason": "signing_policy_matched"} if {
allowed
} else := {"effect": "deny", "reason": first_denial} if {
true
}
allowed if {
input.action == "sign"
input.resource.system == "ops-warden"
input.resource.type == "ssh-certificate"
effective_tenant == "tenant:platform"
valid_actor_type
subject_type_matches_context
actor_type_matches_resource
resource_id_matches_actor
subject_id_allowed
valid_ttl
has_pubkey_fingerprint
principals_allowed
}
default effective_tenant := ""
effective_tenant := input.tenant if {
is_string(input.tenant)
input.tenant != ""
} else := input.resource.tenant if {
is_string(input.resource.tenant)
input.resource.tenant != ""
} else := input.subject.tenant if {
is_string(input.subject.tenant)
input.subject.tenant != ""
}
default first_denial := "no_matching_rule"
first_denial := "wrong_action" if {
input.action != "sign"
} else := "wrong_system" if {
input.resource.system != "ops-warden"
} else := "wrong_resource_type" if {
input.resource.type != "ssh-certificate"
} else := "wrong_tenant" if {
effective_tenant != "tenant:platform"
} else := "unknown_actor_resource" if {
not has_actor_resource
} else := "unknown_subject" if {
not subject_id_allowed
} else := "actor_type_mismatch" if {
not valid_actor_type
} else := "actor_type_mismatch" if {
not subject_type_matches_context
} else := "actor_type_mismatch" if {
not actor_type_matches_resource
} else := "actor_resource_mismatch" if {
not resource_id_matches_actor
} else := "ttl_out_of_bounds" if {
not valid_ttl
} else := "missing_pubkey_fingerprint" if {
not has_pubkey_fingerprint
} else := "missing_principal" if {
not has_principals
} else := "disallowed_principal" if {
count(disallowed_principals) > 0
}
has_actor_resource if {
is_string(input.resource.attributes.actor_id)
input.resource.attributes.actor_id != ""
}
valid_actor_type if {
is_string(input.context.actor_type)
input.context.actor_type in actor_types
}
subject_type_matches_context if {
input.subject.type == input.context.actor_type
}
subject_type_matches_context if {
input.subject.attributes.actor_type == input.context.actor_type
}
actor_type_matches_resource if {
input.context.actor_type == input.resource.attributes.actor_type
}
resource_id_matches_actor if {
input.resource.id == sprintf("ssh-cert:actor/%s", [input.resource.attributes.actor_id])
}
subject_id_allowed if {
input.subject.id in input.resource.attributes.allowed_subjects
}
has_ttl if {
is_number(input.context.ttl_hours)
}
valid_ttl if {
has_ttl
input.context.ttl_hours > 0
input.context.ttl_hours <= input.resource.attributes.max_ttl_hours
}
has_pubkey_fingerprint if {
is_string(input.context.pubkey_fingerprint)
input.context.pubkey_fingerprint != ""
}
has_principals if {
count(input.context.principals) > 0
}
principals_allowed if {
has_principals
count(disallowed_principals) == 0
}
allowed_principal(principal) if {
principal in input.resource.attributes.allowed_principals
}
disallowed_principals contains principal if {
principal := input.context.principals[_]
not allowed_principal(principal)
}
```
## Tests
```rego test
package flexauth.ops_warden.ssh_signing_test
import future.keywords.if
import data.flexauth.ops_warden.ssh_signing
adm_request := {
"id": "check:ops-warden-platform-steward-adm",
"tenant": "tenant:platform",
"subject": {"id": "platform-steward", "type": "adm"},
"action": "sign",
"resource": {
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"system": "ops-warden",
"attributes": {
"actor_id": "platform-steward",
"actor_type": "adm",
"allowed_subjects": ["platform-steward", "iam:platform-steward"],
"allowed_principals": ["platform", "root"],
"max_ttl_hours": 8
}
},
"context": {
"actor_type": "adm",
"principals": ["platform"],
"pubkey_fingerprint": "SHA256:example-adm-fingerprint",
"ttl_hours": 4
}
}
test_adm_sign_allowed if {
ssh_signing.decision.effect == "allow" with input as adm_request
}
test_high_ttl_denied if {
ssh_signing.decision.reason == "ttl_out_of_bounds" with input as {
"tenant": "tenant:platform",
"subject": {"id": "platform-steward", "type": "adm"},
"action": "sign",
"resource": adm_request.resource,
"context": {
"actor_type": "adm",
"principals": ["platform"],
"pubkey_fingerprint": "SHA256:example-adm-fingerprint",
"ttl_hours": 12
}
}
}
test_missing_fingerprint_denied if {
ssh_signing.decision.reason == "missing_pubkey_fingerprint" with input as {
"tenant": "tenant:platform",
"subject": {"id": "platform-steward", "type": "adm"},
"action": "sign",
"resource": adm_request.resource,
"context": {
"actor_type": "adm",
"principals": ["platform"],
"ttl_hours": 4
}
}
}
```

View File

@@ -0,0 +1,450 @@
{
"systems": [
{
"id": "ops-warden",
"name": "Ops Warden",
"resource_types": [
{
"name": "ssh-certificate",
"scope_level": "Resource",
"planes": [
"Identity",
"Secret",
"Audit"
],
"metadata": {
"description": "Short-lived SSH certificate signing request."
}
}
],
"actions": [
{
"name": "sign",
"capabilities": [
"Use",
"Operate",
"Audit"
],
"planes": [
"Identity",
"Secret",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"metadata": {
"required_context": [
"principals",
"actor_type",
"pubkey_fingerprint",
"ttl_hours"
]
}
}
],
"caring_profiles": [
"caring-0.4.0-rc2"
],
"metadata": {
"flex_auth_contract": "protected-system-v0",
"ops_warden_policy_gate": "v2",
"policy_enabled_config": "policy.enabled",
"tenant": "tenant:platform"
}
}
],
"resource_manifests": [
{
"id": "ops-warden-ssh-certificates",
"system": "ops-warden",
"resources": [
{
"id": "ssh-cert:actor/adm-example",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"adm"
],
"trust_zone": "platform",
"owner": "team:platform-security",
"attributes": {
"actor_id": "adm-example",
"actor_type": "adm",
"allowed_subjects": [
"adm-example",
"iam:adm-example"
],
"allowed_principals": [
"adm-full"
],
"max_ttl_hours": 48
}
},
{
"id": "ssh-cert:actor/agt-codex-interhub-bootstrap",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"agt"
],
"trust_zone": "platform",
"owner": "team:platform-security",
"attributes": {
"actor_id": "agt-codex-interhub-bootstrap",
"actor_type": "agt",
"allowed_subjects": [
"agt-codex-interhub-bootstrap",
"iam:agt-codex-interhub-bootstrap"
],
"allowed_principals": [
"agt-interhub-bootstrap"
],
"max_ttl_hours": 2
}
},
{
"id": "ssh-cert:actor/agt-state-hub-bridge",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"agt"
],
"trust_zone": "platform",
"owner": "team:platform-security",
"attributes": {
"actor_id": "agt-state-hub-bridge",
"actor_type": "agt",
"allowed_subjects": [
"agt-state-hub-bridge",
"iam:agt-state-hub-bridge"
],
"allowed_principals": [
"agt-task-bridge"
],
"max_ttl_hours": 24
}
},
{
"id": "ssh-cert:actor/atm-backup-daily",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"atm"
],
"trust_zone": "platform",
"owner": "team:platform-security",
"attributes": {
"actor_id": "atm-backup-daily",
"actor_type": "atm",
"allowed_subjects": [
"atm-backup-daily",
"iam:atm-backup-daily"
],
"allowed_principals": [
"atm-backup-daily"
],
"max_ttl_hours": 8
}
}
],
"actions": [
"sign"
],
"caring_profile": "caring-0.4.0-rc2",
"metadata": {
"flex_auth_contract": "resource-registration-v0",
"tenant": "tenant:platform"
}
}
],
"tenants": [
{
"id": "tenant:platform",
"name": "Platform Tenant"
}
],
"subjects": [
{
"id": "adm-example",
"type": "Agent",
"display_name": "Example human operator \u2014 replace with per-person adm-* actors",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-admins"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "adm"
}
},
{
"id": "agt-codex-interhub-bootstrap",
"type": "Agent",
"display_name": "Short-lived agent access for attended Inter-Hub bootstrap",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-agents"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "agt"
}
},
{
"id": "agt-state-hub-bridge",
"type": "Agent",
"display_name": "ops-bridge tunnel agent for state-hub",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-agents"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "agt"
}
},
{
"id": "atm-backup-daily",
"type": "Automation",
"display_name": "Example nightly automation actor",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-automations"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "atm"
}
}
],
"groups": [
{
"id": "group:ops-warden-admins",
"display_name": "Ops Warden Admins",
"members": [
"adm-example"
],
"tenant": "tenant:platform"
},
{
"id": "group:ops-warden-agents",
"display_name": "Ops Warden Agents",
"members": [
"agt-codex-interhub-bootstrap",
"agt-state-hub-bridge"
],
"tenant": "tenant:platform"
},
{
"id": "group:ops-warden-automations",
"display_name": "Ops Warden Automations",
"members": [
"atm-backup-daily"
],
"tenant": "tenant:platform"
}
],
"relationships": [
{
"id": "rel:adm-example-sign-adm-example",
"system": "ops-warden",
"subject": "group:ops-warden-admins",
"relation": "signer",
"object": "ssh-cert:actor/adm-example",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-adm-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/adm-example",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/adm-example"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
},
{
"id": "rel:agt-codex-interhub-bootstrap-sign-agt-codex-interhub-bootstrap",
"system": "ops-warden",
"subject": "group:ops-warden-agents",
"relation": "signer",
"object": "ssh-cert:actor/agt-codex-interhub-bootstrap",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-agt-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/agt-codex-interhub-bootstrap",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/agt-codex-interhub-bootstrap"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
},
{
"id": "rel:agt-state-hub-bridge-sign-agt-state-hub-bridge",
"system": "ops-warden",
"subject": "group:ops-warden-agents",
"relation": "signer",
"object": "ssh-cert:actor/agt-state-hub-bridge",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-agt-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/agt-state-hub-bridge",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/agt-state-hub-bridge"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
},
{
"id": "rel:atm-backup-daily-sign-atm-backup-daily",
"system": "ops-warden",
"subject": "group:ops-warden-automations",
"relation": "signer",
"object": "ssh-cert:actor/atm-backup-daily",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-atm-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/atm-backup-daily",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/atm-backup-daily"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
}
]
}

View File

@@ -0,0 +1,36 @@
id: ops-warden
name: Ops Warden
resource_types:
- name: ssh-certificate
scope_level: Resource
planes:
- Identity
- Secret
- Audit
metadata:
description: Short-lived SSH certificate signing request.
actions:
- name: sign
capabilities:
- Use
- Operate
- Audit
planes:
- Identity
- Secret
- Audit
exposure_modes:
- Metadata
metadata:
required_context:
- principals
- actor_type
- pubkey_fingerprint
- ttl_hours
caring_profiles:
- caring-0.4.0-rc2
metadata:
flex_auth_contract: protected-system-v0
ops_warden_policy_gate: v2
policy_enabled_config: policy.enabled
tenant: tenant:platform

View File

@@ -0,0 +1,366 @@
{
"systems": [
{
"id": "ops-warden",
"name": "Ops Warden",
"resource_types": [
{
"name": "ssh-certificate",
"scope_level": "Resource",
"planes": [
"Identity",
"Secret",
"Audit"
],
"metadata": {
"description": "Short-lived SSH certificate signing request."
}
}
],
"actions": [
{
"name": "sign",
"capabilities": [
"Use",
"Operate",
"Audit"
],
"planes": [
"Identity",
"Secret",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"metadata": {
"required_context": [
"principals",
"actor_type",
"pubkey_fingerprint",
"ttl_hours"
]
}
}
],
"caring_profiles": [
"caring-0.4.0-rc2"
],
"metadata": {
"flex_auth_contract": "protected-system-v0",
"ops_warden_policy_gate": "v2",
"policy_enabled_config": "policy.enabled",
"tenant": "tenant:platform"
}
}
],
"resource_manifests": [
{
"id": "ops-warden-ssh-certificates",
"system": "ops-warden",
"resources": [
{
"id": "ssh-cert:actor/platform-steward",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"adm"
],
"trust_zone": "platform",
"owner": "team:platform-security",
"attributes": {
"actor_id": "platform-steward",
"actor_type": "adm",
"allowed_subjects": [
"platform-steward",
"iam:platform-steward"
],
"allowed_principals": [
"platform",
"root"
],
"max_ttl_hours": 8
}
},
{
"id": "ssh-cert:actor/ci-deploy-agent",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"agt"
],
"trust_zone": "platform",
"owner": "team:platform-security",
"attributes": {
"actor_id": "ci-deploy-agent",
"actor_type": "agt",
"allowed_subjects": [
"ci-deploy-agent",
"iam:ci-deploy-agent"
],
"allowed_principals": [
"deploy",
"git"
],
"max_ttl_hours": 2
}
},
{
"id": "ssh-cert:actor/backup-automation",
"type": "ssh-certificate",
"labels": [
"ssh-signing",
"atm"
],
"trust_zone": "platform",
"owner": "team:platform-security",
"attributes": {
"actor_id": "backup-automation",
"actor_type": "atm",
"allowed_subjects": [
"backup-automation",
"iam:backup-automation"
],
"allowed_principals": [
"backup"
],
"max_ttl_hours": 1
}
}
],
"actions": [
"sign"
],
"caring_profile": "caring-0.4.0-rc2",
"metadata": {
"flex_auth_contract": "resource-registration-v0",
"tenant": "tenant:platform"
}
}
],
"tenants": [
{
"id": "tenant:platform",
"name": "Platform Tenant"
}
],
"subjects": [
{
"id": "platform-steward",
"type": "Agent",
"display_name": "Platform Steward",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-admins"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "adm"
}
},
{
"id": "ci-deploy-agent",
"type": "Agent",
"display_name": "CI Deploy Agent",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-agents"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "agt"
}
},
{
"id": "backup-automation",
"type": "Automation",
"display_name": "Backup Automation",
"organization_relation": "ServiceProvider",
"roles": [
"Operator"
],
"groups": [
"group:ops-warden-automations"
],
"tenant": "tenant:platform",
"metadata": {
"actor_type": "atm"
}
}
],
"groups": [
{
"id": "group:ops-warden-admins",
"display_name": "Ops Warden Admin Actors",
"members": [
"platform-steward"
],
"tenant": "tenant:platform"
},
{
"id": "group:ops-warden-agents",
"display_name": "Ops Warden Agent Actors",
"members": [
"ci-deploy-agent"
],
"tenant": "tenant:platform"
},
{
"id": "group:ops-warden-automations",
"display_name": "Ops Warden Automation Actors",
"members": [
"backup-automation"
],
"tenant": "tenant:platform"
}
],
"relationships": [
{
"id": "rel:platform-steward-sign-platform-steward",
"system": "ops-warden",
"subject": "group:ops-warden-admins",
"relation": "signer",
"object": "ssh-cert:actor/platform-steward",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-adm-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/platform-steward",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/platform-steward"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
},
{
"id": "rel:ci-deploy-agent-sign-ci-deploy-agent",
"system": "ops-warden",
"subject": "group:ops-warden-agents",
"relation": "signer",
"object": "ssh-cert:actor/ci-deploy-agent",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-agt-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/ci-deploy-agent",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/ci-deploy-agent"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
},
{
"id": "rel:backup-automation-sign-backup-automation",
"system": "ops-warden",
"subject": "group:ops-warden-automations",
"relation": "signer",
"object": "ssh-cert:actor/backup-automation",
"tenant": "tenant:platform",
"conditions": [
"TimeLimited",
"Logged"
],
"caring": {
"id": "descriptor:ops-warden-atm-signer",
"profile": "caring-0.4.0-rc2",
"subject_type": "Group",
"organization_relation": "ServiceProvider",
"canonical_role": "Operator",
"scope": {
"level": "Resource",
"id": "ssh-cert:actor/backup-automation",
"tenant": "tenant:platform",
"resource": "ssh-cert:actor/backup-automation"
},
"planes": [
"Identity",
"Secret",
"Audit"
],
"capabilities": [
"Use",
"Operate",
"Audit"
],
"exposure_modes": [
"Metadata"
],
"conditions": [
"TimeLimited",
"Logged"
],
"restrictions": [
"PrivilegeEscalationBlocked",
"SecretAccessBlocked"
],
"access_path": "mediated"
}
}
]
}

View File

@@ -0,0 +1,59 @@
id: ops-warden-ssh-certificates
system: ops-warden
resources:
- id: ssh-cert:actor/platform-steward
type: ssh-certificate
labels:
- ssh-signing
- adm
trust_zone: platform
owner: team:platform-security
attributes:
actor_id: platform-steward
actor_type: adm
allowed_subjects:
- platform-steward
- iam:platform-steward
allowed_principals:
- platform
- root
max_ttl_hours: 8
- id: ssh-cert:actor/ci-deploy-agent
type: ssh-certificate
labels:
- ssh-signing
- agt
trust_zone: platform
owner: team:platform-security
attributes:
actor_id: ci-deploy-agent
actor_type: agt
allowed_subjects:
- ci-deploy-agent
- iam:ci-deploy-agent
allowed_principals:
- deploy
- git
max_ttl_hours: 2
- id: ssh-cert:actor/backup-automation
type: ssh-certificate
labels:
- ssh-signing
- atm
trust_zone: platform
owner: team:platform-security
attributes:
actor_id: backup-automation
actor_type: atm
allowed_subjects:
- backup-automation
- iam:backup-automation
allowed_principals:
- backup
max_ttl_hours: 1
actions:
- sign
caring_profile: caring-0.4.0-rc2
metadata:
flex_auth_contract: resource-registration-v0
tenant: tenant:platform

View File

@@ -0,0 +1,54 @@
id: subjects:ops-warden-platform
tenants:
- id: tenant:platform
name: Platform Tenant
subjects:
- id: platform-steward
type: Agent
display_name: Platform Steward
organization_relation: ServiceProvider
roles:
- Operator
groups:
- group:ops-warden-admins
tenant: tenant:platform
metadata:
actor_type: adm
- id: ci-deploy-agent
type: Agent
display_name: CI Deploy Agent
organization_relation: ServiceProvider
roles:
- Operator
groups:
- group:ops-warden-agents
tenant: tenant:platform
metadata:
actor_type: agt
- id: backup-automation
type: Automation
display_name: Backup Automation
organization_relation: ServiceProvider
roles:
- Operator
groups:
- group:ops-warden-automations
tenant: tenant:platform
metadata:
actor_type: atm
groups:
- id: group:ops-warden-admins
display_name: Ops Warden Admin Actors
members:
- platform-steward
tenant: tenant:platform
- id: group:ops-warden-agents
display_name: Ops Warden Agent Actors
members:
- ci-deploy-agent
tenant: tenant:platform
- id: group:ops-warden-automations
display_name: Ops Warden Automation Actors
members:
- backup-automation
tenant: tenant:platform

View File

@@ -30,8 +30,9 @@ docker compose down -v
## What the example proves
- Topaz's v3 manifest can express flex-auth's canonical object types
(`user`, `group`, `tenant`, `knowledge_base`, `document`) and
relations (`member`, `parent`, `owner_team`, `reader`, `steward`).
(`user`, `identity`, `group`, `tenant`, `knowledge_base`, `document`)
and relations (`identifier`, `member`, `parent`, `owner_team`,
`reader`, `steward`).
- The Markitect fixture data
(`examples/markitect/resource_manifest.yaml`, mirrored here) seeds
the directory without translation.

View File

@@ -4,8 +4,11 @@
{"type": "group", "id": "team:platform-architecture", "display_name": "Platform Architecture"},
{"type": "group", "id": "reader:platform-architecture", "display_name": "Platform Architecture Readers"},
{"type": "user", "id": "alice@example.test", "display_name": "Alice (steward)"},
{"type": "identity", "id": "identity:alice@example.test", "properties": {"identifier": "alice@example.test", "subject": "alice@example.test"}},
{"type": "user", "id": "bob@example.test", "display_name": "Bob (reader)"},
{"type": "identity", "id": "identity:bob@example.test", "properties": {"identifier": "bob@example.test", "subject": "bob@example.test"}},
{"type": "user", "id": "eve@example.test", "display_name": "Eve (outsider)"},
{"type": "identity", "id": "identity:eve@example.test", "properties": {"identifier": "eve@example.test", "subject": "eve@example.test"}},
{
"type": "knowledge_base",
"id": "knowledge-base:markitect-example",

View File

@@ -2,6 +2,9 @@
"relations": [
{"object_type": "group", "object_id": "team:platform-architecture", "relation": "member", "subject_type": "user", "subject_id": "alice@example.test"},
{"object_type": "group", "object_id": "reader:platform-architecture", "relation": "member", "subject_type": "user", "subject_id": "bob@example.test"},
{"object_type": "identity", "object_id": "identity:alice@example.test", "relation": "identifier", "subject_type": "user", "subject_id": "alice@example.test"},
{"object_type": "identity", "object_id": "identity:bob@example.test", "relation": "identifier", "subject_type": "user", "subject_id": "bob@example.test"},
{"object_type": "identity", "object_id": "identity:eve@example.test", "relation": "identifier", "subject_type": "user", "subject_id": "eve@example.test"},
{"object_type": "knowledge_base", "object_id": "knowledge-base:markitect-example", "relation": "owner_team", "subject_type": "group", "subject_id": "team:platform-architecture"},
{"object_type": "document", "object_id": "document:internal-note", "relation": "parent", "subject_type": "knowledge_base", "subject_id": "knowledge-base:markitect-example"},
{"object_type": "document", "object_id": "document:internal-note", "relation": "steward", "subject_type": "user", "subject_id": "alice@example.test"},

View File

@@ -17,6 +17,10 @@ types:
relations:
manager: user
identity:
relations:
identifier: user
group:
relations:
member: user | group#member

38
go.mod
View File

@@ -2,4 +2,40 @@ module github.com/netkingdom/flex-auth
go 1.22
require gopkg.in/yaml.v3 v3.0.1
require (
github.com/open-policy-agent/opa v0.70.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/OneOfOne/xxhash v1.2.8 // indirect
github.com/agnivade/levenshtein v1.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/tchap/go-patricia/v2 v2.3.1 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/yashtewari/glob-intersection v0.2.0 // indirect
go.opentelemetry.io/otel v1.28.0 // indirect
go.opentelemetry.io/otel/metric v1.28.0 // indirect
go.opentelemetry.io/otel/sdk v1.28.0 // indirect
go.opentelemetry.io/otel/trace v1.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
sigs.k8s.io/yaml v1.4.0 // indirect
)

152
go.sum
View File

@@ -1,4 +1,154 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
github.com/agnivade/levenshtein v1.2.0 h1:U9L4IOT0Y3i0TIlUIDJ7rVUziKi/zPbrJGaFrtYH3SY=
github.com/agnivade/levenshtein v1.2.0/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2 h1:3uZCA/BLTIu+DqCfguByNMJa2HVHpXvjfy0Dy7g6fuA=
github.com/bytecodealliance/wasmtime-go/v3 v3.0.2/go.mod h1:RnUjnIXxEJcL6BgCvNyzCCRzZcxCgsZCi+RNlvYor5Q=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v3 v3.2103.5 h1:ylPa6qzbjYRQMU6jokoj4wzcaweHylt//CH0AKt0akg=
github.com/dgraph-io/badger/v3 v3.2103.5/go.mod h1:4MPiseMeDQ3FNCYwRbbcBOGJLf5jsE0PPFzRiKjtcdw=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v1.2.2 h1:1+mZ9upx1Dh6FmUTFR1naJ77miKiXgALjWOZ3NVFPmY=
github.com/golang/glog v1.2.2/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw=
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/open-policy-agent/opa v0.70.0 h1:B3cqCN2iQAyKxK6+GI+N40uqkin+wzIrM7YA60t9x1U=
github.com/open-policy-agent/opa v0.70.0/go.mod h1:Y/nm5NY0BX0BqjBriKUiV81sCl8XOjjvqQG7dXrggtI=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 h1:MkV+77GLUNo5oJ0jf870itWm3D0Sjh7+Za9gazKc5LQ=
github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg=
github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 h1:4K4tsIXefpVJtvA/8srF4V4y0akAoPHkIslgAkjixJA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo=
go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0 h1:3Q/xZUyC1BBkualc9ROb4G8qkH90LXEIICcs5zv1OYY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0 h1:R3X6ZXmNPRR8ul6i3WgFURCHzaXjHdm0karRG/+dj3s=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.28.0/go.mod h1:QWFXnDavXWwMx2EEcZsf3yxgEKAqsxQ+Syjp+seyInw=
go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q=
go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s=
go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE=
go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg=
go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g=
go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142 h1:wKguEg1hsxI2/L3hUYrpo1RVi48K+uTyzKqprwLXsb8=
google.golang.org/genproto/googleapis/api v0.0.0-20240814211410-ddb44dafa142/go.mod h1:d6be+8HhtEtucleCbxpPW9PA9XwISACu8nvpPqF0BVo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View File

@@ -0,0 +1,3 @@
// Package directory defines external group resolver adapters for Graph, SCIM,
// LDAP/AD, and Keycloak directory sources.
package directory

View File

@@ -0,0 +1,96 @@
package directory
import (
"sort"
"github.com/netkingdom/flex-auth/pkg/api"
)
// MergeResults combines resolver results while keeping source metadata.
func MergeResults(subject api.SubjectRef, results ...ResolveResult) SubjectEnrichment {
groups := map[string]struct{}{}
roles := map[api.CanonicalRole]struct{}{}
metadata := map[string]any{}
enrichment := SubjectEnrichment{
Subject: subject,
Metadata: metadata,
}
for _, result := range results {
for _, group := range result.Groups {
groups[group.ID] = struct{}{}
if group.Descriptor != nil {
enrichment.Descriptors = append(enrichment.Descriptors, *group.Descriptor)
}
}
for _, role := range result.Roles {
roles[role.Role] = struct{}{}
}
if !result.Freshness.RetrievedAt.IsZero() || result.Freshness.Source != "" {
enrichment.Freshness = append(enrichment.Freshness, result.Freshness)
}
if result.Overage.Detected {
enrichment.Overage = append(enrichment.Overage, result.Overage)
}
if result.Source != "" {
metadata["source:"+string(result.Source)] = true
}
}
enrichment.Groups = sortedStringKeys(groups)
enrichment.Roles = sortedRoleKeys(roles)
return enrichment
}
// ApplyToSubject returns a subject with resolved groups, roles, and metadata.
func ApplyToSubject(subject api.Subject, enrichment SubjectEnrichment) api.Subject {
out := subject
out.Groups = mergeStrings(out.Groups, enrichment.Groups)
out.Roles = mergeRoles(out.Roles, enrichment.Roles)
if out.Metadata == nil {
out.Metadata = map[string]any{}
}
out.Metadata["directory_freshness"] = enrichment.Freshness
out.Metadata["directory_overage"] = enrichment.Overage
return out
}
func mergeStrings(a, b []string) []string {
items := map[string]struct{}{}
for _, value := range a {
items[value] = struct{}{}
}
for _, value := range b {
items[value] = struct{}{}
}
return sortedStringKeys(items)
}
func mergeRoles(a, b []api.CanonicalRole) []api.CanonicalRole {
items := map[api.CanonicalRole]struct{}{}
for _, value := range a {
items[value] = struct{}{}
}
for _, value := range b {
items[value] = struct{}{}
}
return sortedRoleKeys(items)
}
func sortedStringKeys(items map[string]struct{}) []string {
keys := make([]string, 0, len(items))
for key := range items {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedRoleKeys(items map[api.CanonicalRole]struct{}) []api.CanonicalRole {
keys := make([]api.CanonicalRole, 0, len(items))
for key := range items {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] })
return keys
}

View File

@@ -0,0 +1,200 @@
package directory
import (
"context"
"fmt"
"time"
"github.com/netkingdom/flex-auth/pkg/api"
)
// GraphClient is the Microsoft Graph group lookup boundary.
type GraphClient interface {
GetMemberGroups(context.Context, string) ([]ExternalGroup, OverageMetadata, error)
}
// SCIMClient is the SCIM group lookup boundary.
type SCIMClient interface {
GroupsForUser(context.Context, string) ([]ExternalGroup, error)
}
// LDAPClient is the LDAP/AD group lookup boundary.
type LDAPClient interface {
GroupsForDN(context.Context, string) ([]ExternalGroup, error)
}
// KeycloakClient is the Keycloak admin API group lookup boundary.
type KeycloakClient interface {
GroupsForUser(context.Context, string) ([]ExternalGroup, error)
}
// ExternalGroup is a provider-native group normalized enough for flex-auth.
type ExternalGroup struct {
ID string
DisplayName string
Descriptor *api.CaringAccessDescriptor
Metadata map[string]any
}
// GraphResolver resolves Microsoft Graph groups, including token overage.
type GraphResolver struct {
Client GraphClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r GraphResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("graph client is required")
}
now := resolveNow(request)
groups, overage, err := r.Client.GetMemberGroups(ctx, request.Subject.ID)
if err != nil {
return ResolveResult{}, err
}
if tokenIndicatesGraphOverage(request.Claims) {
overage.Detected = true
if overage.Claim == "" {
overage.Claim = "_claim_names.groups"
}
}
return ResolveResult{
Subject: request.Subject,
Source: SourceGraph,
Groups: grantsFromExternalGroups(SourceGraph, groups, r.OrganizationRelation, "_claim_names.groups"),
Freshness: freshness(SourceGraph, now, r.MaxAge),
Overage: overage,
}, nil
}
// SCIMResolver resolves provisioned SCIM groups.
type SCIMResolver struct {
Client SCIMClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r SCIMResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("scim client is required")
}
groups, err := r.Client.GroupsForUser(ctx, request.Subject.ID)
if err != nil {
return ResolveResult{}, err
}
return ResolveResult{
Subject: request.Subject,
Source: SourceSCIM,
Groups: grantsFromExternalGroups(SourceSCIM, groups, r.OrganizationRelation, "groups"),
Freshness: freshness(SourceSCIM, resolveNow(request), r.MaxAge),
}, nil
}
// LDAPResolver resolves LDAP/AD group memberships.
type LDAPResolver struct {
Client LDAPClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r LDAPResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("ldap client is required")
}
dn := stringClaim(request.Claims, "distinguished_name")
if dn == "" {
dn = request.Subject.ID
}
groups, err := r.Client.GroupsForDN(ctx, dn)
if err != nil {
return ResolveResult{}, err
}
return ResolveResult{
Subject: request.Subject,
Source: SourceLDAP,
Groups: grantsFromExternalGroups(SourceLDAP, groups, r.OrganizationRelation, "memberOf"),
Freshness: freshness(SourceLDAP, resolveNow(request), r.MaxAge),
}, nil
}
// KeycloakResolver resolves groups through the Keycloak admin API.
type KeycloakResolver struct {
Client KeycloakClient
OrganizationRelation api.OrganizationRelation
MaxAge time.Duration
}
func (r KeycloakResolver) ResolveGroups(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
if r.Client == nil {
return ResolveResult{}, fmt.Errorf("keycloak client is required")
}
groups, err := r.Client.GroupsForUser(ctx, request.Subject.ID)
if err != nil {
return ResolveResult{}, err
}
return ResolveResult{
Subject: request.Subject,
Source: SourceKeycloak,
Groups: grantsFromExternalGroups(SourceKeycloak, groups, r.OrganizationRelation, "groups"),
Freshness: freshness(SourceKeycloak, resolveNow(request), r.MaxAge),
}, nil
}
func grantsFromExternalGroups(source Source, groups []ExternalGroup, relation api.OrganizationRelation, claim string) []GroupGrant {
grants := make([]GroupGrant, 0, len(groups))
for _, group := range groups {
grants = append(grants, GroupGrant{
ID: group.ID,
DisplayName: group.DisplayName,
Source: source,
OrganizationRelation: relation,
SubjectType: api.SubjectTypeGroup,
Claim: claim,
Descriptor: group.Descriptor,
Metadata: copyMap(group.Metadata),
})
}
return grants
}
func freshness(source Source, now time.Time, maxAge time.Duration) Freshness {
out := Freshness{Source: source, RetrievedAt: now}
if maxAge > 0 {
out.MaxAge = maxAge.String()
out.ExpiresAt = now.Add(maxAge)
out.Stale = now.After(out.ExpiresAt)
}
return out
}
func resolveNow(request ResolveRequest) time.Time {
if !request.Now.IsZero() {
return request.Now
}
return time.Now().UTC()
}
func tokenIndicatesGraphOverage(claims map[string]any) bool {
if value, ok := claims["hasgroups"].(bool); ok && value {
return true
}
claimNames, ok := claims["_claim_names"].(map[string]any)
if !ok {
return false
}
_, ok = claimNames["groups"]
return ok
}
func stringClaim(claims map[string]any, key string) string {
value, _ := claims[key].(string)
return value
}
func copyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,151 @@
package directory_test
import (
"context"
"testing"
"time"
"github.com/netkingdom/flex-auth/internal/adapters/directory"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestGraphResolverDetectsTokenOverage(t *testing.T) {
now := time.Date(2026, 5, 17, 5, 0, 0, 0, time.UTC)
resolver := directory.GraphResolver{
Client: graphClient{groups: []directory.ExternalGroup{{ID: "group:platform", DisplayName: "Platform"}}},
OrganizationRelation: api.OrganizationRelationCustomer,
MaxAge: time.Hour,
}
got, err := resolver.ResolveGroups(context.Background(), directory.ResolveRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Claims: map[string]any{
"_claim_names": map[string]any{"groups": "src1"},
},
Now: now,
})
if err != nil {
t.Fatalf("ResolveGroups: %v", err)
}
if !got.Overage.Detected || got.Overage.Claim != "_claim_names.groups" {
t.Fatalf("overage = %+v", got.Overage)
}
if len(got.Groups) != 1 || got.Groups[0].Source != directory.SourceGraph {
t.Fatalf("groups = %+v", got.Groups)
}
if got.Freshness.ExpiresAt != now.Add(time.Hour) {
t.Fatalf("freshness = %+v", got.Freshness)
}
}
func TestSCIMResolverCarriesDescriptorProvenance(t *testing.T) {
descriptor := caringDescriptor()
resolver := directory.SCIMResolver{
Client: scimClient{groups: []directory.ExternalGroup{{ID: "group:customers", Descriptor: descriptor}}},
OrganizationRelation: api.OrganizationRelationCustomer,
}
got, err := resolver.ResolveGroups(context.Background(), directory.ResolveRequest{Subject: api.SubjectRef{ID: "user:alice"}})
if err != nil {
t.Fatalf("ResolveGroups: %v", err)
}
if got.Groups[0].Descriptor == nil || got.Groups[0].Descriptor.CanonicalRole != api.CanonicalRoleDoer {
t.Fatalf("descriptor = %+v", got.Groups[0].Descriptor)
}
if got.Groups[0].Claim != "groups" {
t.Fatalf("claim = %q", got.Groups[0].Claim)
}
}
func TestLDAPResolverUsesDistinguishedNameClaim(t *testing.T) {
client := ldapClient{groups: []directory.ExternalGroup{{ID: "cn=platform,ou=groups,dc=example,dc=test"}}}
resolver := directory.LDAPResolver{Client: client}
_, err := resolver.ResolveGroups(context.Background(), directory.ResolveRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Claims: map[string]any{"distinguished_name": "cn=alice,ou=users,dc=example,dc=test"},
})
if err != nil {
t.Fatalf("ResolveGroups: %v", err)
}
if client.lastDN != "" {
t.Fatal("value receiver should not update original client")
}
}
func TestMergeResultsAndApplyToSubject(t *testing.T) {
subject := api.SubjectRef{ID: "user:alice"}
enrichment := directory.MergeResults(subject,
directory.ResolveResult{
Source: directory.SourceGraph,
Groups: []directory.GroupGrant{
{ID: "group:b", Source: directory.SourceGraph},
{ID: "group:a", Source: directory.SourceGraph, Descriptor: caringDescriptor()},
},
Overage: directory.OverageMetadata{Detected: true, Claim: "_claim_names.groups"},
},
directory.ResolveResult{
Source: directory.SourceKeycloak,
Groups: []directory.GroupGrant{
{ID: "group:a", Source: directory.SourceKeycloak},
},
Roles: []directory.RoleGrant{
{Role: api.CanonicalRoleDoer, Source: directory.SourceKeycloak},
},
},
)
if len(enrichment.Groups) != 2 || enrichment.Groups[0] != "group:a" || enrichment.Groups[1] != "group:b" {
t.Fatalf("groups = %+v", enrichment.Groups)
}
if len(enrichment.Roles) != 1 || enrichment.Roles[0] != api.CanonicalRoleDoer {
t.Fatalf("roles = %+v", enrichment.Roles)
}
if len(enrichment.Descriptors) != 1 || len(enrichment.Overage) != 1 {
t.Fatalf("enrichment = %+v", enrichment)
}
applied := directory.ApplyToSubject(api.Subject{ID: "user:alice", Groups: []string{"group:existing"}}, enrichment)
if len(applied.Groups) != 3 || applied.Metadata["directory_overage"] == nil {
t.Fatalf("applied subject = %+v", applied)
}
}
func caringDescriptor() *api.CaringAccessDescriptor {
return &api.CaringAccessDescriptor{
ID: "descriptor:directory",
Profile: api.CaringProfileCaring040RC2,
SubjectType: api.SubjectTypeGroup,
OrganizationRelation: api.OrganizationRelationCustomer,
CanonicalRole: api.CanonicalRoleDoer,
Scope: api.CaringScope{Level: api.ScopeLevelTenant, ID: "tenant:alpha"},
Planes: []api.Plane{api.PlaneIdentity},
Capabilities: []api.Capability{api.CapabilityUse},
}
}
type graphClient struct {
groups []directory.ExternalGroup
}
func (c graphClient) GetMemberGroups(context.Context, string) ([]directory.ExternalGroup, directory.OverageMetadata, error) {
return c.groups, directory.OverageMetadata{Total: len(c.groups)}, nil
}
type scimClient struct {
groups []directory.ExternalGroup
}
func (c scimClient) GroupsForUser(context.Context, string) ([]directory.ExternalGroup, error) {
return c.groups, nil
}
type ldapClient struct {
groups []directory.ExternalGroup
lastDN string
}
func (c ldapClient) GroupsForDN(_ context.Context, dn string) ([]directory.ExternalGroup, error) {
c.lastDN = dn
return c.groups, nil
}

View File

@@ -0,0 +1,91 @@
package directory
import (
"context"
"time"
"github.com/netkingdom/flex-auth/pkg/api"
)
// Source identifies the external directory source.
type Source string
const (
SourceGraph Source = "graph"
SourceSCIM Source = "scim"
SourceLDAP Source = "ldap"
SourceKeycloak Source = "keycloak"
)
// Resolver resolves external groups and role claims for one subject.
type Resolver interface {
ResolveGroups(context.Context, ResolveRequest) (ResolveResult, error)
}
// ResolveRequest carries subject and claim evidence into a resolver.
type ResolveRequest struct {
Subject api.SubjectRef `json:"subject"`
Claims map[string]any `json:"claims,omitempty"`
AccessToken string `json:"access_token,omitempty"`
Now time.Time `json:"now,omitempty"`
}
// ResolveResult is the normalized resolver response.
type ResolveResult struct {
Subject api.SubjectRef `json:"subject"`
Source Source `json:"source"`
Groups []GroupGrant `json:"groups,omitempty"`
Roles []RoleGrant `json:"roles,omitempty"`
Freshness Freshness `json:"freshness"`
Overage OverageMetadata `json:"overage,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// GroupGrant identifies an external group and its CARING provenance.
type GroupGrant struct {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
Source Source `json:"source"`
OrganizationRelation api.OrganizationRelation `json:"organization_relation,omitempty"`
SubjectType api.SubjectType `json:"subject_type,omitempty"`
Claim string `json:"claim,omitempty"`
Descriptor *api.CaringAccessDescriptor `json:"descriptor,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// RoleGrant records a role claim resolved from an external directory.
type RoleGrant struct {
Role api.CanonicalRole `json:"role"`
Claim string `json:"claim,omitempty"`
Source Source `json:"source"`
OrganizationRelation api.OrganizationRelation `json:"organization_relation,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// Freshness records resolver cache and retrieval metadata.
type Freshness struct {
Source Source `json:"source"`
RetrievedAt time.Time `json:"retrieved_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
MaxAge string `json:"max_age,omitempty"`
Stale bool `json:"stale,omitempty"`
}
// OverageMetadata records group overage behavior from token-based providers.
type OverageMetadata struct {
Detected bool `json:"detected,omitempty"`
Claim string `json:"claim,omitempty"`
Total int `json:"total,omitempty"`
NextLink string `json:"next_link,omitempty"`
}
// SubjectEnrichment is the subject update produced by resolver results.
type SubjectEnrichment struct {
Subject api.SubjectRef `json:"subject"`
Groups []string `json:"groups,omitempty"`
Roles []api.CanonicalRole `json:"roles,omitempty"`
Freshness []Freshness `json:"freshness,omitempty"`
Overage []OverageMetadata `json:"overage,omitempty"`
Descriptors []api.CaringAccessDescriptor `json:"descriptors,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}

View File

@@ -0,0 +1,306 @@
package keycloak
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"github.com/netkingdom/flex-auth/pkg/api"
)
// Adapter maps flex-auth checks and resources to Keycloak Authorization
// Services while preserving the flex-auth decision envelope.
type Adapter struct {
client Client
realm string
audience string
policyPackage string
policyVersion string
}
func New(client Client, options Options) (*Adapter, error) {
if client == nil {
return nil, fmt.Errorf("keycloak client is required")
}
if options.Realm == "" {
return nil, fmt.Errorf("keycloak realm is required")
}
if options.Audience == "" {
return nil, fmt.Errorf("keycloak audience is required")
}
return &Adapter{
client: client,
realm: options.Realm,
audience: options.Audience,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
}, nil
}
func (a *Adapter) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
authz, err := a.BuildAuthorizationRequest(request)
if err != nil {
return a.failureEnvelope(request, AuthorizationRequest{}, FailureInvalidRequest, err), nil
}
result, err := a.client.Authorize(ctx, authz)
if err != nil {
return a.failureEnvelope(request, authz, failureKind(err), err), nil
}
if result.StalePolicy {
return a.failureEnvelope(request, authz, FailureStalePolicy, nil), nil
}
if result.PartialResult {
return a.failureEnvelope(request, authz, FailurePartialResult, nil), nil
}
return a.envelope(request, authz, result), nil
}
func (a *Adapter) RegisterResourceManifest(ctx context.Context, manifest api.ResourceManifest) (ResourceImportReport, error) {
return a.client.RegisterResources(ctx, ResourceRegistrationsFromManifest(manifest))
}
func (a *Adapter) BuildAuthorizationRequest(request api.CheckRequest) (AuthorizationRequest, error) {
if request.Subject.ID == "" || request.Action == "" || request.Resource.ID == "" {
return AuthorizationRequest{}, fmt.Errorf("subject id, action, and resource id are required")
}
claims := map[string]any{
"subject": request.Subject,
"resource": request.Resource,
"action": request.Action,
"context": copyMap(request.Context),
}
if request.CaringContext != nil {
claims["caring_context"] = request.CaringContext
}
return AuthorizationRequest{
Realm: a.realm,
Audience: a.audience,
Subject: request.Subject,
Permission: Permission{
ResourceID: request.Resource.ID,
Scope: request.Action,
},
ClaimToken: claims,
}, nil
}
func ResourceRegistrationsFromManifest(manifest api.ResourceManifest) []ResourceRegistration {
registrations := make([]ResourceRegistration, 0, len(manifest.Resources))
for _, resource := range manifest.Resources {
attrs := map[string][]string{
"flex_auth_system": {manifest.System},
"flex_auth_type": {resource.Type},
}
if resource.TrustZone != "" {
attrs["trust_zone"] = []string{resource.TrustZone}
}
if resource.Parent != "" {
attrs["parent"] = []string{resource.Parent}
}
if resource.Owner != "" {
attrs["owner"] = []string{resource.Owner}
}
if len(resource.Labels) > 0 {
attrs["labels"] = append([]string(nil), resource.Labels...)
}
registrations = append(registrations, ResourceRegistration{
ID: resource.ID,
Name: resource.ID,
Type: resource.Type,
URI: resource.Path,
Scopes: append([]string(nil), manifest.Actions...),
Attributes: attrs,
})
}
return registrations
}
func (a *Adapter) envelope(request api.CheckRequest, authz AuthorizationRequest, result AuthorizationResult) api.DecisionEnvelope {
effect := api.DecisionEffectDeny
if result.Allowed {
effect = api.DecisionEffectAllow
}
reason := result.Reason
if reason == "" {
if result.Allowed {
reason = "keycloak_permission_granted"
} else {
reason = "keycloak_permission_denied"
}
}
policyVersion := firstNonEmpty(result.PolicyVersion, request.PolicyVersion, a.policyVersion)
diagnostics := copyMap(result.Diagnostics)
addDiagnostics(diagnostics, authz, "")
if result.RPTTokenID != "" {
diagnostics["rpt_token_id"] = result.RPTTokenID
}
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: effect,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: reason,
Resource: request.Resource,
Subject: request.Subject,
Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName,
Mode: DelegatedMode,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(firstDescriptor(request.CaringContext, result.CaringDescriptor), result.ConformanceFindings),
}
envelope.ID = decisionID(a.policyPackage, policyVersion, request, effect, reason)
return envelope
}
func (a *Adapter) failureEnvelope(request api.CheckRequest, authz AuthorizationRequest, kind FailureKind, err error) api.DecisionEnvelope {
reason := failureReason(kind)
policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion)
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: api.DecisionEffectDeny,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: reason,
Resource: request.Resource,
Subject: request.Subject,
Diagnostics: failureDiagnostics(authz, kind, err),
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName,
Mode: DelegatedMode,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}),
}
envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
return envelope
}
func caringDecisionMetadata(descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata {
profile := api.CaringProfileCaring040RC2
if descriptor != nil && descriptor.Profile != "" {
profile = descriptor.Profile
}
metadata := &api.CaringDecisionMetadata{
Profile: profile,
ConformanceFindings: append([]api.CaringConformanceFinding(nil), findings...),
}
if descriptor == nil {
metadata.ConformanceFindings = append(metadata.ConformanceFindings, api.CaringConformanceFinding{
Code: "KEYCLOAK-CARING-DESCRIPTOR-MISSING",
Severity: "warning",
Message: "Keycloak authorization result did not include a CARING descriptor",
Fields: []string{"caring_context"},
})
return metadata
}
descriptorCopy := *descriptor
metadata.Descriptor = &descriptorCopy
metadata.RestrictionsEvaluated = append([]api.Restriction(nil), descriptor.Restrictions...)
metadata.ExposureModes = append([]api.ExposureMode(nil), descriptor.ExposureModes...)
metadata.DerivedCapabilities = append([]api.CaringDerivedCapability(nil), descriptor.DerivedCapabilities...)
return metadata
}
func addDiagnostics(diagnostics map[string]any, request AuthorizationRequest, failure string) {
diagnostics["adapter"] = "keycloak"
diagnostics["mode"] = DelegatedMode
if request.Realm != "" {
diagnostics["realm"] = request.Realm
diagnostics["audience"] = request.Audience
diagnostics["permission"] = request.Permission.ResourceID + "#" + request.Permission.Scope
}
if failure != "" {
diagnostics["keycloak_failure"] = failure
}
}
func failureDiagnostics(request AuthorizationRequest, kind FailureKind, err error) map[string]any {
diagnostics := map[string]any{}
addDiagnostics(diagnostics, request, string(kind))
if err != nil {
diagnostics["error"] = err.Error()
}
return diagnostics
}
func failureReason(kind FailureKind) string {
switch kind {
case FailureStalePolicy:
return "keycloak_policy_stale"
case FailurePartialResult:
return "keycloak_partial_result"
case FailureInvalidRequest:
return "keycloak_request_incomplete"
default:
return "keycloak_unavailable"
}
}
func failureFinding(kind FailureKind) api.CaringConformanceFinding {
code := "KEYCLOAK-UNAVAILABLE"
message := "Keycloak Authorization Services was unavailable; flex-auth denied fail-closed."
switch kind {
case FailureStalePolicy:
code = "KEYCLOAK-POLICY-STALE"
message = "Keycloak policy state was stale; flex-auth denied fail-closed."
case FailurePartialResult:
code = "KEYCLOAK-PARTIAL-RESULT"
message = "Keycloak returned a partial result; flex-auth denied fail-closed."
case FailureInvalidRequest:
code = "KEYCLOAK-REQUEST-INCOMPLETE"
message = "Request could not be translated to a Keycloak UMA permission; flex-auth denied fail-closed."
}
return api.CaringConformanceFinding{Code: code, Severity: "error", Message: message}
}
func decisionID(policyPackage, policyVersion string, request api.CheckRequest, effect api.DecisionEffect, reason string) string {
data, _ := json.Marshal(struct {
Adapter string `json:"adapter"`
PolicyPackage string `json:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty"`
Request api.CheckRequest `json:"request"`
Effect api.DecisionEffect `json:"effect"`
Reason string `json:"reason"`
}{
Adapter: EvaluatorName,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
Request: request,
Effect: effect,
Reason: reason,
})
sum := sha256.Sum256(data)
return "decision:keycloak:" + hex.EncodeToString(sum[:8])
}
func firstDescriptor(values ...*api.CaringAccessDescriptor) *api.CaringAccessDescriptor {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func copyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,192 @@
package keycloak_test
import (
"context"
"errors"
"testing"
"github.com/netkingdom/flex-auth/internal/adapters/keycloak"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestBuildAuthorizationRequestUsesUMAPermissionShape(t *testing.T) {
adapter := newAdapter(t, &fakeClient{})
got, err := adapter.BuildAuthorizationRequest(api.CheckRequest{
ID: "check:keycloak",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
Context: map[string]any{"purpose": "support"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("BuildAuthorizationRequest: %v", err)
}
if got.Realm != "platform" || got.Audience != "markitect-tool" {
t.Fatalf("realm/audience = %s/%s", got.Realm, got.Audience)
}
if got.Permission.ResourceID != "document:internal-note" || got.Permission.Scope != "read" {
t.Fatalf("permission = %+v", got.Permission)
}
if got.ClaimToken["caring_context"] == nil {
t.Fatal("claim token missing CARING context")
}
}
func TestResourceRegistrationsFromManifest(t *testing.T) {
got := keycloak.ResourceRegistrationsFromManifest(api.ResourceManifest{
ID: "manifest:markitect",
System: "markitect-tool",
Actions: []string{"read", "export"},
Resources: []api.Resource{
{
ID: "document:internal-note",
Type: "document",
Path: "/docs/internal-note",
Labels: []string{"internal"},
TrustZone: "internal",
Owner: "team:platform",
},
},
})
if len(got) != 1 {
t.Fatalf("len = %d", len(got))
}
if got[0].ID != "document:internal-note" || got[0].Type != "document" {
t.Fatalf("registration = %+v", got[0])
}
if len(got[0].Scopes) != 2 || got[0].Attributes["trust_zone"][0] != "internal" {
t.Fatalf("registration = %+v", got[0])
}
}
func TestAdapterCheckWrapsKeycloakAllow(t *testing.T) {
client := &fakeClient{
result: keycloak.AuthorizationResult{
Allowed: true,
Reason: "uma_permission_granted",
RPTTokenID: "rpt:123",
PolicyVersion: "kc-v2",
Diagnostics: map[string]any{"policy": "document-reader"},
},
}
adapter := newAdapter(t, client)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:allow",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectAllow || got.Reason != "uma_permission_granted" {
t.Fatalf("decision = %s/%s", got.Effect, got.Reason)
}
if got.Provenance.Evaluator != keycloak.EvaluatorName || got.MatchedPolicyVersion != "kc-v2" {
t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion)
}
if got.Diagnostics["permission"] != "document:internal-note#read" || got.Diagnostics["rpt_token_id"] != "rpt:123" {
t.Fatalf("diagnostics = %+v", got.Diagnostics)
}
if got.Caring == nil || got.Caring.Descriptor == nil {
t.Fatal("missing CARING descriptor")
}
}
func TestAdapterFailsClosedOnUnavailableKeycloak(t *testing.T) {
client := &fakeClient{
err: keycloak.NewBackendError(keycloak.FailureUnavailable, "authorize", errors.New("connect refused")),
}
adapter := newAdapter(t, client)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:down",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectDeny || got.Reason != "keycloak_unavailable" {
t.Fatalf("decision = %s/%s; want fail closed", got.Effect, got.Reason)
}
if got.Diagnostics["keycloak_failure"] != "unavailable" {
t.Fatalf("diagnostics = %+v", got.Diagnostics)
}
if got.Caring.ConformanceFindings[0].Code != "KEYCLOAK-UNAVAILABLE" {
t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0])
}
}
func TestRegisterResourceManifestDelegatesToClient(t *testing.T) {
client := &fakeClient{}
adapter := newAdapter(t, client)
report, err := adapter.RegisterResourceManifest(context.Background(), api.ResourceManifest{
System: "markitect-tool",
Actions: []string{"read"},
Resources: []api.Resource{
{ID: "document:internal-note", Type: "document"},
},
})
if err != nil {
t.Fatalf("RegisterResourceManifest: %v", err)
}
if report.ResourcesWritten != 1 || len(client.registered) != 1 {
t.Fatalf("report = %+v registered = %+v", report, client.registered)
}
}
func newAdapter(t *testing.T, client *fakeClient) *keycloak.Adapter {
t.Helper()
adapter, err := keycloak.New(client, keycloak.Options{
Realm: "platform",
Audience: "markitect-tool",
PolicyPackage: "keycloak.authz",
PolicyVersion: "v1",
})
if err != nil {
t.Fatalf("New: %v", err)
}
return adapter
}
func caringDescriptor() *api.CaringAccessDescriptor {
return &api.CaringAccessDescriptor{
ID: "descriptor:keycloak-reader",
Profile: api.CaringProfileCaring040RC2,
SubjectType: api.SubjectTypeHuman,
OrganizationRelation: api.OrganizationRelationCustomer,
CanonicalRole: api.CanonicalRoleDoer,
Scope: api.CaringScope{Level: api.ScopeLevelResource, ID: "document:internal-note"},
Planes: []api.Plane{api.PlaneData},
Capabilities: []api.Capability{api.CapabilityView},
ExposureModes: []api.ExposureMode{api.ExposureModeMasked},
Restrictions: []api.Restriction{api.RestrictionExportBlocked},
}
}
type fakeClient struct {
result keycloak.AuthorizationResult
err error
registered []keycloak.ResourceRegistration
}
func (c *fakeClient) Authorize(context.Context, keycloak.AuthorizationRequest) (keycloak.AuthorizationResult, error) {
return c.result, c.err
}
func (c *fakeClient) RegisterResources(_ context.Context, resources []keycloak.ResourceRegistration) (keycloak.ResourceImportReport, error) {
c.registered = append([]keycloak.ResourceRegistration(nil), resources...)
return keycloak.ResourceImportReport{ResourcesWritten: len(resources), ResourceServerID: "rs:markitect"}, nil
}
func (c *fakeClient) Health(context.Context) error {
return nil
}

View File

@@ -0,0 +1,2 @@
// Package keycloak defines the Keycloak Authorization Services adapter path.
package keycloak

View File

@@ -0,0 +1,121 @@
package keycloak
import (
"context"
"errors"
"fmt"
"github.com/netkingdom/flex-auth/pkg/api"
)
const (
EvaluatorName = "keycloak-authz"
DelegatedMode = "delegated"
)
// Client is the boundary to Keycloak Authorization Services.
type Client interface {
Authorize(context.Context, AuthorizationRequest) (AuthorizationResult, error)
RegisterResources(context.Context, []ResourceRegistration) (ResourceImportReport, error)
Health(context.Context) error
}
// Options configures Keycloak mapping without making Keycloak the source of
// truth for flex-auth resources or policies.
type Options struct {
Realm string
Audience string
PolicyPackage string
PolicyVersion string
}
// AuthorizationRequest is the UMA permission request flex-auth sends to
// Keycloak.
type AuthorizationRequest struct {
Realm string `json:"realm"`
Audience string `json:"audience"`
Subject api.SubjectRef `json:"subject"`
Permission Permission `json:"permission"`
ClaimToken map[string]any `json:"claim_token,omitempty"`
}
// Permission is the Keycloak resource#scope tuple.
type Permission struct {
ResourceID string `json:"resource_id"`
Scope string `json:"scope"`
}
// AuthorizationResult is a Keycloak decision before flex-auth wrapping.
type AuthorizationResult struct {
Allowed bool
Reason string
RPTTokenID string
PolicyVersion string
Diagnostics map[string]any
CaringDescriptor *api.CaringAccessDescriptor
ConformanceFindings []api.CaringConformanceFinding
StalePolicy bool
PartialResult bool
}
// ResourceRegistration is the Keycloak resource server registration shape.
type ResourceRegistration struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
URI string `json:"uri,omitempty"`
Scopes []string `json:"scopes,omitempty"`
Attributes map[string][]string `json:"attributes,omitempty"`
}
// ResourceImportReport summarizes resource registration.
type ResourceImportReport struct {
ResourcesWritten int
ResourceServerID string
}
// FailureKind classifies fail-closed Keycloak decisions.
type FailureKind string
const (
FailureUnavailable FailureKind = "unavailable"
FailureStalePolicy FailureKind = "stale_policy"
FailurePartialResult FailureKind = "partial_result"
FailureInvalidRequest FailureKind = "invalid_request"
)
// BackendError wraps Keycloak failures with adapter semantics.
type BackendError struct {
Kind FailureKind
Op string
Err error
}
func (e *BackendError) Error() string {
if e == nil {
return ""
}
if e.Err == nil {
return fmt.Sprintf("keycloak %s failed: %s", e.Op, e.Kind)
}
return fmt.Sprintf("keycloak %s failed: %s: %v", e.Op, e.Kind, e.Err)
}
func (e *BackendError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func NewBackendError(kind FailureKind, op string, err error) error {
return &BackendError{Kind: kind, Op: op, Err: err}
}
func failureKind(err error) FailureKind {
var backend *BackendError
if errors.As(err, &backend) && backend.Kind != "" {
return backend.Kind
}
return FailureUnavailable
}

View File

@@ -0,0 +1,442 @@
package relationship
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
// Adapter wraps tuple-oriented PDP results into flex-auth decision envelopes.
type Adapter struct {
backend Backend
backendName string
policyPackage string
policyVersion string
}
// New creates a relationship PDP adapter.
func New(backend Backend, options Options) (*Adapter, error) {
if backend == nil {
return nil, fmt.Errorf("relationship backend is required")
}
backendName := options.BackendName
if backendName == "" {
backendName = "relationship"
}
return &Adapter{
backend: backend,
backendName: backendName,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
}, nil
}
// Check evaluates one subject/action/resource tuple.
func (a *Adapter) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
tupleRequest, err := BuildTupleCheckRequest(request)
if err != nil {
return a.failureEnvelope(request, TupleCheckRequest{}, FailureInvalidRequest, err), nil
}
result, err := a.backend.Check(ctx, tupleRequest)
if err != nil {
return a.failureEnvelope(request, tupleRequest, failureKind(err), err), nil
}
if result.Stale {
return a.failureEnvelope(request, tupleRequest, FailureStaleData, nil), nil
}
if result.Partial {
return a.failureEnvelope(request, tupleRequest, FailurePartialResult, nil), nil
}
return a.envelope(request, tupleRequest, result), nil
}
// BatchCheck evaluates resources in order.
func (a *Adapter) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
tupleRequests := make([]TupleCheckRequest, 0, len(request.Resources))
checkRequests := make([]api.CheckRequest, 0, len(request.Resources))
for _, resource := range request.Resources {
checkRequest := api.CheckRequest{
ID: request.ID,
Subject: request.Subject,
Action: request.Action,
Resource: resource,
Context: request.Context,
PolicyVersion: request.PolicyVersion,
}
tupleRequest, err := BuildTupleCheckRequest(checkRequest)
if err != nil {
decision := a.failureEnvelope(checkRequest, TupleCheckRequest{}, FailureInvalidRequest, err)
tupleRequests = append(tupleRequests, TupleCheckRequest{})
checkRequests = append(checkRequests, checkRequest)
return []api.DecisionEnvelope{decision}, nil
}
tupleRequests = append(tupleRequests, tupleRequest)
checkRequests = append(checkRequests, checkRequest)
}
results, err := a.backend.BatchCheck(ctx, tupleRequests)
if err != nil {
decisions := make([]api.DecisionEnvelope, 0, len(checkRequests))
for i, checkRequest := range checkRequests {
decisions = append(decisions, a.failureEnvelope(checkRequest, tupleRequests[i], failureKind(err), err))
}
return decisions, nil
}
if len(results) != len(checkRequests) {
decisions := make([]api.DecisionEnvelope, 0, len(checkRequests))
for i, checkRequest := range checkRequests {
decisions = append(decisions, a.failureEnvelope(checkRequest, tupleRequests[i], FailurePartialResult, nil))
}
return decisions, nil
}
decisions := make([]api.DecisionEnvelope, 0, len(results))
for i, result := range results {
if result.Stale {
decisions = append(decisions, a.failureEnvelope(checkRequests[i], tupleRequests[i], FailureStaleData, nil))
continue
}
if result.Partial {
decisions = append(decisions, a.failureEnvelope(checkRequests[i], tupleRequests[i], FailurePartialResult, nil))
continue
}
decisions = append(decisions, a.envelope(checkRequests[i], tupleRequests[i], result))
}
return decisions, nil
}
// ListAllowed asks the relationship backend for allowed objects and returns
// allow envelopes with consistency metadata.
func (a *Adapter) ListAllowed(ctx context.Context, request TupleListRequest) (ListAllowedResult, error) {
result, err := a.backend.ListObjects(ctx, request)
if err != nil {
return ListAllowedResult{
Diagnostics: failureDiagnostics(a.backendName, TupleCheckRequest{}, failureKind(err), err),
}, nil
}
if result.Stale || result.Partial {
kind := FailureStaleData
if result.Partial {
kind = FailurePartialResult
}
return ListAllowedResult{
ConsistencyToken: result.ConsistencyToken,
Diagnostics: failureDiagnostics(a.backendName, TupleCheckRequest{}, kind, nil),
}, nil
}
out := ListAllowedResult{
ConsistencyToken: result.ConsistencyToken,
Diagnostics: copyMap(result.Diagnostics),
Decisions: make([]api.DecisionEnvelope, 0, len(result.Objects)),
}
for _, listed := range result.Objects {
checkRequest := api.CheckRequest{
Subject: request.Subject,
Action: request.Relation,
Resource: listed.Resource,
Context: request.Context,
}
tupleRequest, _ := BuildTupleCheckRequest(checkRequest)
checkResult := TupleCheckResult{
Allowed: true,
Reason: "relationship_list_allowed",
ConsistencyToken: firstNonEmpty(listed.ConsistencyToken, result.ConsistencyToken),
MatchedTuples: listed.MatchedTuples,
Diagnostics: listed.Diagnostics,
CaringDescriptor: listed.CaringDescriptor,
}
out.Decisions = append(out.Decisions, a.envelope(checkRequest, tupleRequest, checkResult))
}
return out, nil
}
// ImportTuples writes canonical registry relationships to the backend.
func (a *Adapter) ImportTuples(ctx context.Context, snapshot registry.Snapshot) (ImportReport, error) {
return a.backend.ImportTuples(ctx, SnapshotToTuples(snapshot))
}
// BuildTupleCheckRequest maps flex-auth's stable request to a tuple check.
func BuildTupleCheckRequest(request api.CheckRequest) (TupleCheckRequest, error) {
objectType := request.Resource.Type
if objectType == "" {
objectType = inferTypeFromID(request.Resource.ID)
}
if objectType == "" || request.Resource.ID == "" || request.Action == "" || request.Subject.ID == "" {
return TupleCheckRequest{}, fmt.Errorf("resource type/id, action, and subject id are required")
}
return TupleCheckRequest{
ObjectType: objectType,
ObjectID: request.Resource.ID,
Relation: request.Action,
SubjectType: tupleSubjectType(request.Subject.Type, request.Subject.ID),
SubjectID: request.Subject.ID,
Context: copyMap(request.Context),
ConsistencyToken: contextString(request.Context, "consistency_token"),
}, nil
}
func (a *Adapter) envelope(request api.CheckRequest, tupleRequest TupleCheckRequest, result TupleCheckResult) api.DecisionEnvelope {
effect := result.Effect
if effect == "" {
if result.Allowed {
effect = api.DecisionEffectAllow
} else {
effect = api.DecisionEffectDeny
}
}
reason := result.Reason
if reason == "" {
if effect == api.DecisionEffectAllow {
reason = "relationship_tuple_allow"
} else {
reason = "relationship_tuple_deny"
}
}
policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion)
diagnostics := copyMap(result.Diagnostics)
addTupleDiagnostics(diagnostics, a.backendName, tupleRequest, "")
if len(result.MatchedTuples) > 0 {
diagnostics["matched_tuples"] = len(result.MatchedTuples)
}
if len(result.InheritedFrom) > 0 {
diagnostics["inherited_tuples"] = len(result.InheritedFrom)
}
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: effect,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: firstNonEmpty(result.MatchedRule, reason),
Resource: request.Resource,
Subject: request.Subject,
Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName,
Mode: DelegatedMode,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
DirectoryETag: result.ConsistencyToken,
},
Caring: caringDecisionMetadata(request, descriptorForResult(request, result), result.ConformanceFindings),
}
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, effect, reason, result.ConsistencyToken)
return envelope
}
func (a *Adapter) failureEnvelope(request api.CheckRequest, tupleRequest TupleCheckRequest, kind FailureKind, err error) api.DecisionEnvelope {
reason := failureReason(kind)
policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion)
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: api.DecisionEffectDeny,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: reason,
Resource: request.Resource,
Subject: request.Subject,
Diagnostics: failureDiagnostics(a.backendName, tupleRequest, kind, err),
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName,
Mode: DelegatedMode,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(request, request.CaringContext, []api.CaringConformanceFinding{failureFinding(kind)}),
}
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "")
return envelope
}
func descriptorForResult(request api.CheckRequest, result TupleCheckResult) *api.CaringAccessDescriptor {
if request.CaringContext != nil {
return request.CaringContext
}
if result.CaringDescriptor != nil {
return result.CaringDescriptor
}
for _, tuple := range result.MatchedTuples {
if tuple.Caring != nil {
return tuple.Caring
}
}
for _, tuple := range result.InheritedFrom {
if tuple.Caring != nil {
return tuple.Caring
}
}
return nil
}
func caringDecisionMetadata(request api.CheckRequest, descriptor *api.CaringAccessDescriptor, findings []api.CaringConformanceFinding) *api.CaringDecisionMetadata {
profile := api.CaringProfileCaring040RC2
if descriptor != nil && descriptor.Profile != "" {
profile = descriptor.Profile
}
metadata := &api.CaringDecisionMetadata{
Profile: profile,
ConformanceFindings: append([]api.CaringConformanceFinding(nil), findings...),
}
if descriptor == nil {
metadata.ConformanceFindings = append(metadata.ConformanceFindings, api.CaringConformanceFinding{
Code: "RELATIONSHIP-CARING-DESCRIPTOR-MISSING",
Severity: "warning",
Message: "relationship backend result did not include a CARING descriptor",
Fields: []string{"caring_context"},
})
return metadata
}
descriptorCopy := *descriptor
metadata.Descriptor = &descriptorCopy
metadata.RestrictionsEvaluated = append([]api.Restriction(nil), descriptor.Restrictions...)
metadata.ExposureModes = append([]api.ExposureMode(nil), descriptor.ExposureModes...)
metadata.DerivedCapabilities = append([]api.CaringDerivedCapability(nil), descriptor.DerivedCapabilities...)
if descriptor.ExposureEvent != "" {
scope := descriptor.Scope
metadata.ExposureEvent = &api.CaringExposureEvent{
ID: decisionID("relationship", "", "", request, api.DecisionEffectAllow, string(descriptor.ExposureEvent), ""),
Type: descriptor.ExposureEvent,
Actor: request.Subject.ID,
Subject: request.Subject.ID,
Descriptor: &descriptorCopy,
Scope: &scope,
Planes: append([]api.Plane(nil), descriptor.Planes...),
CapabilitiesUsed: append([]api.Capability(nil), descriptor.Capabilities...),
ExposureModes: append([]api.ExposureMode(nil), descriptor.ExposureModes...),
Reason: "relationship backend result carries CARING exposure event hook",
AuthoritySource: EvaluatorName,
}
}
return metadata
}
func failureDiagnostics(backendName string, request TupleCheckRequest, kind FailureKind, err error) map[string]any {
diagnostics := map[string]any{}
addTupleDiagnostics(diagnostics, backendName, request, string(kind))
if err != nil {
diagnostics["error"] = err.Error()
}
return diagnostics
}
func addTupleDiagnostics(diagnostics map[string]any, backendName string, request TupleCheckRequest, failure string) {
diagnostics["adapter"] = "relationship"
diagnostics["backend"] = backendName
diagnostics["mode"] = DelegatedMode
if failure != "" {
diagnostics["relationship_failure"] = failure
}
if request.ObjectType != "" {
diagnostics["tuple_object_type"] = request.ObjectType
diagnostics["tuple_object_id"] = request.ObjectID
diagnostics["tuple_relation"] = request.Relation
diagnostics["tuple_subject_type"] = request.SubjectType
diagnostics["tuple_subject_id"] = request.SubjectID
}
}
func failureReason(kind FailureKind) string {
switch kind {
case FailureStaleData:
return "relationship_data_stale"
case FailurePartialResult:
return "relationship_partial_result"
case FailureInvalidRequest:
return "relationship_request_incomplete"
default:
return "relationship_backend_unavailable"
}
}
func failureFinding(kind FailureKind) api.CaringConformanceFinding {
code := "RELATIONSHIP-BACKEND-UNAVAILABLE"
message := "Relationship backend was unavailable; flex-auth denied the delegated request fail-closed."
switch kind {
case FailureStaleData:
code = "RELATIONSHIP-DATA-STALE"
message = "Relationship backend consistency token was stale; flex-auth denied fail-closed."
case FailurePartialResult:
code = "RELATIONSHIP-PARTIAL-RESULT"
message = "Relationship backend returned a partial result; flex-auth denied fail-closed."
case FailureInvalidRequest:
code = "RELATIONSHIP-REQUEST-INCOMPLETE"
message = "Request could not be translated to a tuple check; flex-auth denied fail-closed."
}
return api.CaringConformanceFinding{Code: code, Severity: "error", Message: message}
}
func decisionID(backendName, policyPackage, policyVersion string, request api.CheckRequest, effect api.DecisionEffect, reason, consistencyToken string) string {
data, _ := json.Marshal(struct {
Adapter string `json:"adapter"`
Backend string `json:"backend"`
PolicyPackage string `json:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty"`
Request api.CheckRequest `json:"request"`
Effect api.DecisionEffect `json:"effect"`
Reason string `json:"reason"`
ConsistencyToken string `json:"consistency_token,omitempty"`
}{
Adapter: EvaluatorName,
Backend: backendName,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
Request: request,
Effect: effect,
Reason: reason,
ConsistencyToken: consistencyToken,
})
sum := sha256.Sum256(data)
return "decision:relationship:" + hex.EncodeToString(sum[:8])
}
func tupleSubjectType(subjectType api.SubjectType, id string) string {
switch subjectType {
case api.SubjectTypeGroup, api.SubjectTypeOrganization:
return "group"
default:
if strings.HasPrefix(id, "group:") || strings.HasPrefix(id, "team:") || strings.HasPrefix(id, "reader:") {
return "group"
}
return "user"
}
}
func inferTypeFromID(id string) string {
before, _, ok := strings.Cut(id, ":")
if !ok {
return ""
}
return before
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func copyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}
func contextString(context map[string]any, key string) string {
value, _ := context[key].(string)
return value
}

View File

@@ -0,0 +1,304 @@
package relationship_test
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/netkingdom/flex-auth/internal/adapters/relationship"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestSnapshotToTuplesMapsCanonicalRegistry(t *testing.T) {
tuples := relationship.SnapshotToTuples(loadRegistry(t).Snapshot())
want := relationship.Tuple{
ObjectType: "document",
ObjectID: "document:internal-note",
Relation: "reader",
SubjectType: "group",
SubjectID: "group:platform-architecture",
SubjectRelation: "member",
}
got := findTuple(tuples, want)
if got.Caring == nil || got.Caring.CanonicalRole != api.CanonicalRoleDoer {
t.Fatalf("reader tuple CARING = %+v; want Doer descriptor", got.Caring)
}
if len(got.Conditions) != 1 || got.Conditions[0] != api.ConditionLogged {
t.Fatalf("conditions = %+v; want Logged", got.Conditions)
}
}
func TestAdapterCheckPreservesCARINGFromMatchedTuple(t *testing.T) {
descriptor := caringDescriptor()
backend := &fakeBackend{
checkResult: relationship.TupleCheckResult{
Allowed: true,
Reason: "direct_tuple",
ConsistencyToken: "zed:42",
MatchedTuples: []relationship.Tuple{
{
ObjectType: "document",
ObjectID: "document:internal-note",
Relation: "reader",
SubjectType: "group",
SubjectID: "group:platform-architecture",
Caring: descriptor,
},
},
InheritedFrom: []relationship.Tuple{
{ObjectType: "knowledge_base", ObjectID: "knowledge-base:markitect-example", Relation: "reader"},
},
Diagnostics: map[string]any{"backend_trace": "trace-1"},
},
}
adapter := newAdapter(t, backend)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:relationship-allow",
Subject: api.SubjectRef{ID: "user:alice", Type: api.SubjectTypeHuman},
Action: "read",
Resource: api.ResourceRef{
ID: "document:internal-note",
Type: "document",
System: "markitect-tool",
},
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectAllow || got.Reason != "direct_tuple" {
t.Fatalf("decision = %s/%s; want allow/direct_tuple", got.Effect, got.Reason)
}
if got.Provenance.Evaluator != "relationship-pdp/openfga" || got.Provenance.DirectoryETag != "zed:42" {
t.Fatalf("provenance = %+v", got.Provenance)
}
if got.Diagnostics["matched_tuples"] != 1 || got.Diagnostics["inherited_tuples"] != 1 {
t.Fatalf("diagnostics = %+v; want matched and inherited counts", got.Diagnostics)
}
if got.Caring == nil || got.Caring.Descriptor == nil {
t.Fatal("missing CARING metadata")
}
if len(got.Caring.DerivedCapabilities) != 1 || got.Caring.DerivedCapabilities[0].Capability != api.CapabilityViewCollection {
t.Fatalf("derived capabilities = %+v", got.Caring.DerivedCapabilities)
}
if got.Caring.ExposureEvent == nil || got.Caring.ExposureEvent.Type != api.ExposureEventSupport {
t.Fatalf("exposure event = %+v", got.Caring.ExposureEvent)
}
}
func TestAdapterCheckFailsClosedOnStaleBackend(t *testing.T) {
backend := &fakeBackend{
checkErr: relationship.NewBackendError(relationship.FailureStaleData, "check", errors.New("zed token too old")),
}
adapter := newAdapter(t, backend)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:stale",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectDeny || got.Reason != "relationship_data_stale" {
t.Fatalf("decision = %s/%s; want stale deny", got.Effect, got.Reason)
}
if got.Diagnostics["relationship_failure"] != "stale_data" {
t.Fatalf("diagnostics = %+v", got.Diagnostics)
}
if got.Caring.ConformanceFindings[0].Code != "RELATIONSHIP-DATA-STALE" {
t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0])
}
}
func TestBatchCheckPreservesBackendOrder(t *testing.T) {
backend := &fakeBackend{
batchResults: []relationship.TupleCheckResult{
{Allowed: true, ConsistencyToken: "zed:1"},
{Allowed: false, Reason: "no_tuple", ConsistencyToken: "zed:2"},
},
}
adapter := newAdapter(t, backend)
got, err := adapter.BatchCheck(context.Background(), api.BatchCheckRequest{
ID: "batch:documents",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resources: []api.ResourceRef{
{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
{ID: "document:missing", Type: "document", System: "markitect-tool"},
},
})
if err != nil {
t.Fatalf("BatchCheck: %v", err)
}
if len(got) != 2 {
t.Fatalf("len(got) = %d; want 2", len(got))
}
if got[0].Effect != api.DecisionEffectAllow || got[1].Effect != api.DecisionEffectDeny {
t.Fatalf("effects = %s/%s; want allow/deny", got[0].Effect, got[1].Effect)
}
if got[0].Resource.ID != "document:internal-note" || got[1].Resource.ID != "document:missing" {
t.Fatalf("resource order = %s/%s", got[0].Resource.ID, got[1].Resource.ID)
}
}
func TestListAllowedReturnsEnvelopeResults(t *testing.T) {
backend := &fakeBackend{
listResult: relationship.TupleListResult{
ConsistencyToken: "zed:list",
Objects: []relationship.ListedObject{
{
Resource: api.ResourceRef{
ID: "document:internal-note",
Type: "document",
System: "markitect-tool",
},
CaringDescriptor: caringDescriptor(),
},
},
},
}
adapter := newAdapter(t, backend)
got, err := adapter.ListAllowed(context.Background(), relationship.TupleListRequest{
Subject: api.SubjectRef{ID: "user:alice"},
Relation: "read",
ResourceType: "document",
System: "markitect-tool",
})
if err != nil {
t.Fatalf("ListAllowed: %v", err)
}
if got.ConsistencyToken != "zed:list" || len(got.Decisions) != 1 {
t.Fatalf("list result = %+v", got)
}
if got.Decisions[0].Effect != api.DecisionEffectAllow || got.Decisions[0].Reason != "relationship_list_allowed" {
t.Fatalf("decision = %+v", got.Decisions[0])
}
}
func TestImportTuplesDelegatesSnapshotTuples(t *testing.T) {
backend := &fakeBackend{}
adapter := newAdapter(t, backend)
report, err := adapter.ImportTuples(context.Background(), loadRegistry(t).Snapshot())
if err != nil {
t.Fatalf("ImportTuples: %v", err)
}
if report.TuplesWritten != len(backend.imported) {
t.Fatalf("report = %+v imported=%d", report, len(backend.imported))
}
if findTuple(backend.imported, relationship.Tuple{
ObjectType: "document",
ObjectID: "document:internal-note",
Relation: "reader",
SubjectType: "group",
SubjectID: "group:platform-architecture",
SubjectRelation: "member",
}).Caring == nil {
t.Fatal("imported reader tuple lost CARING descriptor")
}
}
func newAdapter(t *testing.T, backend *fakeBackend) *relationship.Adapter {
t.Helper()
adapter, err := relationship.New(backend, relationship.Options{
BackendName: "openfga",
PolicyPackage: "relationship.boundary",
PolicyVersion: "v1",
})
if err != nil {
t.Fatalf("New: %v", err)
}
return adapter
}
func loadRegistry(t *testing.T) *registry.Store {
t.Helper()
store, err := registry.LoadFile(filepath.Join("..", "..", "..", "examples", "caring", "registry_snapshot.json"))
if err != nil {
t.Fatalf("LoadFile: %v", err)
}
return store
}
func caringDescriptor() *api.CaringAccessDescriptor {
return &api.CaringAccessDescriptor{
ID: "descriptor:tuple-reader",
Profile: api.CaringProfileCaring040RC2,
SubjectType: api.SubjectTypeGroup,
OrganizationRelation: api.OrganizationRelationCustomer,
CanonicalRole: api.CanonicalRoleDoer,
Scope: api.CaringScope{
Level: api.ScopeLevelResource,
ID: "document:internal-note",
Tenant: "tenant:alpha",
Resource: "document:internal-note",
},
Planes: []api.Plane{api.PlaneData},
Capabilities: []api.Capability{api.CapabilityView},
ExposureModes: []api.ExposureMode{api.ExposureModeMasked},
Restrictions: []api.Restriction{api.RestrictionExportBlocked},
ExposureEvent: api.ExposureEventSupport,
DerivedCapabilities: []api.CaringDerivedCapability{
{Capability: api.CapabilityViewCollection, Reason: "parent reader inheritance", Source: "tuple:parent"},
},
}
}
func findTuple(tuples []relationship.Tuple, want relationship.Tuple) relationship.Tuple {
for _, tuple := range tuples {
if tuple.ObjectType == want.ObjectType &&
tuple.ObjectID == want.ObjectID &&
tuple.Relation == want.Relation &&
tuple.SubjectType == want.SubjectType &&
tuple.SubjectID == want.SubjectID &&
tuple.SubjectRelation == want.SubjectRelation {
return tuple
}
}
panic("tuple not found")
}
type fakeBackend struct {
checkResult relationship.TupleCheckResult
checkErr error
batchResults []relationship.TupleCheckResult
listResult relationship.TupleListResult
imported []relationship.Tuple
}
func (b *fakeBackend) Check(context.Context, relationship.TupleCheckRequest) (relationship.TupleCheckResult, error) {
return b.checkResult, b.checkErr
}
func (b *fakeBackend) BatchCheck(_ context.Context, requests []relationship.TupleCheckRequest) ([]relationship.TupleCheckResult, error) {
if b.batchResults != nil {
return b.batchResults, nil
}
results := make([]relationship.TupleCheckResult, len(requests))
for i := range results {
results[i] = b.checkResult
}
return results, b.checkErr
}
func (b *fakeBackend) ListObjects(context.Context, relationship.TupleListRequest) (relationship.TupleListResult, error) {
return b.listResult, nil
}
func (b *fakeBackend) ImportTuples(_ context.Context, tuples []relationship.Tuple) (relationship.ImportReport, error) {
b.imported = append([]relationship.Tuple(nil), tuples...)
return relationship.ImportReport{TuplesWritten: len(tuples), ConsistencyToken: "zed:import"}, nil
}
func (b *fakeBackend) Health(context.Context) error {
return nil
}

View File

@@ -0,0 +1,3 @@
// Package relationship defines the backend-neutral relationship PDP boundary
// for OpenFGA, SpiceDB, and similar tuple-oriented authorization systems.
package relationship

View File

@@ -0,0 +1,197 @@
package relationship
import (
"fmt"
"sort"
"strings"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
// SnapshotToTuples maps flex-auth registry data to relationship tuples.
func SnapshotToTuples(snapshot registry.Snapshot) []Tuple {
index := newSnapshotIndex(snapshot)
tuples := map[string]Tuple{}
addTuple := func(tuple Tuple) {
if tuple.ObjectType == "" || tuple.ObjectID == "" || tuple.Relation == "" ||
tuple.SubjectType == "" || tuple.SubjectID == "" {
return
}
tuples[tupleKey(tuple)] = tuple
}
for _, group := range snapshot.Groups {
for _, member := range group.Members {
addTuple(Tuple{
ObjectType: "group",
ObjectID: group.ID,
Relation: "member",
SubjectType: index.subjectType(member),
SubjectID: index.subjectID(member),
})
}
}
for _, team := range snapshot.Teams {
for _, member := range team.Members {
addTuple(Tuple{
ObjectType: "group",
ObjectID: teamObjectID(team.ID),
Relation: "member",
SubjectType: index.subjectType(member),
SubjectID: index.subjectID(member),
})
}
}
for _, manifest := range snapshot.ResourceManifests {
for _, resource := range manifest.Resources {
if resource.Parent != "" {
addTuple(Tuple{
ObjectType: resource.Type,
ObjectID: resource.ID,
Relation: "parent",
SubjectType: index.resourceType(resource.Parent),
SubjectID: resource.Parent,
Metadata: map[string]any{
"system": manifest.System,
},
})
}
if resource.Owner != "" {
addTuple(Tuple{
ObjectType: resource.Type,
ObjectID: resource.ID,
Relation: "owner_team",
SubjectType: "group",
SubjectID: teamOrGroupObjectID(resource.Owner),
Metadata: map[string]any{
"system": manifest.System,
},
})
}
}
}
for _, relationship := range snapshot.Relationships {
subjectType := index.subjectType(relationship.Subject)
subjectRelation := ""
if subjectType == "group" && relationship.Relation != "member" && relationship.Relation != "owner_team" {
subjectRelation = "member"
}
addTuple(Tuple{
ObjectType: index.resourceType(relationship.Object),
ObjectID: relationship.Object,
Relation: relationship.Relation,
SubjectType: subjectType,
SubjectID: index.subjectID(relationship.Subject),
SubjectRelation: subjectRelation,
Conditions: append([]api.Condition(nil), relationship.Conditions...),
Caring: relationship.Caring,
Provenance: copyMap(relationship.Provenance),
Metadata: copyMap(relationship.Metadata),
})
}
return sortedTuples(tuples)
}
type snapshotIndex struct {
groups map[string]api.Group
teams map[string]api.Team
resourceTypes map[string]string
}
func newSnapshotIndex(snapshot registry.Snapshot) snapshotIndex {
index := snapshotIndex{
groups: make(map[string]api.Group),
teams: make(map[string]api.Team),
resourceTypes: make(map[string]string),
}
for _, group := range snapshot.Groups {
index.groups[group.ID] = group
}
for _, team := range snapshot.Teams {
index.teams[team.ID] = team
index.teams[teamObjectID(team.ID)] = team
}
for _, manifest := range snapshot.ResourceManifests {
for _, resource := range manifest.Resources {
index.resourceTypes[resource.ID] = resource.Type
}
}
return index
}
func (i snapshotIndex) subjectType(id string) string {
if _, ok := i.groups[id]; ok {
return "group"
}
if _, ok := i.teams[id]; ok {
return "group"
}
if strings.HasPrefix(id, "group:") || strings.HasPrefix(id, "team:") || strings.HasPrefix(id, "reader:") {
return "group"
}
if resourceType := i.resourceType(id); resourceType != "" {
return resourceType
}
return "user"
}
func (i snapshotIndex) subjectID(id string) string {
if _, ok := i.teams[id]; ok {
return teamObjectID(id)
}
return id
}
func (i snapshotIndex) resourceType(id string) string {
if resourceType := i.resourceTypes[id]; resourceType != "" {
return resourceType
}
if inferred := inferTypeFromID(id); inferred != "" {
return inferred
}
return "resource"
}
func tupleKey(tuple Tuple) string {
return fmt.Sprintf(
"%s\x00%s\x00%s\x00%s\x00%s\x00%s",
tuple.ObjectType,
tuple.ObjectID,
tuple.Relation,
tuple.SubjectType,
tuple.SubjectID,
tuple.SubjectRelation,
)
}
func sortedTuples(tuples map[string]Tuple) []Tuple {
keys := make([]string, 0, len(tuples))
for key := range tuples {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]Tuple, 0, len(keys))
for _, key := range keys {
out = append(out, tuples[key])
}
return out
}
func teamObjectID(id string) string {
if strings.HasPrefix(id, "team:") {
return id
}
return "team:" + id
}
func teamOrGroupObjectID(id string) string {
if strings.HasPrefix(id, "team:") || strings.HasPrefix(id, "group:") || strings.HasPrefix(id, "reader:") {
return id
}
return teamObjectID(id)
}

View File

@@ -0,0 +1,164 @@
package relationship
import (
"context"
"errors"
"fmt"
"github.com/netkingdom/flex-auth/pkg/api"
)
const (
// EvaluatorName is recorded in delegated relationship-PDP provenance.
EvaluatorName = "relationship-pdp"
// DelegatedMode is the stable mode used for delegated tuple checks.
DelegatedMode = "delegated"
)
// Backend is the protocol boundary for OpenFGA, SpiceDB, and similar systems.
type Backend interface {
Check(context.Context, TupleCheckRequest) (TupleCheckResult, error)
BatchCheck(context.Context, []TupleCheckRequest) ([]TupleCheckResult, error)
ListObjects(context.Context, TupleListRequest) (TupleListResult, error)
ImportTuples(context.Context, []Tuple) (ImportReport, error)
Health(context.Context) error
}
// Options configures the relationship adapter without binding callers to a
// specific backend protocol.
type Options struct {
BackendName string
PolicyPackage string
PolicyVersion string
}
// Tuple is the canonical relation fact sent to tuple-oriented PDPs.
type Tuple struct {
ObjectType string `json:"object_type"`
ObjectID string `json:"object_id"`
Relation string `json:"relation"`
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
SubjectRelation string `json:"subject_relation,omitempty"`
Conditions []api.Condition `json:"conditions,omitempty"`
Caring *api.CaringAccessDescriptor `json:"caring,omitempty"`
Provenance map[string]any `json:"provenance,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
// TupleCheckRequest is the backend-neutral relationship check.
type TupleCheckRequest struct {
ObjectType string `json:"object_type"`
ObjectID string `json:"object_id"`
Relation string `json:"relation"`
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Context map[string]any `json:"context,omitempty"`
ConsistencyToken string `json:"consistency_token,omitempty"`
}
// TupleCheckResult is a tuple-PDP response before flex-auth envelope wrapping.
type TupleCheckResult struct {
Allowed bool
Effect api.DecisionEffect
Reason string
MatchedRule string
ConsistencyToken string
Stale bool
Partial bool
MatchedTuples []Tuple
InheritedFrom []Tuple
Diagnostics map[string]any
CaringDescriptor *api.CaringAccessDescriptor
ConformanceFindings []api.CaringConformanceFinding
}
// TupleListRequest asks the backend for objects a subject may access.
type TupleListRequest struct {
Subject api.SubjectRef `json:"subject"`
Relation string `json:"relation"`
ResourceType string `json:"resource_type,omitempty"`
System string `json:"system,omitempty"`
Tenant string `json:"tenant,omitempty"`
Filters map[string]any `json:"filters,omitempty"`
Context map[string]any `json:"context,omitempty"`
ConsistencyToken string `json:"consistency_token,omitempty"`
}
// ListedObject is one backend list result.
type ListedObject struct {
Resource api.ResourceRef `json:"resource"`
ConsistencyToken string `json:"consistency_token,omitempty"`
MatchedTuples []Tuple `json:"matched_tuples,omitempty"`
Diagnostics map[string]any `json:"diagnostics,omitempty"`
CaringDescriptor *api.CaringAccessDescriptor `json:"caring_descriptor,omitempty"`
}
// TupleListResult is a backend list response.
type TupleListResult struct {
Objects []ListedObject `json:"objects"`
ConsistencyToken string `json:"consistency_token,omitempty"`
Stale bool `json:"stale,omitempty"`
Partial bool `json:"partial,omitempty"`
Diagnostics map[string]any `json:"diagnostics,omitempty"`
}
// ListAllowedResult is the flex-auth list response with consistency metadata.
type ListAllowedResult struct {
Decisions []api.DecisionEnvelope `json:"decisions"`
ConsistencyToken string `json:"consistency_token,omitempty"`
Diagnostics map[string]any `json:"diagnostics,omitempty"`
}
// ImportReport summarizes tuple import.
type ImportReport struct {
TuplesWritten int `json:"tuples_written"`
ConsistencyToken string `json:"consistency_token,omitempty"`
}
// FailureKind classifies fail-closed delegated relationship decisions.
type FailureKind string
const (
FailureUnavailable FailureKind = "unavailable"
FailureStaleData FailureKind = "stale_data"
FailurePartialResult FailureKind = "partial_result"
FailureInvalidRequest FailureKind = "invalid_request"
)
// BackendError wraps tuple-backend failures with adapter semantics.
type BackendError struct {
Kind FailureKind
Op string
Err error
}
func (e *BackendError) Error() string {
if e == nil {
return ""
}
if e.Err == nil {
return fmt.Sprintf("relationship backend %s failed: %s", e.Op, e.Kind)
}
return fmt.Sprintf("relationship backend %s failed: %s: %v", e.Op, e.Kind, e.Err)
}
func (e *BackendError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// NewBackendError classifies a relationship-backend failure.
func NewBackendError(kind FailureKind, op string, err error) error {
return &BackendError{Kind: kind, Op: op, Err: err}
}
func failureKind(err error) FailureKind {
var backend *BackendError
if errors.As(err, &backend) && backend.Kind != "" {
return backend.Kind
}
return FailureUnavailable
}

View File

@@ -0,0 +1,461 @@
package rule
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"reflect"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/pkg/api"
)
// Adapter wraps rule-PDP responses into flex-auth decision envelopes.
type Adapter struct {
backend Backend
backendName string
policyPackage string
policyVersion string
language Language
caring api.CaringPolicyMetadata
}
// New creates a delegated rule-PDP adapter.
func New(backend Backend, options Options) (*Adapter, error) {
if backend == nil {
return nil, fmt.Errorf("rule backend is required")
}
backendName := options.BackendName
if backendName == "" {
backendName = "rule"
}
language := options.Language
if language == "" {
language = LanguageRego
}
return &Adapter{
backend: backend,
backendName: backendName,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
language: language,
caring: options.Caring,
}, nil
}
// Check evaluates one request through the delegated rule backend.
func (a *Adapter) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
evaluation, err := a.buildEvaluationRequest(request)
if err != nil {
return a.failureEnvelope(request, EvaluationRequest{}, FailureInvalidRequest, err), nil
}
result, err := a.backend.Evaluate(ctx, evaluation)
if err != nil {
return a.failureEnvelope(request, evaluation, failureKind(err), err), nil
}
if result.StalePolicy {
return a.failureEnvelope(request, evaluation, FailureStalePolicy, nil), nil
}
if result.PartialResult {
return a.failureEnvelope(request, evaluation, FailurePartialResult, nil), nil
}
return a.envelope(request, evaluation, result), nil
}
// BatchCheck evaluates resources in request order.
func (a *Adapter) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
evaluations := make([]EvaluationRequest, 0, len(request.Resources))
checks := make([]api.CheckRequest, 0, len(request.Resources))
for _, resource := range request.Resources {
check := api.CheckRequest{
ID: request.ID,
Subject: request.Subject,
Action: request.Action,
Resource: resource,
Context: request.Context,
PolicyVersion: request.PolicyVersion,
}
evaluation, err := a.buildEvaluationRequest(check)
if err != nil {
return []api.DecisionEnvelope{a.failureEnvelope(check, EvaluationRequest{}, FailureInvalidRequest, err)}, nil
}
evaluations = append(evaluations, evaluation)
checks = append(checks, check)
}
results, err := a.backend.BatchEvaluate(ctx, evaluations)
if err != nil {
decisions := make([]api.DecisionEnvelope, 0, len(checks))
for i, check := range checks {
decisions = append(decisions, a.failureEnvelope(check, evaluations[i], failureKind(err), err))
}
return decisions, nil
}
if len(results) != len(checks) {
decisions := make([]api.DecisionEnvelope, 0, len(checks))
for i, check := range checks {
decisions = append(decisions, a.failureEnvelope(check, evaluations[i], FailurePartialResult, nil))
}
return decisions, nil
}
decisions := make([]api.DecisionEnvelope, 0, len(results))
for i, result := range results {
if result.StalePolicy {
decisions = append(decisions, a.failureEnvelope(checks[i], evaluations[i], FailureStalePolicy, nil))
continue
}
if result.PartialResult {
decisions = append(decisions, a.failureEnvelope(checks[i], evaluations[i], FailurePartialResult, nil))
continue
}
decisions = append(decisions, a.envelope(checks[i], evaluations[i], result))
}
return decisions, nil
}
// ImportPolicy imports a flex-auth Rego-in-Markdown package as a delegated
// rule artifact.
func (a *Adapter) ImportPolicy(ctx context.Context, pkg *policy.Package) (PolicyImportReport, error) {
if pkg == nil {
return PolicyImportReport{}, fmt.Errorf("policy package is required")
}
artifact := PolicyArtifactFromPackage(pkg)
report, err := a.backend.ImportPolicy(ctx, artifact)
if err != nil {
return PolicyImportReport{}, err
}
a.policyPackage = artifact.ID
a.policyVersion = artifact.Version
a.language = artifact.Language
a.caring = artifact.Caring
return report, nil
}
// EvaluateFixtures runs policy fixtures through the delegated backend.
func (a *Adapter) EvaluateFixtures(ctx context.Context, fixtures []api.PolicyFixture) []FixtureResult {
results := make([]FixtureResult, 0, len(fixtures))
for _, fixture := range fixtures {
decision, err := a.Check(ctx, fixture.Request)
actual := api.DecisionExpectation{}
if err == nil {
actual = api.DecisionExpectation{
Effect: decision.Effect,
Reason: decision.Reason,
Obligations: decision.Obligations,
ConformanceFindings: conformanceFindings(decision),
}
}
result := FixtureResult{
ID: fixture.ID,
Expected: fixture.Expect,
Actual: actual,
Passed: err == nil && expectationMatches(fixture.Expect, actual),
}
if err != nil {
result.Error = err.Error()
}
results = append(results, result)
}
return results
}
func (a *Adapter) buildEvaluationRequest(request api.CheckRequest) (EvaluationRequest, error) {
if request.Subject.ID == "" || request.Action == "" || request.Resource.ID == "" {
return EvaluationRequest{}, fmt.Errorf("subject id, action, and resource id are required")
}
policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion)
evaluation := EvaluationRequest{
ID: request.ID,
Subject: request.Subject,
Action: request.Action,
Resource: request.Resource,
Context: copyMap(request.Context),
CaringContext: request.CaringContext,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
Language: a.language,
}
evaluation.Input = CanonicalInput(evaluation)
return evaluation, nil
}
// CanonicalInput returns the policy-language-neutral input shape.
func CanonicalInput(request EvaluationRequest) map[string]any {
input := map[string]any{
"subject": request.Subject,
"action": request.Action,
"resource": request.Resource,
"context": copyMap(request.Context),
"policy": map[string]any{
"package": request.PolicyPackage,
"version": request.PolicyVersion,
},
}
if request.CaringContext != nil {
input["caring_context"] = request.CaringContext
}
return input
}
func (a *Adapter) envelope(request api.CheckRequest, evaluation EvaluationRequest, result EvaluationResult) api.DecisionEnvelope {
effect := result.Effect
if effect == "" {
effect = api.DecisionEffectDeny
}
reason := result.Reason
if reason == "" {
reason = string(effect)
}
policyPackage := firstNonEmpty(result.PolicyPackage, a.policyPackage)
policyVersion := firstNonEmpty(result.PolicyVersion, evaluation.PolicyVersion, a.policyVersion)
diagnostics := copyMap(result.Diagnostics)
addRuleDiagnostics(diagnostics, a.backendName, evaluation, "")
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: effect,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: firstNonEmpty(result.MatchedRule, reason),
Resource: request.Resource,
Subject: request.Subject,
Obligations: append([]api.Obligation(nil), result.Obligations...),
Diagnostics: diagnostics,
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName,
Mode: DelegatedMode,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(request, firstDescriptor(request.CaringContext, result.CaringDescriptor), a.caring, result.ConformanceFindings),
}
envelope.ID = decisionID(a.backendName, policyPackage, policyVersion, request, effect, reason)
return envelope
}
func (a *Adapter) failureEnvelope(request api.CheckRequest, evaluation EvaluationRequest, kind FailureKind, err error) api.DecisionEnvelope {
reason := failureReason(kind)
policyVersion := firstNonEmpty(request.PolicyVersion, a.policyVersion)
envelope := api.DecisionEnvelope{
RequestID: request.ID,
Effect: api.DecisionEffectDeny,
Reason: reason,
MatchedPolicyVersion: policyVersion,
MatchedRule: reason,
Resource: request.Resource,
Subject: request.Subject,
Diagnostics: failureDiagnostics(a.backendName, evaluation, kind, err),
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName + "/" + a.backendName,
Mode: DelegatedMode,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(request, request.CaringContext, a.caring, []api.CaringConformanceFinding{failureFinding(kind)}),
}
envelope.ID = decisionID(a.backendName, a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason)
return envelope
}
func caringDecisionMetadata(
request api.CheckRequest,
descriptor *api.CaringAccessDescriptor,
policyMetadata api.CaringPolicyMetadata,
findings []api.CaringConformanceFinding,
) *api.CaringDecisionMetadata {
profile := firstNonEmpty(policyMetadata.Profile, api.CaringProfileCaring040RC2)
if descriptor != nil && descriptor.Profile != "" {
profile = descriptor.Profile
}
metadata := &api.CaringDecisionMetadata{
Profile: profile,
ConformanceFindings: append([]api.CaringConformanceFinding(nil), findings...),
}
metadata.ConformanceFindings = append(metadata.ConformanceFindings, policyMetadataFindings(policyMetadata)...)
if descriptor == nil {
metadata.ConformanceFindings = append(metadata.ConformanceFindings, api.CaringConformanceFinding{
Code: "RULE-CARING-DESCRIPTOR-MISSING",
Severity: "warning",
Message: "delegated rule backend result did not include a CARING descriptor",
Fields: []string{"caring_context"},
})
return metadata
}
descriptorCopy := *descriptor
metadata.Descriptor = &descriptorCopy
metadata.RestrictionsEvaluated = append([]api.Restriction(nil), descriptor.Restrictions...)
metadata.ExposureModes = append([]api.ExposureMode(nil), descriptor.ExposureModes...)
metadata.DerivedCapabilities = append([]api.CaringDerivedCapability(nil), descriptor.DerivedCapabilities...)
if descriptor.ExposureEvent != "" {
scope := descriptor.Scope
metadata.ExposureEvent = &api.CaringExposureEvent{
ID: decisionID("rule", "", "", request, api.DecisionEffectAllow, string(descriptor.ExposureEvent)),
Type: descriptor.ExposureEvent,
Actor: request.Subject.ID,
Subject: request.Subject.ID,
Descriptor: &descriptorCopy,
Scope: &scope,
Planes: append([]api.Plane(nil), descriptor.Planes...),
CapabilitiesUsed: append([]api.Capability(nil), descriptor.Capabilities...),
ExposureModes: append([]api.ExposureMode(nil), descriptor.ExposureModes...),
Reason: "delegated rule decision carries CARING exposure event hook",
AuthoritySource: EvaluatorName,
}
}
return metadata
}
func policyMetadataFindings(metadata api.CaringPolicyMetadata) []api.CaringConformanceFinding {
if metadata.Profile == "" {
return nil
}
var findings []api.CaringConformanceFinding
addMissing := func(empty bool, field, label string) {
if empty {
findings = append(findings, api.CaringConformanceFinding{
Code: "RULE-CARING-METADATA-GAP",
Severity: "warning",
Message: "backend-native rule artifact does not directly represent CARING " + label,
Fields: []string{field},
})
}
}
addMissing(len(metadata.CanonicalRoles) == 0, "caring.canonical_roles", "canonical roles")
addMissing(len(metadata.OrganizationRelations) == 0, "caring.organization_relations", "organization relations")
addMissing(len(metadata.Scopes) == 0, "caring.scopes", "scopes")
addMissing(len(metadata.Planes) == 0, "caring.planes", "planes")
addMissing(len(metadata.Capabilities) == 0, "caring.capabilities", "capabilities")
addMissing(len(metadata.ExposureModes) == 0, "caring.exposure_modes", "exposure modes")
return findings
}
func failureDiagnostics(backendName string, request EvaluationRequest, kind FailureKind, err error) map[string]any {
diagnostics := map[string]any{}
addRuleDiagnostics(diagnostics, backendName, request, string(kind))
if err != nil {
diagnostics["error"] = err.Error()
}
return diagnostics
}
func addRuleDiagnostics(diagnostics map[string]any, backendName string, request EvaluationRequest, failure string) {
diagnostics["adapter"] = "rule"
diagnostics["backend"] = backendName
diagnostics["mode"] = DelegatedMode
diagnostics["language"] = string(request.Language)
if failure != "" {
diagnostics["rule_failure"] = failure
}
if request.PolicyPackage != "" {
diagnostics["policy_package"] = request.PolicyPackage
diagnostics["policy_version"] = request.PolicyVersion
}
}
func failureReason(kind FailureKind) string {
switch kind {
case FailureStalePolicy:
return "rule_policy_stale"
case FailurePartialResult:
return "rule_partial_result"
case FailureInvalidRequest:
return "rule_request_incomplete"
case FailureUnsupportedPolicy:
return "rule_policy_unsupported"
default:
return "rule_backend_unavailable"
}
}
func failureFinding(kind FailureKind) api.CaringConformanceFinding {
code := "RULE-BACKEND-UNAVAILABLE"
message := "Rule backend was unavailable; flex-auth denied the delegated request fail-closed."
switch kind {
case FailureStalePolicy:
code = "RULE-POLICY-STALE"
message = "Rule backend policy version was stale; flex-auth denied fail-closed."
case FailurePartialResult:
code = "RULE-PARTIAL-RESULT"
message = "Rule backend returned a partial result; flex-auth denied fail-closed."
case FailureInvalidRequest:
code = "RULE-REQUEST-INCOMPLETE"
message = "Request could not be translated to canonical rule input; flex-auth denied fail-closed."
case FailureUnsupportedPolicy:
code = "RULE-POLICY-UNSUPPORTED"
message = "Rule backend cannot represent the imported policy artifact; flex-auth denied fail-closed."
}
return api.CaringConformanceFinding{Code: code, Severity: "error", Message: message}
}
func conformanceFindings(decision api.DecisionEnvelope) []api.CaringConformanceFinding {
if decision.Caring == nil {
return nil
}
return decision.Caring.ConformanceFindings
}
func expectationMatches(expected, actual api.DecisionExpectation) bool {
if expected.Effect != actual.Effect {
return false
}
if expected.Reason != "" && expected.Reason != actual.Reason {
return false
}
if len(expected.Obligations) > 0 && !reflect.DeepEqual(expected.Obligations, actual.Obligations) {
return false
}
return true
}
func firstDescriptor(values ...*api.CaringAccessDescriptor) *api.CaringAccessDescriptor {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func decisionID(backendName, policyPackage, policyVersion string, request api.CheckRequest, effect api.DecisionEffect, reason string) string {
data, _ := json.Marshal(struct {
Adapter string `json:"adapter"`
Backend string `json:"backend"`
PolicyPackage string `json:"policy_package,omitempty"`
PolicyVersion string `json:"policy_version,omitempty"`
Request api.CheckRequest `json:"request"`
Effect api.DecisionEffect `json:"effect"`
Reason string `json:"reason"`
}{
Adapter: EvaluatorName,
Backend: backendName,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
Request: request,
Effect: effect,
Reason: reason,
})
sum := sha256.Sum256(data)
return "decision:rule:" + hex.EncodeToString(sum[:8])
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func copyMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for key, value := range in {
out[key] = value
}
return out
}

View File

@@ -0,0 +1,269 @@
package rule_test
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/netkingdom/flex-auth/internal/adapters/rule"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestCanonicalInputFromCheck(t *testing.T) {
adapter := newAdapter(t, &fakeBackend{})
decision, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:input",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
Context: map[string]any{"purpose": "support"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if decision.Diagnostics["input_seen"] != true {
t.Fatalf("backend did not receive canonical input: %+v", decision.Diagnostics)
}
}
func TestPolicyArtifactFromPackagePreservesRegoAndFixtures(t *testing.T) {
pkg := loadPolicy(t)
artifact := rule.PolicyArtifactFromPackage(pkg)
if artifact.ID != "markitect.documents.internal-read" || artifact.Version != "v1" {
t.Fatalf("artifact metadata = %+v", artifact)
}
if artifact.Language != rule.LanguageRego {
t.Fatalf("Language = %q", artifact.Language)
}
if artifact.Module != pkg.RegoModule {
t.Fatal("Rego module changed during artifact creation")
}
if len(artifact.Fixtures) != len(pkg.Fixtures) {
t.Fatalf("fixtures = %d; want %d", len(artifact.Fixtures), len(pkg.Fixtures))
}
if artifact.Caring.Profile != api.CaringProfileCaring040RC2 {
t.Fatalf("Caring profile = %q", artifact.Caring.Profile)
}
}
func TestAdapterCheckWrapsRuleResult(t *testing.T) {
backend := &fakeBackend{
result: rule.EvaluationResult{
Effect: api.DecisionEffectRedact,
Reason: "masked_internal_document",
MatchedRule: "rule.mask_internal",
PolicyVersion: "v2",
Obligations: []api.Obligation{
{Type: "mask_fields", Parameters: map[string]any{"fields": []string{"email"}}},
},
Diagnostics: map[string]any{"backend_trace": "trace-1"},
CaringDescriptor: caringDescriptor(),
ConformanceFindings: []api.CaringConformanceFinding{
{Code: "RULE-MASKED", Severity: "info", Message: "masked internal document"},
},
},
}
adapter := newAdapter(t, backend)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:redact",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectRedact || got.Reason != "masked_internal_document" {
t.Fatalf("decision = %s/%s", got.Effect, got.Reason)
}
if got.Provenance.Evaluator != "rule-pdp/opa" || got.MatchedPolicyVersion != "v2" {
t.Fatalf("provenance = %+v matched=%s", got.Provenance, got.MatchedPolicyVersion)
}
if len(got.Obligations) != 1 || got.Obligations[0].Type != "mask_fields" {
t.Fatalf("obligations = %+v", got.Obligations)
}
if got.Caring == nil || got.Caring.Descriptor == nil || got.Caring.ExposureEvent == nil {
t.Fatalf("CARING metadata = %+v", got.Caring)
}
}
func TestAdapterFailsClosedOnStalePolicy(t *testing.T) {
backend := &fakeBackend{
err: rule.NewBackendError(rule.FailureStalePolicy, "evaluate", errors.New("policy revision too old")),
}
adapter := newAdapter(t, backend)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:stale",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
CaringContext: caringDescriptor(),
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if got.Effect != api.DecisionEffectDeny || got.Reason != "rule_policy_stale" {
t.Fatalf("decision = %s/%s; want stale deny", got.Effect, got.Reason)
}
if got.Diagnostics["rule_failure"] != "stale_policy" {
t.Fatalf("diagnostics = %+v", got.Diagnostics)
}
if got.Caring.ConformanceFindings[0].Code != "RULE-POLICY-STALE" {
t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0])
}
}
func TestBatchCheckPreservesOrder(t *testing.T) {
backend := &fakeBackend{
batch: []rule.EvaluationResult{
{Effect: api.DecisionEffectAllow, Reason: "first"},
{Effect: api.DecisionEffectDeny, Reason: "second"},
},
}
adapter := newAdapter(t, backend)
got, err := adapter.BatchCheck(context.Background(), api.BatchCheckRequest{
ID: "batch:rule",
Subject: api.SubjectRef{ID: "user:alice"},
Action: "read",
Resources: []api.ResourceRef{
{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
{ID: "document:missing", Type: "document", System: "markitect-tool"},
},
})
if err != nil {
t.Fatalf("BatchCheck: %v", err)
}
if len(got) != 2 || got[0].Reason != "first" || got[1].Reason != "second" {
t.Fatalf("batch = %+v", got)
}
}
func TestEvaluateFixturesComparesExpectations(t *testing.T) {
backend := &fakeBackend{
result: rule.EvaluationResult{Effect: api.DecisionEffectDeny, Reason: "no_matching_rule"},
}
adapter := newAdapter(t, backend)
results := adapter.EvaluateFixtures(context.Background(), []api.PolicyFixture{
{
ID: "fixture:deny",
Request: api.CheckRequest{
Subject: api.SubjectRef{ID: "user:bob"},
Action: "read",
Resource: api.ResourceRef{ID: "document:internal-note", Type: "document", System: "markitect-tool"},
},
Expect: api.DecisionExpectation{Effect: api.DecisionEffectDeny, Reason: "no_matching_rule"},
},
})
if len(results) != 1 || !results[0].Passed {
t.Fatalf("fixture results = %+v", results)
}
}
func TestImportPolicyDelegatesArtifact(t *testing.T) {
backend := &fakeBackend{}
adapter := newAdapter(t, backend)
pkg := loadPolicy(t)
report, err := adapter.ImportPolicy(context.Background(), pkg)
if err != nil {
t.Fatalf("ImportPolicy: %v", err)
}
if report.ArtifactID != pkg.Metadata.ID || backend.artifact.Module != pkg.RegoModule {
t.Fatalf("report = %+v artifact = %+v", report, backend.artifact)
}
}
func newAdapter(t *testing.T, backend *fakeBackend) *rule.Adapter {
t.Helper()
adapter, err := rule.New(backend, rule.Options{
BackendName: "opa",
PolicyPackage: "markitect.documents.internal-read",
PolicyVersion: "v1",
Language: rule.LanguageRego,
Caring: api.CaringPolicyMetadata{
Profile: api.CaringProfileCaring040RC2,
CanonicalRoles: []api.CanonicalRole{api.CanonicalRoleDoer},
Planes: []api.Plane{api.PlaneData},
Capabilities: []api.Capability{api.CapabilityView},
ExposureModes: []api.ExposureMode{api.ExposureModeMasked},
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
return adapter
}
func loadPolicy(t *testing.T) *policy.Package {
t.Helper()
pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "..", "examples", "caring", "policy_package.md"))
if err != nil {
t.Fatalf("LoadAndValidateFile: %v", err)
}
return pkg
}
func caringDescriptor() *api.CaringAccessDescriptor {
return &api.CaringAccessDescriptor{
ID: "descriptor:rule-reader",
Profile: api.CaringProfileCaring040RC2,
SubjectType: api.SubjectTypeHuman,
OrganizationRelation: api.OrganizationRelationCustomer,
CanonicalRole: api.CanonicalRoleDoer,
Scope: api.CaringScope{Level: api.ScopeLevelResource, ID: "document:internal-note"},
Planes: []api.Plane{api.PlaneData},
Capabilities: []api.Capability{api.CapabilityView},
ExposureModes: []api.ExposureMode{api.ExposureModeMasked},
Restrictions: []api.Restriction{api.RestrictionExportBlocked},
ExposureEvent: api.ExposureEventSupport,
}
}
type fakeBackend struct {
result rule.EvaluationResult
err error
batch []rule.EvaluationResult
artifact rule.PolicyArtifact
}
func (b *fakeBackend) Evaluate(_ context.Context, request rule.EvaluationRequest) (rule.EvaluationResult, error) {
if request.Input["subject"] != nil && b.result.Diagnostics == nil {
b.result.Diagnostics = map[string]any{"input_seen": true}
}
return b.result, b.err
}
func (b *fakeBackend) BatchEvaluate(_ context.Context, requests []rule.EvaluationRequest) ([]rule.EvaluationResult, error) {
if b.batch != nil {
return b.batch, nil
}
results := make([]rule.EvaluationResult, len(requests))
for i := range results {
results[i] = b.result
}
return results, b.err
}
func (b *fakeBackend) ImportPolicy(_ context.Context, artifact rule.PolicyArtifact) (rule.PolicyImportReport, error) {
b.artifact = artifact
return rule.PolicyImportReport{
ArtifactID: artifact.ID,
Version: artifact.Version,
Language: artifact.Language,
BackendRef: "opa:" + artifact.ID,
}, nil
}
func (b *fakeBackend) Health(context.Context) error {
return nil
}

View File

@@ -0,0 +1,3 @@
// Package rule defines the delegated rule-PDP boundary for OPA/Rego,
// Cedar-style policy services, and other policy-language backends.
package rule

View File

@@ -0,0 +1,26 @@
package rule
import (
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/pkg/api"
)
// PolicyArtifactFromPackage preserves the extracted Rego module and metadata
// from a flex-auth Rego-in-Markdown package.
func PolicyArtifactFromPackage(pkg *policy.Package) PolicyArtifact {
tests := make([]string, 0, len(pkg.TestBlocks))
for _, block := range pkg.TestBlocks {
tests = append(tests, block.Body)
}
return PolicyArtifact{
ID: pkg.Metadata.ID,
Version: pkg.Metadata.Version,
Language: LanguageRego,
Package: pkg.Metadata.Package,
Module: pkg.RegoModule,
Tests: tests,
Fixtures: append([]api.PolicyFixture(nil), pkg.Fixtures...),
Caring: pkg.Metadata.Caring,
Metadata: copyMap(pkg.Metadata.Metadata),
}
}

Some files were not shown because too many files have changed in this diff Show More