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,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
}