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>
15 KiB
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
usertype. Topaz's authorizer.Is treatsuseras the canonical identity subject. Theprincipal_typedistinction lives inpropertiesso 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
teamtype is possible but adds an indirection. Theteam:id-prefix keeps semantics legible without forking the type. - Labels and trust_zone in
propertiesrather 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:
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:
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:
- gRPC (Topaz's native API) — primary. Type-safe, generated Go
clients (
github.com/aserto-dev/topaz/pkg/cliexposes the protos), low overhead. Authorizer.Is, DecisionTree, Reader/Writer/Model. - 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.
- 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)
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 declaringuser,group,tenant,knowledge_base,documentobject types and relationsmember,parent,owner_team,reader,steward, plus the permission expressions forread,query,search,export,admin.policy/markitect.documents.rego— Rego module matching the Rego-in-Markdown example in ADR-002 (internal document read gated onreaderrelation orstewardrole). 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 fromexamples/markitect/resource_manifest.yamlplus 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-shotseedcontainer (pushes the manifest, objects, relations via the directory REST gateway) + a one-shotprobecontainer that calls the directorycheckAPI 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
- 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. - Identity objects are Topaz-canonical, not flex-auth-canonical.
Topaz expects an
identityobject type with anidentifierrelation to auserobject whenidentity_context.type == IDENTITY_TYPE_SUB. flex-auth's subject model is flat (a Subject is the principal). The adapter materializes Topazidentityobjects from the flex-auth subject registry at directory-import time, mapping subject id → identity → user. The standalone path doesn't need this indirection. - 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->readetc.) 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. - The Topaz config schema is not stable across versions. Keys
moved between
directory:anddirectory_service:; relation rewrites use->only inpermissions:notrelations:;disable_tlsis 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.jsonwith 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 —
Checkreturns the canonical envelope; the same code path serves standalone evaluation and Topaz-delegated evaluation; only the evaluator andds.*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
DirectorySnapshotand 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 fieldprovenance.directory_etagis reserved for this. - Group overage / freshness. Out of scope for this spike; handled by directory resolver adapters in FLEX-WP-0004 T05.