diff --git a/docs/topaz-mapping-spike.md b/docs/topaz-mapping-spike.md new file mode 100644 index 0000000..620a14f --- /dev/null +++ b/docs/topaz-mapping-spike.md @@ -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:}` | `display_name` from `preferred_username` or claims | +| Subject (service account) | `object{type:"user", id:}` with `properties.principal_type:"service"` | Same type; principal_type carried in properties | +| Group | `object{type:"group", id:}` | Members via `member` relation | +| Team | `object{type:"group", id:"team:"}` | Modeled as a kind of group; slug-prefixed for clarity | +| Tenant | `object{type:"tenant", id:}` | 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:, 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: ` | Same | +| Action | Topaz permission name | `read`, `query`, `search`, `package`, `export`, `workflow_run`, `admin` | +| Relationship fact (subject↔resource) | `relation{subject, name:, 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.` +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": "", + "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. diff --git a/examples/topaz/README.md b/examples/topaz/README.md new file mode 100644 index 0000000..fcfef91 --- /dev/null +++ b/examples/topaz/README.md @@ -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. diff --git a/examples/topaz/bundle/policy/markitect.documents.rego b/examples/topaz/bundle/policy/markitect.documents.rego new file mode 100644 index 0000000..65045cb --- /dev/null +++ b/examples/topaz/bundle/policy/markitect.documents.rego @@ -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, + }) +} diff --git a/examples/topaz/cfg/config.yaml b/examples/topaz/cfg/config.yaml new file mode 100644 index 0000000..e464998 --- /dev/null +++ b/examples/topaz/cfg/config.yaml @@ -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 diff --git a/examples/topaz/data/objects.json b/examples/topaz/data/objects.json new file mode 100644 index 0000000..6e90dd8 --- /dev/null +++ b/examples/topaz/data/objects.json @@ -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"} + } + ] +} diff --git a/examples/topaz/data/relations.json b/examples/topaz/data/relations.json new file mode 100644 index 0000000..aded767 --- /dev/null +++ b/examples/topaz/data/relations.json @@ -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"} + ] +} diff --git a/examples/topaz/docker-compose.yml b/examples/topaz/docker-compose.yml new file mode 100644 index 0000000..eaf4d4a --- /dev/null +++ b/examples/topaz/docker-compose.yml @@ -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: diff --git a/examples/topaz/manifest.yaml b/examples/topaz/manifest.yaml new file mode 100644 index 0000000..5205c19 --- /dev/null +++ b/examples/topaz/manifest.yaml @@ -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 diff --git a/examples/topaz/policy/markitect.documents.rego b/examples/topaz/policy/markitect.documents.rego new file mode 100644 index 0000000..65045cb --- /dev/null +++ b/examples/topaz/policy/markitect.documents.rego @@ -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, + }) +} diff --git a/examples/topaz/scripts/probe.sh b/examples/topaz/scripts/probe.sh new file mode 100755 index 0000000..888f9da --- /dev/null +++ b/examples/topaz/scripts/probe.sh @@ -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 < $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" diff --git a/examples/topaz/scripts/seed.sh b/examples/topaz/scripts/seed.sh new file mode 100755 index 0000000..f1126d1 --- /dev/null +++ b/examples/topaz/scripts/seed.sh @@ -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" diff --git a/workplans/FLEX-WP-0005-foundations-and-topaz-alignment.md b/workplans/FLEX-WP-0005-foundations-and-topaz-alignment.md index 4f004e4..8bbe70e 100644 --- a/workplans/FLEX-WP-0005-foundations-and-topaz-alignment.md +++ b/workplans/FLEX-WP-0005-foundations-and-topaz-alignment.md @@ -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" ```