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 }