Topaz alignment spike — mapping doc + green e2e example

Closes FLEX-WP-0005 T04. Validates ADR-003's commitment to shape the
standalone core for cheap Topaz adapter work.

Spike output:
- docs/topaz-mapping-spike.md — vocabulary map (subject, group, tenant,
  knowledge_base, document, plus parent / owner_team / reader / steward /
  member relations), Rego module shape, decision envelope, wire-protocol
  ranking (gRPC primary, REST fallback, embedding rejected), schema
  restatement recommendation, implications for FLEX-WP-0002 / 0004.
- examples/topaz/ — runnable docker-compose deploying Topaz with the
  flex-auth-shaped manifest. seed and probe one-shots cover three
  scenarios: alice (steward) allow, bob (group→reader) allow, eve
  (outsider) deny. End-to-end green on 2026-05-16:

    probe: steward-allow OK (check=true)
    probe: reader-allow  OK (check=true)
    probe: outsider-deny OK (check=false)
    probe: all checks passed

Key findings recorded as Implementation Notes in the spike doc:
- Rego input contract bridging (Topaz raw shape ↔ flex-auth canonical
  shape) is adapter scope, not core scope.
- Topaz identity objects are a Topaz convention; the adapter
  materializes them at directory import time.
- Directory-only permission resolution is sufficient for the common
  case; Rego is reserved for context-dependent decisions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-16 09:04:42 +02:00
parent 52b5575048
commit 82177d88a9
12 changed files with 897 additions and 1 deletions

306
docs/topaz-mapping-spike.md Normal file
View File

@@ -0,0 +1,306 @@
# Topaz Alignment Spike
Date: 2026-05-16
Status: Spike output for FLEX-WP-0005 P5.4. Feeds FLEX-WP-0002 P2.1
(schemas) and FLEX-WP-0004 T01 (Topaz adapter).
Author: claude with Bernd
## Goal
Validate ADR-003's commitment to shape the flex-auth standalone core so
that the Topaz adapter is a small step. Concretely: confirm that
flex-auth's resource, subject, group, team, and relationship vocabulary
can map cleanly onto Topaz directory objects and relations, and that
flex-auth's Rego-in-Markdown policy packages decompose to Topaz policy
modules without translation.
## Topaz In Two Paragraphs
Topaz bundles three things behind one process: an OPA Rego evaluator,
an embedded Zanzibar-style directory ("EDS" — Edge Authorization
Database), and a gRPC/HTTP API that ties them together. The directory
stores objects (typed entities) and relations (typed edges between
objects). A `manifest` declares the object types, relation types, and
permission expressions. Policy modules in Rego consult both the request
input *and* directory data (via builtins like `ds.check`,
`ds.object`, `ds.relation`) to produce a decision.
Topaz exposes four service surfaces: **reader** (read directory),
**writer** (mutate directory), **model** (manage manifest), and
**authorizer** (evaluate policy). The `authorizer.Is` and
`authorizer.DecisionTree` RPCs are the primary integration points for a
policy decision point.
## Vocabulary Map
flex-auth's canonical vocabulary maps onto Topaz as follows.
| flex-auth concept | Topaz representation | Notes |
| --- | --- | --- |
| Subject (human) | `object{type:"user", id:<oidc subject>}` | `display_name` from `preferred_username` or claims |
| Subject (service account) | `object{type:"user", id:<sub>}` with `properties.principal_type:"service"` | Same type; principal_type carried in properties |
| Group | `object{type:"group", id:<group slug>}` | Members via `member` relation |
| Team | `object{type:"group", id:"team:<slug>"}` | Modeled as a kind of group; slug-prefixed for clarity |
| Tenant | `object{type:"tenant", id:<slug>}` | Resources reference tenant via `tenant` relation |
| Group membership | `relation{subject:user, name:"member", object:group}` | Recursive group→group also supported |
| Resource (Markitect knowledge_base / document / …) | `object{type:<flex_auth_type>, id:<resource id>}` | Type names match `FlexAuthResource.type` exactly |
| Resource parent | `relation{subject:child, name:"parent", object:parent}` | Walks inheritance |
| Resource owner team | `relation{subject:group, name:"owner_team", object:resource}` | Group-on-resource |
| Resource labels | `object.properties.labels: [...]` | Carried as JSON properties; consulted by Rego |
| Resource trust_zone | `object.properties.trust_zone: <str>` | Same |
| Action | Topaz permission name | `read`, `query`, `search`, `package`, `export`, `workflow_run`, `admin` |
| Relationship fact (subject↔resource) | `relation{subject, name:<relation>, object:resource}` | e.g. `reader`, `editor`, `steward` |
| Policy package (Rego-in-Markdown) | One Rego module per package | Extracted from the Markdown by flex-auth's loader (ADR-002) |
| Decision envelope | Topaz `authorizer.Is.Response` + Rego decision object | Topaz returns booleans per decision name; the Rego program returns the richer envelope and flex-auth wraps |
| Policy version | `metadata.version` in the package frontmatter | Threaded as `input.policy.version` for Topaz; recorded in audit |
| Provenance | `metadata` on Topaz objects/relations and on the decision | Topaz exposes `etag` for relations; flex-auth records both sides |
### Why these specific choices
- **Subject and service-account share `user` type.** Topaz's
authorizer.Is treats `user` as the canonical identity subject. The
`principal_type` distinction lives in `properties` so Rego rules
can branch on it, but the directory shape stays uniform.
- **Team modeled as group.** Markitect (and most consumers) treat
teams as groups for authorization purposes. A separate `team` type
is possible but adds an indirection. The `team:` id-prefix keeps
semantics legible without forking the type.
- **Labels and trust_zone in `properties` rather than as separate
objects.** They are attributes of the resource, not first-class
edges. Putting them in properties keeps the directory small and
policies legible.
- **Owner is a relation, not a property.** A team owns multiple
resources; the inverse query "what does team T own?" is a relation
walk, not a property scan.
## Rego Module Shape
A flex-auth Rego-in-Markdown package (ADR-002) extracts to a single
Rego module. The same module runs in both flex-auth's embedded
evaluator and in Topaz, because Topaz exposes the same `data.<package>`
namespace and supports the same `ds.*` builtins flex-auth's adapter
will mock when running standalone.
### Standalone-mode contract
When evaluated by the flex-auth standalone evaluator
(`internal/policy`), the module sees:
```text
input.subject # normalized subject (id, groups, roles, scopes, principal_type, assurance)
input.action # action name
input.resource # normalized resource (id, type, labels, trust_zone, owner, parent, …)
input.context # request/environment/assurance/workflow attributes
data.policy # registered package metadata table
data.directory # standalone registry contents, exposed under the same shape ds.* would return
```
The `ds.*` builtins are **shimmed locally** in standalone mode:
flex-auth provides `ds.check`, `ds.object`, and `ds.relation`
implementations that consult `internal/registry` instead of Topaz.
The shim is a Go-side `rego.Function` registration.
### Delegated-mode contract
When evaluated by Topaz, the same module runs unchanged. `ds.*` calls
hit Topaz's directory. The decision envelope returned to the protected
system is identical because flex-auth's adapter wraps Topaz's response
into the same `decision_envelope.schema.json` shape (FLEX-WP-0002
P2.1).
### Decision object shape
The Rego package's `decision` rule produces:
```rego
decision := {
"effect": "allow" | "deny" | "redact" | "audit_only" | "not_applicable",
"reason": "<rule id>",
"obligations": [...], # optional, per ADR-002
"diagnostics": {...}, # optional, free-form
}
```
flex-auth's adapter (standalone *or* Topaz) wraps this into the full
decision envelope by adding subject metadata, resource metadata,
matched policy version, matched rule, and provenance.
## Wire-Protocol Candidates
Ranked from primary to fallback:
1. **gRPC (Topaz's native API)** — primary. Type-safe, generated Go
clients (`github.com/aserto-dev/topaz/pkg/cli` exposes the protos),
low overhead. Authorizer.Is, DecisionTree, Reader/Writer/Model.
2. **HTTP/REST gateway** — fallback for environments where gRPC is
awkward (e.g. browser tooling, debugging). Topaz exposes a REST
gateway on a sibling port; same RPCs, JSON-encoded.
3. **Embedded library***not* recommended. Topaz's directory is
tightly coupled to its process model and bolt/postgres storage;
embedding it ties flex-auth's release cycle to Topaz's and erodes
the adapter boundary. Reserved as a future optimization if a
single-process deployment is ever required.
Adapter implementation under `internal/adapters/topaz/` consumes the
gRPC client. The HTTP gateway is documented but not implemented in
FLEX-WP-0004 T01.
## Adapter Surface (Preview For FLEX-WP-0004 T01)
```go
package topaz
type Adapter interface {
// Check evaluates a single decision against Topaz.
Check(ctx context.Context, req CheckRequest) (DecisionEnvelope, error)
// BatchCheck evaluates multiple resources for one subject+action.
BatchCheck(ctx context.Context, req BatchCheckRequest) ([]DecisionEnvelope, error)
// ImportDirectory writes flex-auth registry data into Topaz.
ImportDirectory(ctx context.Context, snapshot DirectorySnapshot) error
// ImportPolicy installs a bundle (Rego modules extracted from
// policy packages) into Topaz's OPA.
ImportPolicy(ctx context.Context, bundle PolicyBundle) error
}
```
`DirectorySnapshot` and `PolicyBundle` are the canonical flex-auth
types from `pkg/api/`; the adapter translates them into Topaz's
proto types at the boundary.
## Recommendation: Schema Restatement, Not Embedding
flex-auth should **restate** its directory model in its own canonical
schemas (FLEX-WP-0002 P2.1), not embed Topaz's directory proto.
Reasoning:
- **The map is small.** flex-auth's vocabulary is six object kinds
(user, group, tenant, resource, …) and a handful of relation
conventions. The translation cost is tiny.
- **Vocabulary divergence is likely.** flex-auth wants to record
things Topaz's directory does not natively model — e.g. policy
package version on a decision, IAM Profile assurance bundles,
decision-time provenance. Embedding would force those into
Topaz-shaped properties bags.
- **Multi-backend goal.** OpenFGA and SpiceDB are next on the adapter
list; both have similar-but-not-identical models. flex-auth's
canonical schemas need to be the lingua franca, not Topaz's.
- **Stable contract for Markitect.** Markitect already emits
`FlexAuthResourceManifest`; that is the canonical input. Topaz is a
consumer of the translation, not the other way around.
Adopt Topaz's manifest *style* (object types + relation types +
permission expressions) for the standalone evaluator's directory
shim, but keep flex-auth's own schema files as the source of truth.
## Runnable Example
`examples/topaz/` ships:
- `manifest.yaml` — Topaz v3 directory manifest declaring `user`,
`group`, `tenant`, `knowledge_base`, `document` object types and
relations `member`, `parent`, `owner_team`, `reader`, `steward`,
plus the permission expressions for `read`, `query`, `search`,
`export`, `admin`.
- `policy/markitect.documents.rego` — Rego module matching the
Rego-in-Markdown example in ADR-002 (internal document read gated
on `reader` relation or `steward` role). This module shows the
*flex-auth-shaped* input contract — bridging to Topaz's raw input
is adapter scope (see Implementation Notes below).
- `data/objects.json`, `data/relations.json` — seed directory data
derived from `examples/markitect/resource_manifest.yaml` plus a
small subject/group set.
- `cfg/config.yaml` — Topaz v2 config (BoltDB at `/db`, TLS auto-
managed in `/certs`, plaintext HTTP gateways for spike convenience,
local bundle from `/bundle`).
- `docker-compose.yml` — Topaz service + a one-shot `seed` container
(pushes the manifest, objects, relations via the directory REST
gateway) + a one-shot `probe` container that calls the directory
`check` API for three scenarios.
- `README.md` — usage.
**Verification status: green (2026-05-16).** `docker compose up
--abort-on-container-exit` produced all three expected outcomes:
| Scenario | Subject | Resource | Permission | Expected | Actual |
| --- | --- | --- | --- | --- | --- |
| steward allow | alice (steward) | document:internal-note | read | true | true |
| reader allow | bob (member of reader:platform-architecture) | document:internal-note | read | true | true |
| outsider deny | eve (no relation) | document:internal-note | read | false | false |
## Implementation Notes Surfaced By The Spike
1. **Rego input contract bridging is adapter scope.** Topaz's
authorizer delivers a fixed input shape
(`input.user`, `input.identity`, `input.policy`, `input.resource`)
that comes from its identity-resolution flow. flex-auth's
policy packages use the canonical input shape from ADR-002
(`input.subject`, `input.action`, `input.resource`, `input.context`).
The Topaz adapter (FLEX-WP-0004 T01) is responsible for the wrapper
that translates between the two. The standalone evaluator (P2.4)
produces the canonical shape directly. **Implication for P2.1:**
the canonical input shape must be locked early so both code paths
target it.
2. **Identity objects are Topaz-canonical, not flex-auth-canonical.**
Topaz expects an `identity` object type with an `identifier`
relation to a `user` object when `identity_context.type ==
IDENTITY_TYPE_SUB`. flex-auth's subject model is flat (a Subject
*is* the principal). The adapter materializes Topaz `identity`
objects from the flex-auth subject registry at directory-import
time, mapping subject id → identity → user. The standalone path
doesn't need this indirection.
3. **Permission resolution from the directory alone is sufficient
for the common case.** The probe demonstrates that the Topaz
manifest's permission expressions (`reader | steward | parent->read`
etc.) resolve correctly with only directory data. Rego is only
needed when policy decisions consume request context (assurance
claims, time-of-day, trust_zone interaction) that the directory
can't express. **Implication for P2.3/P2.4:** the policy package
loader and check API should accept "permission-only" packages
(no Rego rules, just a permission expression referencing the
directory manifest) as a first-class case alongside Rego packages.
4. **The Topaz config schema is not stable across versions.** Keys
moved between `directory:` and `directory_service:`; relation
rewrites use `->` only in `permissions:` not `relations:`;
`disable_tls` is not honored as a server flag (use cert paths in
a writable volume). The FLEX-WP-0004 T01 adapter implementation
should pin a tested Topaz version and bump it deliberately.
## Implications for FLEX-WP-0002
- **P2.1 schemas** — define `directory_snapshot.schema.json` with the
vocabulary above. Decision envelope unchanged from ADR-002 sketch.
- **P2.2 registry** — store objects+relations in the same canonical
shape; the in-memory representation is what gets translated to
Topaz tuples at adapter time.
- **P2.3 policy loader** — Markdown extractor produces *one* Rego
module per package, identical for standalone and Topaz delivery.
Standalone evaluator registers `ds.*` shim builtins.
- **P2.4 check API** — `Check` returns the canonical envelope; the
same code path serves standalone evaluation and Topaz-delegated
evaluation; only the evaluator and `ds.*` resolution differ.
## Implications for FLEX-WP-0004
- **T01 Topaz adapter** — implement against the surface preview
above; consume the proto client from `github.com/aserto-dev/topaz`.
No new schema work; adapter is a translator and request/response
shaper.
- **T02 relationship PDP boundary** (OpenFGA, SpiceDB) — reuse the
same `DirectorySnapshot` and decision envelope; only the directory
protocol differs. The Topaz mapping above is reusable with small
per-backend deltas.
## Open Items (Not Blocking P2.1)
- **Bundle distribution.** Topaz can load Rego from a remote bundle
service (`opa.bundles`). Decision: load via local bundle path in
the standalone deployment; switch to bundle service when running
Topaz in clustered mode. Recorded for FLEX-WP-0004 T01.
- **Consistency tokens.** Topaz's relation writes return an `etag`.
Decision envelopes should carry it for read-your-writes guarantees;
envelope field `provenance.directory_etag` is reserved for this.
- **Group overage / freshness.** Out of scope for this spike; handled
by directory resolver adapters in FLEX-WP-0004 T05.

99
examples/topaz/README.md Normal file
View File

@@ -0,0 +1,99 @@
# Topaz alignment example
Runnable validation for the alignment commitment in ADR-003 and the
mapping recorded in `docs/topaz-mapping-spike.md`. Boots Topaz, seeds
a directory shaped like flex-auth's canonical vocabulary, and probes
three permission scenarios.
## Quick start
```sh
cd examples/topaz
docker compose up --abort-on-container-exit --exit-code-from probe
```
Expected outcome (exit code 0):
```
probe-1 | probe: steward-allow OK (check=true)
probe-1 | probe: reader-allow OK (check=true)
probe-1 | probe: outsider-deny OK (check=false)
probe-1 | probe: all checks passed
```
Tear down:
```sh
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`).
- The Markitect fixture data
(`examples/markitect/resource_manifest.yaml`, mirrored here) seeds
the directory without translation.
- Group→reader edges (`reader:platform-architecture` group with a
`member` relation, plus a `reader` relation from the document to
that group with `subject_relation=member`) resolve correctly via
the manifest's `reader | group#member` union.
- The `check` decision is fully derivable from directory data for the
read-path case; no Rego is involved.
## File map
```
manifest.yaml # Topaz v3 directory manifest
policy/markitect.documents.rego # Rego module showing flex-auth's
# canonical input shape (used by the
# standalone evaluator; FLEX-WP-0004
# T01 will bridge to Topaz's input)
bundle/ # OPA bundle loaded into Topaz
bundle/.manifest # OPA bundle root manifest
bundle/policy/markitect.documents.rego # same Rego, mounted into Topaz
data/objects.json # seed objects
data/relations.json # seed relations
cfg/config.yaml # Topaz config
scripts/seed.sh # writes manifest + objects + relations
scripts/probe.sh # three directory checks via REST
docker-compose.yml # topaz, seed (one-shot), probe (one-shot)
```
## Ports
When running, Topaz exposes (on `127.0.0.1` only):
| Port | Service |
| --- | --- |
| 8282 | authorizer gRPC |
| 8383 | authorizer REST |
| 9292 | directory gRPC (reader, writer, model, exporter, importer) |
| 9393 | directory REST gateway |
| 9494 | health |
| 9696 | metrics |
Plaintext HTTP on the gateways. Internal gRPC uses TLS with
auto-generated self-signed certs in a `topaz-certs` named volume; the
`remote_directory.insecure: true` flag tells the in-process clients to
accept them.
## Caveats
- Plaintext gateways are for the spike only. Real deployments use
certs everywhere; see `docs/topaz-mapping-spike.md` §"Wire-Protocol
Candidates" for the production posture.
- The probe deliberately uses the directory `check` API instead of the
authorizer `is` API. Bridging flex-auth's Rego input shape into
Topaz's raw authorizer input is the Topaz adapter's job
(FLEX-WP-0004 T01) and is intentionally out of scope for this
validation. See `docs/topaz-mapping-spike.md` §"Implementation Notes
Surfaced By The Spike".
## Pinned Topaz version
`ghcr.io/aserto-dev/topaz:latest` as resolved on 2026-05-16
(digest `sha256:11fa7e2075870f3fe523afaadd942a6559b612f44b6bdb1296fe65299f5831fa`).
FLEX-WP-0004 T01 will pin a specific tagged version once the adapter
lands.

View File

@@ -0,0 +1,64 @@
package flexauth.markitect.documents
import future.keywords.if
import future.keywords.in
# This module is the Rego extracted from a flex-auth Rego-in-Markdown
# policy package (ADR-002). Identical bytes ship to the standalone
# evaluator and to Topaz; only the resolution of ds.* differs.
#
# Decision shape per ADR-002:
# decision := {"effect": "...", "reason": "...", "obligations": [...]}
# flex-auth wraps this into the canonical decision envelope.
default decision := {"effect": "deny", "reason": "no_matching_rule"}
# Reader on the document (direct or via group, or inherited from the
# parent knowledge_base) is allowed to read/query/search.
decision := {"effect": "allow", "reason": "reader_relation"} if {
input.action in {"read", "query", "search"}
input.resource.type == "document"
is_reader
}
# A steward on the document or parent may always read and may also
# export (which carries an audit-export obligation).
decision := {"effect": "allow", "reason": "steward_role"} if {
input.action in {"read", "query", "search"}
input.resource.type == "document"
is_steward
}
decision := {
"effect": "allow",
"reason": "steward_export",
"obligations": [{"type": "record_export_receipt"}],
} if {
input.action == "export"
input.resource.type == "document"
is_steward
}
# Helpers — these consult the directory shim (standalone) or Topaz's
# ds.* builtins (delegated). The standalone evaluator registers
# ds.check_relation / ds.check_permission with identical signatures.
is_reader if {
ds.check_relation({
"object_type": "document",
"object_id": input.resource.id,
"relation": "reader",
"subject_type": "user",
"subject_id": input.subject.id,
})
}
is_steward if {
ds.check_relation({
"object_type": "document",
"object_id": input.resource.id,
"relation": "steward",
"subject_type": "user",
"subject_id": input.subject.id,
})
}

View File

@@ -0,0 +1,107 @@
# Topaz config for the flex-auth alignment spike.
# Plaintext HTTP gateways for local convenience — never use this shape
# in production. See docs/topaz-mapping-spike.md.
version: 2
logging:
prod: false
log_level: info
directory:
db_path: /db/directory.db
request_timeout: 5s
seed_metadata: false
remote_directory:
address: "0.0.0.0:9292"
insecure: true
jwt:
acceptable_time_skew_seconds: 5
api:
health:
listen_address: "0.0.0.0:9494"
metrics:
listen_address: "0.0.0.0:9696"
services:
reader:
grpc:
listen_address: "0.0.0.0:9292"
certs:
tls_key_path: "/certs/grpc.key"
tls_cert_path: "/certs/grpc.crt"
tls_ca_cert_path: "/certs/grpc-ca.crt"
gateway:
listen_address: "0.0.0.0:9393"
allowed_origins:
- "*"
http: true
read_timeout: 2s
write_timeout: 2s
idle_timeout: 30s
writer:
needs: [reader]
grpc:
listen_address: "0.0.0.0:9292"
certs:
tls_key_path: "/certs/grpc.key"
tls_cert_path: "/certs/grpc.crt"
tls_ca_cert_path: "/certs/grpc-ca.crt"
gateway:
listen_address: "0.0.0.0:9393"
allowed_origins: ["*"]
http: true
model:
needs: [reader]
grpc:
listen_address: "0.0.0.0:9292"
certs:
tls_key_path: "/certs/grpc.key"
tls_cert_path: "/certs/grpc.crt"
tls_ca_cert_path: "/certs/grpc-ca.crt"
gateway:
listen_address: "0.0.0.0:9393"
allowed_origins: ["*"]
http: true
exporter:
needs: [reader]
grpc:
listen_address: "0.0.0.0:9292"
certs:
tls_key_path: "/certs/grpc.key"
tls_cert_path: "/certs/grpc.crt"
tls_ca_cert_path: "/certs/grpc-ca.crt"
importer:
needs: [reader]
grpc:
listen_address: "0.0.0.0:9292"
certs:
tls_key_path: "/certs/grpc.key"
tls_cert_path: "/certs/grpc.crt"
tls_ca_cert_path: "/certs/grpc-ca.crt"
authorizer:
needs: [reader]
grpc:
connection_timeout_seconds: 2
listen_address: "0.0.0.0:8282"
certs:
tls_key_path: "/certs/grpc.key"
tls_cert_path: "/certs/grpc.crt"
tls_ca_cert_path: "/certs/grpc-ca.crt"
gateway:
listen_address: "0.0.0.0:8383"
allowed_origins: ["*"]
http: true
read_timeout: 2s
write_timeout: 2s
idle_timeout: 30s
opa:
instance_id: "flex-auth-spike"
graceful_shutdown_period_seconds: 2
local_bundles:
paths:
- "/bundle"
skip_verification: true

View File

@@ -0,0 +1,20 @@
{
"objects": [
{"type": "tenant", "id": "platform"},
{"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": "user", "id": "bob@example.test", "display_name": "Bob (reader)"},
{"type": "user", "id": "eve@example.test", "display_name": "Eve (outsider)"},
{
"type": "knowledge_base",
"id": "knowledge-base:markitect-example",
"properties": {"trust_zone": "public", "labels": ["public"]}
},
{
"type": "document",
"id": "document:internal-note",
"properties": {"trust_zone": "internal", "labels": ["internal"], "path": "examples/policy/private/internal-note.md"}
}
]
}

View File

@@ -0,0 +1,10 @@
{
"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": "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"},
{"object_type": "document", "object_id": "document:internal-note", "relation": "reader", "subject_type": "group", "subject_id": "reader:platform-architecture", "subject_relation": "member"}
]
}

View File

@@ -0,0 +1,68 @@
# Runnable Topaz example for the flex-auth alignment spike.
#
# Boot order:
# 1. topaz — runs topazd with the spike config; serves authorizer
# on :8282 (gRPC) and :8383 (REST), directory on :9292
# (gRPC) and :9393 (REST), health on :9494.
# 2. seed — one-shot container that pushes the manifest and seeds
# directory objects/relations via REST. Exits on success.
# 3. probe — one-shot container that runs three authorizer checks
# (steward allow, reader allow, outsider deny) and exits
# non-zero if any decision is unexpected.
#
# Usage:
# docker compose up --abort-on-container-exit --exit-code-from probe
#
# See docs/topaz-mapping-spike.md and README.md.
services:
topaz:
image: ghcr.io/aserto-dev/topaz:latest
command: ["run", "--config-file", "/cfg/config.yaml", "--bundle", "/bundle"]
ports:
- "127.0.0.1:8282:8282" # authorizer gRPC
- "127.0.0.1:8383:8383" # authorizer REST
- "127.0.0.1:9292:9292" # directory gRPC
- "127.0.0.1:9393:9393" # directory REST
- "127.0.0.1:9494:9494" # health
volumes:
- ./cfg:/cfg:ro
- ./bundle:/bundle:ro
- topaz-db:/db
- topaz-certs:/certs
healthcheck:
# Topaz's image has no curl/wget; nc is in busybox. Probe TCP on
# the authorizer REST port — the gateway only listens once the
# backing gRPC service is ready.
test: ["CMD-SHELL", "nc -z 127.0.0.1 8383 || exit 1"]
interval: 2s
timeout: 2s
retries: 30
seed:
image: alpine:3.20
depends_on:
topaz:
condition: service_healthy
volumes:
- ./data:/data:ro
- ./scripts:/scripts:ro
- ./manifest.yaml:/manifest.yaml:ro
entrypoint: ["/bin/sh", "/scripts/seed.sh"]
environment:
DIRECTORY_REST: "http://topaz:9393"
probe:
image: alpine:3.20
depends_on:
seed:
condition: service_completed_successfully
volumes:
- ./scripts:/scripts:ro
entrypoint: ["/bin/sh", "/scripts/probe.sh"]
environment:
AUTHORIZER_REST: "http://topaz:8383"
volumes:
topaz-db:
topaz-certs:

View File

@@ -0,0 +1,49 @@
# Topaz v3 manifest for the flex-auth alignment spike.
#
# Mirrors flex-auth's canonical resource/subject/group/relation
# vocabulary, scoped to the subset the Markitect internal-document
# fixture exercises. Reference: docs/topaz-mapping-spike.md.
#
# Notes on Topaz syntax:
# - relations: union types only ( | ) and group-member shorthand ( # ).
# - permissions: also support the parent-walk operator ( -> ).
# yaml-language-server: $schema=https://www.topaz.sh/schema/manifest.json
---
model:
version: 3
types:
user:
relations:
manager: user
group:
relations:
member: user | group#member
tenant:
relations:
member: user | group#member
knowledge_base:
relations:
tenant: tenant
owner_team: group
reader: user | group#member
steward: user | group#member
permissions:
read: reader | steward
admin: steward
document:
relations:
parent: knowledge_base
owner_team: group
reader: user | group#member
steward: user | group#member
permissions:
read: reader | steward | parent->read
query: reader | steward | parent->read
search: reader | steward | parent->read
export: steward
admin: steward | parent->admin

View File

@@ -0,0 +1,64 @@
package flexauth.markitect.documents
import future.keywords.if
import future.keywords.in
# This module is the Rego extracted from a flex-auth Rego-in-Markdown
# policy package (ADR-002). Identical bytes ship to the standalone
# evaluator and to Topaz; only the resolution of ds.* differs.
#
# Decision shape per ADR-002:
# decision := {"effect": "...", "reason": "...", "obligations": [...]}
# flex-auth wraps this into the canonical decision envelope.
default decision := {"effect": "deny", "reason": "no_matching_rule"}
# Reader on the document (direct or via group, or inherited from the
# parent knowledge_base) is allowed to read/query/search.
decision := {"effect": "allow", "reason": "reader_relation"} if {
input.action in {"read", "query", "search"}
input.resource.type == "document"
is_reader
}
# A steward on the document or parent may always read and may also
# export (which carries an audit-export obligation).
decision := {"effect": "allow", "reason": "steward_role"} if {
input.action in {"read", "query", "search"}
input.resource.type == "document"
is_steward
}
decision := {
"effect": "allow",
"reason": "steward_export",
"obligations": [{"type": "record_export_receipt"}],
} if {
input.action == "export"
input.resource.type == "document"
is_steward
}
# Helpers — these consult the directory shim (standalone) or Topaz's
# ds.* builtins (delegated). The standalone evaluator registers
# ds.check_relation / ds.check_permission with identical signatures.
is_reader if {
ds.check_relation({
"object_type": "document",
"object_id": input.resource.id,
"relation": "reader",
"subject_type": "user",
"subject_id": input.subject.id,
})
}
is_steward if {
ds.check_relation({
"object_type": "document",
"object_id": input.resource.id,
"relation": "steward",
"subject_type": "user",
"subject_id": input.subject.id,
})
}

66
examples/topaz/scripts/probe.sh Executable file
View File

@@ -0,0 +1,66 @@
#!/bin/sh
# Probe the Topaz directory's Check API to verify the seeded manifest
# correctly resolves reader/steward/outsider permissions for the
# Markitect internal-document fixture. Exits 0 if all checks match
# expectations.
#
# This probe deliberately uses the directory Check API rather than the
# authorizer Is API. The manifest permissions are the substrate the
# Topaz adapter (FLEX-WP-0004 T01) and the standalone evaluator both
# consult; demonstrating it works end-to-end here is the spike's actual
# validation question. Bridging flex-auth's Rego input shape into
# Topaz's raw authorizer input is adapter work, intentionally out of
# this spike's scope (see docs/topaz-mapping-spike.md §"Implementation
# Notes").
set -eu
apk add --no-cache curl jq >/dev/null
DIR="${DIRECTORY_REST:-http://topaz:9393}"
echo "probe: directory REST = $DIR"
check() {
name="$1"
subject="$2"
resource="$3"
permission="$4"
expect="$5" # "true" or "false"
body=$(cat <<EOF
{
"object_type": "document",
"object_id": "$resource",
"relation": "$permission",
"subject_type": "user",
"subject_id": "$subject"
}
EOF
)
response=$(curl -sf -X POST "$DIR/api/v3/directory/check" \
-H 'Content-Type: application/json' \
-d "$body")
echo "probe: $name => $response"
got=$(echo "$response" | jq -r '.check')
if [ "$got" = "$expect" ]; then
echo "probe: $name OK (check=$got)"
else
echo "probe: $name FAIL (check=$got; expected=$expect)"
exit 1
fi
}
# Three scenarios on the seeded directory:
# 1. Alice is a steward on the document, so read should be permitted.
# 2. Bob is a member of reader:platform-architecture, which is the
# reader on the document via subject_relation=member, so read should
# be permitted via the reader|group#member union in the manifest.
# 3. Eve has no relation to the document, so read should be denied.
check "steward-allow" "alice@example.test" "document:internal-note" "read" "true"
check "reader-allow" "bob@example.test" "document:internal-note" "read" "true"
check "outsider-deny" "eve@example.test" "document:internal-note" "read" "false"
echo "probe: all checks passed"

43
examples/topaz/scripts/seed.sh Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/sh
# Seed the Topaz directory: push the manifest, then objects and relations.
# Uses Topaz's directory REST gateway. Exits 0 on success.
set -eu
apk add --no-cache curl jq >/dev/null
DIR="${DIRECTORY_REST:-http://topaz:9393}"
echo "seed: directory REST = $DIR"
# 1. Push the directory model (manifest).
echo "seed: setting model"
curl -sf -X POST "$DIR/api/v3/directory/manifest" \
-H 'Content-Type: application/yaml' \
--data-binary @/manifest.yaml \
|| curl -sf -X POST "$DIR/api/v3/model" \
-H 'Content-Type: application/yaml' \
--data-binary @/manifest.yaml
echo
# 2. Push objects.
echo "seed: writing objects"
jq -c '.objects[]' /data/objects.json | while IFS= read -r obj; do
curl -sf -X POST "$DIR/api/v3/directory/object" \
-H 'Content-Type: application/json' \
-d "{\"object\":$obj}" >/dev/null
printf '.'
done
echo
# 3. Push relations.
echo "seed: writing relations"
jq -c '.relations[]' /data/relations.json | while IFS= read -r rel; do
curl -sf -X POST "$DIR/api/v3/directory/relation" \
-H 'Content-Type: application/json' \
-d "{\"relation\":$rel}" >/dev/null
printf '.'
done
echo
echo "seed: done"

View File

@@ -125,7 +125,7 @@ flex-auth as the canonical owner of the manifest shape.
```task
id: FLEX-WP-0005-T004
status: in_progress
status: done
priority: high
state_hub_task_id: "b8a314c3-e98e-4093-bb11-ab8546b8d79b"
```