Files
flex-auth/internal/adapters/topaz/adapter.go
tegwick 1ce0181e8f
Some checks failed
CI / Build and Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
Implement Topaz adapter
2026-05-17 06:58:04 +02:00

416 lines
13 KiB
Go

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
}