Implement Topaz adapter
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled

This commit is contained in:
2026-05-17 06:58:04 +02:00
parent 0fbb2a45c2
commit 1ce0181e8f
15 changed files with 1727 additions and 4 deletions

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

@@ -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

View File

@@ -0,0 +1,415 @@
package topaz
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
// Adapter delegates checks, directory writes, and policy bundle publication to
// Topaz while preserving flex-auth request and decision contracts.
type Adapter struct {
client Client
policyPackage string
policyVersion string
}
// New creates a Topaz adapter.
func New(client Client, options Options) (*Adapter, error) {
if client == nil {
return nil, fmt.Errorf("topaz client is required")
}
return &Adapter{
client: client,
policyPackage: options.PolicyPackage,
policyVersion: options.PolicyVersion,
}, nil
}
// Check evaluates one protected-system decision against Topaz.
func (a *Adapter) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
topazRequest, err := BuildCheckRequest(request)
if err != nil {
return a.failureEnvelope(request, DirectoryCheckRequest{}, FailureInvalidRequest, err), nil
}
result, err := a.client.Check(ctx, topazRequest)
if err != nil {
return a.failureEnvelope(request, topazRequest, failureKind(err), err), nil
}
if result.StaleDirectory {
return a.failureEnvelope(request, topazRequest, FailureStaleDirectory, nil), nil
}
if result.PartialResult {
return a.failureEnvelope(request, topazRequest, FailurePartialResult, nil), nil
}
envelope := a.envelope(request, topazRequest, result)
return envelope, nil
}
// BatchCheck evaluates resources in request order.
func (a *Adapter) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
for _, resource := range request.Resources {
decision, err := a.Check(ctx, api.CheckRequest{
ID: request.ID,
Subject: request.Subject,
Action: request.Action,
Resource: resource,
Context: request.Context,
PolicyVersion: request.PolicyVersion,
})
if err != nil {
return nil, err
}
decisions = append(decisions, decision)
}
return decisions, nil
}
// ImportDirectory writes a canonical registry snapshot to Topaz objects and
// relations.
func (a *Adapter) ImportDirectory(ctx context.Context, snapshot registry.Snapshot) (ImportReport, error) {
directory := SnapshotToDirectory(snapshot)
report := ImportReport{}
for _, object := range directory.Objects {
if err := a.client.PutObject(ctx, object); err != nil {
return report, err
}
report.ObjectsWritten++
}
for _, relation := range directory.Relations {
result, err := a.client.PutRelation(ctx, relation)
if err != nil {
return report, err
}
if result.ETag != "" {
report.DirectoryETag = result.ETag
}
report.RelationsWritten++
}
return report, nil
}
// ImportManifest installs the Topaz directory manifest.
func (a *Adapter) ImportManifest(ctx context.Context, manifest []byte) error {
if len(manifest) == 0 {
return fmt.Errorf("topaz manifest is required")
}
return a.client.PutManifest(ctx, manifest)
}
// ImportPolicy decomposes a validated Rego-in-Markdown package into one Rego
// module and writes it to the configured Topaz bundle sink unchanged.
func (a *Adapter) ImportPolicy(ctx context.Context, pkg *policy.Package) (PolicyImportReport, error) {
if pkg == nil {
return PolicyImportReport{}, fmt.Errorf("policy package is required")
}
if pkg.RegoModule == "" {
return PolicyImportReport{}, fmt.Errorf("policy package %q has no rego module", pkg.Metadata.ID)
}
bundle := PolicyBundleFromPackage(pkg)
if err := a.client.PutPolicyBundle(ctx, bundle); err != nil {
return PolicyImportReport{}, err
}
report := PolicyImportReport{
BundleID: bundle.ID,
Version: bundle.Version,
Modules: make([]string, 0, len(bundle.Modules)),
}
for _, module := range bundle.Modules {
report.Modules = append(report.Modules, module.Path)
}
if bundle.ID != "" {
a.policyPackage = bundle.ID
}
if bundle.Version != "" {
a.policyVersion = bundle.Version
}
return report, nil
}
// BuildCheckRequest maps a flex-auth check to Topaz's directory check shape.
func BuildCheckRequest(request api.CheckRequest) (DirectoryCheckRequest, error) {
objectType := request.Resource.Type
if objectType == "" {
objectType = inferTypeFromID(request.Resource.ID)
}
if objectType == "" || request.Resource.ID == "" || request.Action == "" || request.Subject.ID == "" {
return DirectoryCheckRequest{}, fmt.Errorf("resource type/id, action, and subject id are required")
}
return DirectoryCheckRequest{
ObjectType: objectType,
ObjectID: request.Resource.ID,
Relation: request.Action,
SubjectType: topazSubjectType(request.Subject.Type, request.Subject.ID),
SubjectID: topazSubjectID(request.Subject.ID),
}, nil
}
func (a *Adapter) envelope(request api.CheckRequest, topazRequest DirectoryCheckRequest, result CheckResult) 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 = reasonDirectoryAllow
} else {
reason = reasonDirectoryDeny
}
}
policyPackage := firstNonEmpty(result.PolicyPackage, a.policyPackage)
policyVersion := firstNonEmpty(result.PolicyVersion, request.PolicyVersion, a.policyVersion)
diagnostics := copyMap(result.Diagnostics)
addTopazDiagnostics(diagnostics, topazRequest, "")
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,
Mode: DelegatedMode,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
DirectoryETag: result.DirectoryETag,
},
Caring: caringDecisionMetadata(request, firstDescriptor(request.CaringContext, result.CaringDescriptor), result.ConformanceFindings, result.ExposureEvent),
}
envelope.ID = decisionID(policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, result.DirectoryETag)
if envelope.Caring != nil && envelope.Caring.ExposureEvent != nil && envelope.Caring.ExposureEvent.ID == "" {
envelope.Caring.ExposureEvent.ID = envelope.ID + ":exposure"
}
return envelope
}
func (a *Adapter) failureEnvelope(request api.CheckRequest, topazRequest DirectoryCheckRequest, kind FailureKind, err error) api.DecisionEnvelope {
reason := failureReason(kind)
diagnostics := map[string]any{}
addTopazDiagnostics(diagnostics, topazRequest, string(kind))
if err != nil {
diagnostics["error"] = err.Error()
}
findings := []api.CaringConformanceFinding{failureFinding(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: diagnostics,
Provenance: api.DecisionProvenance{
Evaluator: EvaluatorName,
Mode: DelegatedMode,
PolicyPackage: a.policyPackage,
PolicyVersion: policyVersion,
},
Caring: caringDecisionMetadata(request, request.CaringContext, findings, nil),
}
envelope.ID = decisionID(a.policyPackage, policyVersion, request, envelope.Effect, envelope.Reason, "")
return envelope
}
func addTopazDiagnostics(diagnostics map[string]any, request DirectoryCheckRequest, failure string) {
diagnostics["adapter"] = "topaz"
diagnostics["mode"] = DelegatedMode
if failure != "" {
diagnostics["topaz_failure"] = failure
}
if request.ObjectType != "" {
diagnostics["topaz_object_type"] = request.ObjectType
diagnostics["topaz_object_id"] = request.ObjectID
diagnostics["topaz_relation"] = request.Relation
diagnostics["topaz_subject_type"] = request.SubjectType
diagnostics["topaz_subject_id"] = request.SubjectID
}
}
func caringDecisionMetadata(
request api.CheckRequest,
descriptor *api.CaringAccessDescriptor,
findings []api.CaringConformanceFinding,
exposureEvent *api.CaringExposureEvent,
) *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: "TOPAZ-CARING-DESCRIPTOR-MISSING",
Severity: "warning",
Message: "delegated Topaz decision 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 exposureEvent != nil {
eventCopy := *exposureEvent
metadata.ExposureEvent = &eventCopy
} else if descriptor.ExposureEvent != "" {
scope := descriptor.Scope
metadata.ExposureEvent = &api.CaringExposureEvent{
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 Topaz decision carries CARING exposure event hook",
AuthoritySource: EvaluatorName,
}
}
return metadata
}
func firstDescriptor(values ...*api.CaringAccessDescriptor) *api.CaringAccessDescriptor {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func failureReason(kind FailureKind) string {
switch kind {
case FailureStaleDirectory:
return reasonStaleDirectory
case FailurePartialResult:
return reasonPartialResult
case FailureInvalidRequest:
return reasonInvalidRequest
default:
return reasonUnavailable
}
}
func failureFinding(kind FailureKind) api.CaringConformanceFinding {
code := "TOPAZ-UNAVAILABLE"
message := "Topaz was unavailable; flex-auth denied the delegated request fail-closed."
switch kind {
case FailureStaleDirectory:
code = "TOPAZ-DIRECTORY-STALE"
message = "Topaz directory freshness could not satisfy the request; flex-auth denied fail-closed."
case FailurePartialResult:
code = "TOPAZ-PARTIAL-RESULT"
message = "Topaz returned a partial result; flex-auth denied fail-closed."
case FailureInvalidRequest:
code = "TOPAZ-REQUEST-INCOMPLETE"
message = "The request could not be translated to a Topaz directory check; flex-auth denied fail-closed."
case FailureUnsupported:
code = "TOPAZ-UNSUPPORTED"
message = "The configured Topaz client does not support the requested operation; 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, etag 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,omitempty"`
DirectoryETag string `json:"directory_etag,omitempty"`
}{
Adapter: EvaluatorName,
PolicyPackage: policyPackage,
PolicyVersion: policyVersion,
Request: request,
Effect: effect,
Reason: reason,
DirectoryETag: etag,
})
sum := sha256.Sum256(data)
return "decision:topaz:" + hex.EncodeToString(sum[:8])
}
func topazSubjectType(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 topazSubjectID(id string) string {
return id
}
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
}

View File

@@ -0,0 +1,303 @@
package topaz_test
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/netkingdom/flex-auth/internal/adapters/topaz"
"github.com/netkingdom/flex-auth/internal/policy"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
func TestSnapshotToDirectoryMapsCanonicalRegistry(t *testing.T) {
store := loadRegistry(t)
got := topaz.SnapshotToDirectory(store.Snapshot())
assertObject(t, got, "user", "user:alice")
assertObject(t, got, "identity", "identity:user:alice")
assertObject(t, got, "group", "group:platform-architecture")
assertObject(t, got, "document", "document:internal-note")
assertRelation(t, got, topaz.DirectoryRelation{
ObjectType: "identity",
ObjectID: "identity:user:alice",
Relation: "identifier",
SubjectType: "user",
SubjectID: "user:alice",
})
assertRelation(t, got, topaz.DirectoryRelation{
ObjectType: "group",
ObjectID: "group:platform-architecture",
Relation: "member",
SubjectType: "user",
SubjectID: "user:alice",
})
assertRelation(t, got, topaz.DirectoryRelation{
ObjectType: "document",
ObjectID: "document:internal-note",
Relation: "reader",
SubjectType: "group",
SubjectID: "group:platform-architecture",
SubjectRelation: "member",
})
}
func TestAdapterCheckWrapsTopazAllowInFlexAuthEnvelope(t *testing.T) {
client := &fakeClient{
checkResult: topaz.CheckResult{
Allowed: true,
DirectoryETag: "etag:rel-42",
Diagnostics: map[string]any{
"topaz_trace": "trace-1",
},
},
}
adapter := newAdapter(t, client)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:topaz-allow",
Subject: api.SubjectRef{ID: "user:alice", Type: api.SubjectTypeHuman},
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 != "topaz_directory_allow" {
t.Fatalf("decision = %s/%s; want allow/topaz_directory_allow", got.Effect, got.Reason)
}
if got.Provenance.Evaluator != topaz.EvaluatorName || got.Provenance.Mode != topaz.DelegatedMode {
t.Fatalf("provenance = %+v; want delegated Topaz", got.Provenance)
}
if got.Provenance.DirectoryETag != "etag:rel-42" {
t.Fatalf("DirectoryETag = %q", got.Provenance.DirectoryETag)
}
if got.Diagnostics["topaz_object_type"] != "document" || got.Diagnostics["topaz_subject_type"] != "user" {
t.Fatalf("diagnostics = %+v; want Topaz check shape", got.Diagnostics)
}
if got.Caring == nil || got.Caring.Descriptor == nil {
t.Fatal("missing CARING descriptor")
}
if len(got.Caring.RestrictionsEvaluated) != 1 || got.Caring.RestrictionsEvaluated[0] != api.RestrictionExportBlocked {
t.Fatalf("restrictions = %+v; want ExportBlocked", got.Caring.RestrictionsEvaluated)
}
}
func TestAdapterCheckFailsClosedOnUnavailableTopaz(t *testing.T) {
client := &fakeClient{
checkErr: topaz.NewBackendError(topaz.FailureUnavailable, "check", errors.New("connection refused")),
}
adapter := newAdapter(t, client)
got, err := adapter.Check(context.Background(), api.CheckRequest{
ID: "check:topaz-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 != "topaz_unavailable" {
t.Fatalf("decision = %s/%s; want deny/topaz_unavailable", got.Effect, got.Reason)
}
if got.Diagnostics["topaz_failure"] != "unavailable" {
t.Fatalf("diagnostics = %+v; want unavailable failure", got.Diagnostics)
}
if got.Caring == nil || len(got.Caring.ConformanceFindings) == 0 {
t.Fatal("fail-closed decision should carry CARING conformance finding")
}
if got.Caring.ConformanceFindings[0].Code != "TOPAZ-UNAVAILABLE" {
t.Fatalf("finding = %+v", got.Caring.ConformanceFindings[0])
}
}
func TestImportDirectoryWritesObjectsAndRelations(t *testing.T) {
client := &fakeClient{}
adapter := newAdapter(t, client)
report, err := adapter.ImportDirectory(context.Background(), loadRegistry(t).Snapshot())
if err != nil {
t.Fatalf("ImportDirectory: %v", err)
}
if report.ObjectsWritten != len(client.objects) {
t.Fatalf("ObjectsWritten = %d; client wrote %d", report.ObjectsWritten, len(client.objects))
}
if report.RelationsWritten != len(client.relations) {
t.Fatalf("RelationsWritten = %d; client wrote %d", report.RelationsWritten, len(client.relations))
}
assertWrittenRelation(t, client.relations, topaz.DirectoryRelation{
ObjectType: "document",
ObjectID: "document:internal-note",
Relation: "reader",
SubjectType: "group",
SubjectID: "group:platform-architecture",
SubjectRelation: "member",
})
}
func TestImportPolicyWritesTopazBundle(t *testing.T) {
pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "..", "examples", "caring", "policy_package.md"))
if err != nil {
t.Fatalf("LoadAndValidateFile: %v", err)
}
client := &fakeClient{bundleSink: topaz.FileBundleSink{Root: t.TempDir()}}
adapter := newAdapter(t, client)
report, err := adapter.ImportPolicy(context.Background(), pkg)
if err != nil {
t.Fatalf("ImportPolicy: %v", err)
}
if report.BundleID != "markitect.documents.internal-read" || report.Version != "v1" {
t.Fatalf("report = %+v", report)
}
modulePath := filepath.Join(client.bundleSink.Root, "policy", "flexauth", "markitect", "documents.rego")
data, err := os.ReadFile(modulePath)
if err != nil {
t.Fatalf("read module: %v", err)
}
if string(data) != pkg.RegoModule {
t.Fatal("policy module changed during Topaz import")
}
manifest, err := os.ReadFile(filepath.Join(client.bundleSink.Root, ".manifest"))
if err != nil {
t.Fatalf("read manifest: %v", err)
}
if !contains(string(manifest), "flexauth/markitect/documents") {
t.Fatalf(".manifest = %s", manifest)
}
}
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 newAdapter(t *testing.T, client *fakeClient) *topaz.Adapter {
t.Helper()
adapter, err := topaz.New(client, topaz.Options{
PolicyPackage: "markitect.documents.internal-read",
PolicyVersion: "v1",
})
if err != nil {
t.Fatalf("New: %v", err)
}
return adapter
}
func caringDescriptor() *api.CaringAccessDescriptor {
return &api.CaringAccessDescriptor{
ID: "descriptor:tenant-alpha-document-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, api.ExposureModePlaintext},
Restrictions: []api.Restriction{api.RestrictionExportBlocked},
}
}
func assertObject(t *testing.T, snapshot topaz.DirectorySnapshot, objectType, objectID string) {
t.Helper()
for _, object := range snapshot.Objects {
if object.Type == objectType && object.ID == objectID {
return
}
}
t.Fatalf("object %s:%s not found in %+v", objectType, objectID, snapshot.Objects)
}
func assertRelation(t *testing.T, snapshot topaz.DirectorySnapshot, want topaz.DirectoryRelation) {
t.Helper()
assertWrittenRelation(t, snapshot.Relations, want)
}
func assertWrittenRelation(t *testing.T, relations []topaz.DirectoryRelation, want topaz.DirectoryRelation) {
t.Helper()
for _, relation := range relations {
if relation == want {
return
}
}
t.Fatalf("relation %+v not found in %+v", want, relations)
}
func contains(value, needle string) bool {
return len(needle) == 0 || (len(value) >= len(needle) && stringsContains(value, needle))
}
func stringsContains(value, needle string) bool {
for i := 0; i+len(needle) <= len(value); i++ {
if value[i:i+len(needle)] == needle {
return true
}
}
return false
}
type fakeClient struct {
checkResult topaz.CheckResult
checkErr error
objects []topaz.DirectoryObject
relations []topaz.DirectoryRelation
bundleSink topaz.FileBundleSink
}
func (c *fakeClient) Check(context.Context, topaz.DirectoryCheckRequest) (topaz.CheckResult, error) {
return c.checkResult, c.checkErr
}
func (c *fakeClient) PutObject(_ context.Context, object topaz.DirectoryObject) error {
c.objects = append(c.objects, object)
return nil
}
func (c *fakeClient) PutRelation(_ context.Context, relation topaz.DirectoryRelation) (topaz.WriteResult, error) {
c.relations = append(c.relations, relation)
return topaz.WriteResult{ETag: "etag:last"}, nil
}
func (c *fakeClient) PutManifest(context.Context, []byte) error {
return nil
}
func (c *fakeClient) PutPolicyBundle(ctx context.Context, bundle topaz.PolicyBundle) error {
if c.bundleSink.Root == "" {
return nil
}
return c.bundleSink.PutPolicyBundle(ctx, bundle)
}
func (c *fakeClient) Health(context.Context) error {
return nil
}

View File

@@ -0,0 +1,7 @@
// Package topaz implements the delegated Topaz adapter for flex-auth.
//
// The package keeps Topaz behind a small client interface. Protected systems
// continue to send flex-auth CheckRequest values and receive the same
// DecisionEnvelope shape used by the standalone evaluator; only the PDP and
// directory implementation changes.
package topaz

View File

@@ -0,0 +1,159 @@
package topaz
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// HTTPClient talks to Topaz's REST gateways. It is the runnable development
// client used by examples/topaz; production deployments can swap in a gRPC
// client behind the same Client interface.
type HTTPClient struct {
DirectoryURL string
HealthURL string
Client *http.Client
BundleSink BundleSink
}
// NewHTTPClient creates an HTTP-backed Topaz client.
func NewHTTPClient(directoryURL string, bundleSink BundleSink) (*HTTPClient, error) {
if directoryURL == "" {
return nil, fmt.Errorf("directory URL is required")
}
return &HTTPClient{
DirectoryURL: strings.TrimRight(directoryURL, "/"),
Client: &http.Client{Timeout: 10 * time.Second},
BundleSink: bundleSink,
}, nil
}
// Check calls Topaz's directory check endpoint.
func (c *HTTPClient) Check(ctx context.Context, request DirectoryCheckRequest) (CheckResult, error) {
var response struct {
Check bool `json:"check"`
ETag string `json:"etag,omitempty"`
}
if err := c.postJSON(ctx, "/api/v3/directory/check", request, &response); err != nil {
return CheckResult{}, NewBackendError(FailureUnavailable, "check", err)
}
return CheckResult{
Allowed: response.Check,
DirectoryETag: response.ETag,
}, nil
}
// PutObject writes one directory object.
func (c *HTTPClient) PutObject(ctx context.Context, object DirectoryObject) error {
body := map[string]DirectoryObject{"object": object}
if err := c.postJSON(ctx, "/api/v3/directory/object", body, nil); err != nil {
return NewBackendError(FailureUnavailable, "put object", err)
}
return nil
}
// PutRelation writes one directory relation.
func (c *HTTPClient) PutRelation(ctx context.Context, relation DirectoryRelation) (WriteResult, error) {
body := map[string]DirectoryRelation{"relation": relation}
var response struct {
ETag string `json:"etag,omitempty"`
}
if err := c.postJSON(ctx, "/api/v3/directory/relation", body, &response); err != nil {
return WriteResult{}, NewBackendError(FailureUnavailable, "put relation", err)
}
return WriteResult{ETag: response.ETag}, nil
}
// PutManifest writes the directory manifest. Topaz versions have exposed both
// /directory/manifest and /model, so the client tries the current endpoint and
// falls back to the spike-compatible one.
func (c *HTTPClient) PutManifest(ctx context.Context, manifest []byte) error {
if err := c.postYAML(ctx, "/api/v3/directory/manifest", manifest); err == nil {
return nil
}
if err := c.postYAML(ctx, "/api/v3/model", manifest); err != nil {
return NewBackendError(FailureUnavailable, "put manifest", err)
}
return nil
}
// PutPolicyBundle delegates bundle publication to the configured sink.
func (c *HTTPClient) PutPolicyBundle(ctx context.Context, bundle PolicyBundle) error {
if c.BundleSink == nil {
return NewBackendError(FailureUnsupported, "put policy bundle", fmt.Errorf("no bundle sink configured"))
}
return c.BundleSink.PutPolicyBundle(ctx, bundle)
}
// Health checks Topaz readiness. If HealthURL is empty, it checks the
// directory gateway root.
func (c *HTTPClient) Health(ctx context.Context) error {
url := c.HealthURL
if url == "" {
url = c.DirectoryURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(url, "/"), nil)
if err != nil {
return err
}
resp, err := c.httpClient().Do(req)
if err != nil {
return NewBackendError(FailureUnavailable, "health", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 500 {
return NewBackendError(FailureUnavailable, "health", fmt.Errorf("status %s", resp.Status))
}
return nil
}
func (c *HTTPClient) postJSON(ctx context.Context, path string, body any, out any) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
return c.do(ctx, http.MethodPost, c.DirectoryURL+path, "application/json", data, out)
}
func (c *HTTPClient) postYAML(ctx context.Context, path string, body []byte) error {
return c.do(ctx, http.MethodPost, c.DirectoryURL+path, "application/yaml", body, nil)
}
func (c *HTTPClient) do(ctx context.Context, method, url, contentType string, body []byte, out any) error {
req, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(body))
if err != nil {
return err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := c.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("status %s: %s", resp.Status, strings.TrimSpace(string(responseBody)))
}
if out == nil {
io.Copy(io.Discard, resp.Body)
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil && err != io.EOF {
return err
}
return nil
}
func (c *HTTPClient) httpClient() *http.Client {
if c.Client != nil {
return c.Client
}
return http.DefaultClient
}

View File

@@ -0,0 +1,106 @@
package topaz_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/netkingdom/flex-auth/internal/adapters/topaz"
)
func TestHTTPClientUsesTopazDirectoryRESTShape(t *testing.T) {
var gotCheck topaz.DirectoryCheckRequest
var gotObject topaz.DirectoryObject
var gotRelation topaz.DirectoryRelation
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v3/directory/check":
var body struct {
topaz.DirectoryCheckRequest
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode check: %v", err)
}
gotCheck = body.DirectoryCheckRequest
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"check":true,"etag":"etag:check"}`))
case "/api/v3/directory/object":
var body struct {
Object topaz.DirectoryObject `json:"object"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode object: %v", err)
}
gotObject = body.Object
w.WriteHeader(http.StatusNoContent)
case "/api/v3/directory/relation":
var body struct {
Relation topaz.DirectoryRelation `json:"relation"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode relation: %v", err)
}
gotRelation = body.Relation
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"etag":"etag:relation"}`))
case "/api/v3/directory/manifest":
if r.Header.Get("Content-Type") != "application/yaml" {
t.Fatalf("manifest content-type = %q", r.Header.Get("Content-Type"))
}
w.WriteHeader(http.StatusNoContent)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
client, err := topaz.NewHTTPClient(server.URL, nil)
if err != nil {
t.Fatalf("NewHTTPClient: %v", err)
}
check, err := client.Check(context.Background(), topaz.DirectoryCheckRequest{
ObjectType: "document",
ObjectID: "document:internal-note",
Relation: "read",
SubjectType: "user",
SubjectID: "user:alice",
})
if err != nil {
t.Fatalf("Check: %v", err)
}
if !check.Allowed || check.DirectoryETag != "etag:check" {
t.Fatalf("check = %+v", check)
}
if gotCheck.ObjectType != "document" || gotCheck.SubjectID != "user:alice" {
t.Fatalf("gotCheck = %+v", gotCheck)
}
if err := client.PutObject(context.Background(), topaz.DirectoryObject{Type: "document", ID: "document:internal-note"}); err != nil {
t.Fatalf("PutObject: %v", err)
}
if gotObject.Type != "document" || gotObject.ID != "document:internal-note" {
t.Fatalf("gotObject = %+v", gotObject)
}
write, err := client.PutRelation(context.Background(), topaz.DirectoryRelation{
ObjectType: "document",
ObjectID: "document:internal-note",
Relation: "reader",
SubjectType: "user",
SubjectID: "user:alice",
})
if err != nil {
t.Fatalf("PutRelation: %v", err)
}
if write.ETag != "etag:relation" || gotRelation.Relation != "reader" {
t.Fatalf("write = %+v relation = %+v", write, gotRelation)
}
if err := client.PutManifest(context.Background(), []byte("model: v3\n")); err != nil {
t.Fatalf("PutManifest: %v", err)
}
}

View File

@@ -0,0 +1,27 @@
package topaz_test
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestTopazDockerComposeExample(t *testing.T) {
if os.Getenv("FLEX_AUTH_RUN_TOPAZ_INTEGRATION") != "1" {
t.Skip("set FLEX_AUTH_RUN_TOPAZ_INTEGRATION=1 to run the examples/topaz docker-compose integration")
}
dir := filepath.Join("..", "..", "..", "examples", "topaz")
cmd := exec.Command("docker", "compose", "up", "--abort-on-container-exit", "--exit-code-from", "probe")
cmd.Dir = dir
output, err := cmd.CombinedOutput()
t.Cleanup(func() {
down := exec.Command("docker", "compose", "down", "-v")
down.Dir = dir
down.Run()
})
if err != nil {
t.Fatalf("docker compose integration failed: %v\n%s", err, output)
}
}

View File

@@ -0,0 +1,80 @@
package topaz
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/netkingdom/flex-auth/internal/policy"
)
// PolicyBundleFromPackage extracts the Rego module from a flex-auth
// Rego-in-Markdown package without translation.
func PolicyBundleFromPackage(pkg *policy.Package) PolicyBundle {
root := strings.ReplaceAll(pkg.Metadata.Package, ".", "/")
modulePath := filepath.ToSlash(filepath.Join("policy", root+".rego"))
return PolicyBundle{
ID: pkg.Metadata.ID,
Version: pkg.Metadata.Version,
Package: pkg.Metadata.Package,
Revision: pkg.Metadata.Version,
Roots: []string{root},
Modules: []PolicyModule{
{Path: modulePath, Source: pkg.RegoModule},
},
}
}
// FileBundleSink writes a Topaz-readable local OPA bundle directory.
type FileBundleSink struct {
Root string
}
// PutPolicyBundle writes .manifest and module files under Root.
func (s FileBundleSink) PutPolicyBundle(_ context.Context, bundle PolicyBundle) error {
if s.Root == "" {
return fmt.Errorf("bundle root is required")
}
if len(bundle.Modules) == 0 {
return fmt.Errorf("policy bundle %q contains no modules", bundle.ID)
}
if err := os.MkdirAll(s.Root, 0o755); err != nil {
return fmt.Errorf("create policy bundle root: %w", err)
}
manifest := struct {
Revision string `json:"revision,omitempty"`
Roots []string `json:"roots"`
}{
Revision: bundle.Revision,
Roots: append([]string(nil), bundle.Roots...),
}
manifestData, err := json.Marshal(manifest)
if err != nil {
return fmt.Errorf("marshal policy bundle manifest: %w", err)
}
manifestData = append(manifestData, '\n')
if err := os.WriteFile(filepath.Join(s.Root, ".manifest"), manifestData, 0o644); err != nil {
return fmt.Errorf("write policy bundle manifest: %w", err)
}
for _, module := range bundle.Modules {
if module.Path == "" {
return fmt.Errorf("policy bundle %q contains module with empty path", bundle.ID)
}
path := filepath.Clean(filepath.Join(s.Root, module.Path))
if !strings.HasPrefix(path, filepath.Clean(s.Root)+string(os.PathSeparator)) {
return fmt.Errorf("policy module path escapes bundle root: %s", module.Path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create policy module directory: %w", err)
}
if err := os.WriteFile(path, []byte(module.Source), 0o644); err != nil {
return fmt.Errorf("write policy module %q: %w", module.Path, err)
}
}
return nil
}

View File

@@ -0,0 +1,325 @@
package topaz
import (
"fmt"
"sort"
"strings"
"github.com/netkingdom/flex-auth/internal/registry"
"github.com/netkingdom/flex-auth/pkg/api"
)
// SnapshotToDirectory converts flex-auth's canonical registry snapshot into
// Topaz directory objects and relations.
func SnapshotToDirectory(snapshot registry.Snapshot) DirectorySnapshot {
index := newSnapshotIndex(snapshot)
objects := map[string]DirectoryObject{}
relations := map[string]DirectoryRelation{}
addObject := func(object DirectoryObject) {
if object.Type == "" || object.ID == "" {
return
}
objects[objectKey(object.Type, object.ID)] = object
}
addRelation := func(relation DirectoryRelation) {
if relation.ObjectType == "" || relation.ObjectID == "" || relation.Relation == "" ||
relation.SubjectType == "" || relation.SubjectID == "" {
return
}
relations[relationKey(relation)] = relation
}
for _, tenant := range snapshot.Tenants {
addObject(DirectoryObject{Type: "tenant", ID: tenant.ID, DisplayName: tenant.Name, Properties: copyStringAnyMap(tenant.Metadata)})
}
for _, subject := range snapshot.Subjects {
properties := copyStringAnyMap(subject.Metadata)
addProperty(properties, "principal_type", principalType(subject.Type))
addProperty(properties, "organization_relation", subject.OrganizationRelation)
addProperty(properties, "roles", subject.Roles)
addProperty(properties, "tenant", subject.Tenant)
addProperties(properties, subject.Claims)
addObject(DirectoryObject{Type: "user", ID: subject.ID, DisplayName: subject.DisplayName, Properties: properties})
identityID := identityObjectID(subject.ID)
addObject(DirectoryObject{
Type: "identity",
ID: identityID,
Properties: map[string]any{
"identifier": subject.ID,
"subject": subject.ID,
},
})
addRelation(DirectoryRelation{
ObjectType: "identity",
ObjectID: identityID,
Relation: "identifier",
SubjectType: "user",
SubjectID: subject.ID,
})
}
for _, group := range snapshot.Groups {
properties := copyStringAnyMap(group.Metadata)
addProperty(properties, "tenant", group.Tenant)
addObject(DirectoryObject{Type: "group", ID: group.ID, DisplayName: group.DisplayName, Properties: properties})
for _, member := range group.Members {
addRelation(DirectoryRelation{
ObjectType: "group",
ObjectID: group.ID,
Relation: "member",
SubjectType: index.subjectType(member),
SubjectID: index.subjectID(member),
})
}
}
for _, team := range snapshot.Teams {
teamID := teamObjectID(team.ID)
properties := copyStringAnyMap(team.Metadata)
addProperty(properties, "kind", "team")
addProperty(properties, "flex_auth_id", team.ID)
addProperty(properties, "tenant", team.Tenant)
addObject(DirectoryObject{Type: "group", ID: teamID, DisplayName: team.DisplayName, Properties: properties})
for _, member := range team.Members {
addRelation(DirectoryRelation{
ObjectType: "group",
ObjectID: teamID,
Relation: "member",
SubjectType: index.subjectType(member),
SubjectID: index.subjectID(member),
})
}
}
for _, manifest := range snapshot.ResourceManifests {
for _, resource := range manifest.Resources {
properties := copyStringAnyMap(resource.Attributes)
addProperty(properties, "system", manifest.System)
addProperty(properties, "path", resource.Path)
addProperty(properties, "parent", resource.Parent)
addProperty(properties, "labels", resource.Labels)
addProperty(properties, "trust_zone", resource.TrustZone)
addProperty(properties, "owner", resource.Owner)
addObject(DirectoryObject{Type: resource.Type, ID: resource.ID, Properties: properties})
if resource.Parent != "" {
addRelation(DirectoryRelation{
ObjectType: resource.Type,
ObjectID: resource.ID,
Relation: "parent",
SubjectType: index.resourceType(resource.Parent),
SubjectID: resource.Parent,
})
}
if resource.Owner != "" {
addRelation(DirectoryRelation{
ObjectType: resource.Type,
ObjectID: resource.ID,
Relation: "owner_team",
SubjectType: "group",
SubjectID: teamOrGroupObjectID(resource.Owner),
})
}
}
}
for _, relationship := range snapshot.Relationships {
subjectType := index.subjectType(relationship.Subject)
subjectRelation := ""
if subjectType == "group" && relationship.Relation != "member" && relationship.Relation != "owner_team" {
subjectRelation = "member"
}
addRelation(DirectoryRelation{
ObjectType: index.resourceType(relationship.Object),
ObjectID: relationship.Object,
Relation: relationship.Relation,
SubjectType: subjectType,
SubjectID: index.subjectID(relationship.Subject),
SubjectRelation: subjectRelation,
})
}
return DirectorySnapshot{
Objects: sortedObjects(objects),
Relations: sortedRelations(relations),
}
}
type snapshotIndex struct {
subjects map[string]api.Subject
groups map[string]api.Group
teams map[string]api.Team
resourceTypes map[string]string
}
func newSnapshotIndex(snapshot registry.Snapshot) snapshotIndex {
index := snapshotIndex{
subjects: make(map[string]api.Subject),
groups: make(map[string]api.Group),
teams: make(map[string]api.Team),
resourceTypes: make(map[string]string),
}
for _, subject := range snapshot.Subjects {
index.subjects[subject.ID] = subject
}
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 objectKey(objectType, objectID string) string {
return objectType + "\x00" + objectID
}
func relationKey(relation DirectoryRelation) string {
return fmt.Sprintf(
"%s\x00%s\x00%s\x00%s\x00%s\x00%s",
relation.ObjectType,
relation.ObjectID,
relation.Relation,
relation.SubjectType,
relation.SubjectID,
relation.SubjectRelation,
)
}
func sortedObjects(objects map[string]DirectoryObject) []DirectoryObject {
keys := make([]string, 0, len(objects))
for key := range objects {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]DirectoryObject, 0, len(keys))
for _, key := range keys {
out = append(out, objects[key])
}
return out
}
func sortedRelations(relations map[string]DirectoryRelation) []DirectoryRelation {
keys := make([]string, 0, len(relations))
for key := range relations {
keys = append(keys, key)
}
sort.Strings(keys)
out := make([]DirectoryRelation, 0, len(keys))
for _, key := range keys {
out = append(out, relations[key])
}
return out
}
func identityObjectID(subjectID string) string {
return "identity:" + subjectID
}
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)
}
func principalType(subjectType api.SubjectType) string {
switch subjectType {
case api.SubjectTypeService, api.SubjectTypeAutomation, api.SubjectTypeAgent:
return "service"
case api.SubjectTypeHuman:
return "human"
case "":
return ""
default:
return strings.ToLower(string(subjectType))
}
}
func copyStringAnyMap(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 addProperties(target map[string]any, attrs map[string]any) {
for key, value := range attrs {
addProperty(target, key, value)
}
}
func addProperty(target map[string]any, key string, value any) {
if target == nil || emptyProperty(value) {
return
}
if _, exists := target[key]; !exists {
target[key] = value
}
}
func emptyProperty(value any) bool {
switch typed := value.(type) {
case string:
return typed == ""
case api.OrganizationRelation:
return typed == ""
case []string:
return len(typed) == 0
case []api.CanonicalRole:
return len(typed) == 0
default:
return value == nil
}
}

View File

@@ -0,0 +1,183 @@
package topaz
import (
"context"
"errors"
"fmt"
"github.com/netkingdom/flex-auth/pkg/api"
)
const (
// EvaluatorName is recorded in delegated decision provenance.
EvaluatorName = "topaz"
// DelegatedMode is the stable provenance mode for Topaz-backed decisions.
DelegatedMode = "delegated"
reasonDirectoryAllow = "topaz_directory_allow"
reasonDirectoryDeny = "topaz_directory_deny"
reasonUnavailable = "topaz_unavailable"
reasonStaleDirectory = "topaz_directory_stale"
reasonPartialResult = "topaz_partial_result"
reasonInvalidRequest = "topaz_request_incomplete"
)
// Client is the protocol boundary between flex-auth and Topaz.
type Client interface {
Check(context.Context, DirectoryCheckRequest) (CheckResult, error)
PutObject(context.Context, DirectoryObject) error
PutRelation(context.Context, DirectoryRelation) (WriteResult, error)
PutManifest(context.Context, []byte) error
PutPolicyBundle(context.Context, PolicyBundle) error
Health(context.Context) error
}
// BundleSink writes a policy bundle to the distribution mechanism Topaz reads
// from, such as the local bundle directory mounted by examples/topaz.
type BundleSink interface {
PutPolicyBundle(context.Context, PolicyBundle) error
}
// Options configures the adapter without leaking Topaz-specific types into the
// public flex-auth API.
type Options struct {
PolicyPackage string
PolicyVersion string
}
// DirectoryObject is the Topaz directory object shape used by the REST
// gateway and by the adapter's protocol-neutral client interface.
type DirectoryObject struct {
Type string `json:"type"`
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
Properties map[string]any `json:"properties,omitempty"`
}
// DirectoryRelation is the Topaz directory relation shape.
type DirectoryRelation 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"`
}
// DirectorySnapshot is a deterministic Topaz import payload derived from the
// canonical flex-auth registry snapshot.
type DirectorySnapshot struct {
Objects []DirectoryObject `json:"objects"`
Relations []DirectoryRelation `json:"relations"`
}
// DirectoryCheckRequest is the Topaz directory check input.
type DirectoryCheckRequest 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"`
}
// CheckResult is a protocol-neutral Topaz decision result.
type CheckResult struct {
Allowed bool
Effect api.DecisionEffect
Reason string
MatchedRule string
PolicyPackage string
PolicyVersion string
DirectoryETag string
Obligations []api.Obligation
Diagnostics map[string]any
CaringDescriptor *api.CaringAccessDescriptor
ConformanceFindings []api.CaringConformanceFinding
ExposureEvent *api.CaringExposureEvent
StaleDirectory bool
PartialResult bool
}
// WriteResult records Topaz write metadata such as relation etags.
type WriteResult struct {
ETag string
}
// ImportReport summarizes a directory import into Topaz.
type ImportReport struct {
ObjectsWritten int
RelationsWritten int
DirectoryETag string
}
// PolicyModule is one Rego module in a Topaz-readable OPA bundle.
type PolicyModule struct {
Path string
Source string
}
// PolicyBundle is the policy bundle handed to Topaz unchanged after Markdown
// extraction.
type PolicyBundle struct {
ID string
Version string
Package string
Revision string
Roots []string
Modules []PolicyModule
}
// PolicyImportReport summarizes a policy import into Topaz.
type PolicyImportReport struct {
BundleID string
Version string
Modules []string
}
// FailureKind classifies fail-closed Topaz decisions.
type FailureKind string
const (
FailureUnavailable FailureKind = "unavailable"
FailureStaleDirectory FailureKind = "stale_directory"
FailurePartialResult FailureKind = "partial_result"
FailureInvalidRequest FailureKind = "invalid_request"
FailureUnsupported FailureKind = "unsupported"
)
// BackendError wraps transport and 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("topaz %s failed: %s", e.Op, e.Kind)
}
return fmt.Sprintf("topaz %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 an adapter backend error.
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

@@ -3,7 +3,7 @@ id: FLEX-WP-0004
type: workplan
title: "Delegated PDP and Directory Adapters"
domain: netkingdom
status: todo
status: active
owner: flex-auth
topic_slug: flex-auth
planning_priority: P2
@@ -44,7 +44,7 @@ vocabulary are stable.
```task
id: FLEX-WP-0004-T001
status: todo
status: done
priority: high
state_hub_task_id: "9046418c-2b78-42c6-8bfa-76d6ed0050dd"
```