generated from coulomb/repo-seed
Implement Topaz adapter
This commit is contained in:
415
internal/adapters/topaz/adapter.go
Normal file
415
internal/adapters/topaz/adapter.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user