generated from coulomb/repo-seed
Implement Topaz adapter
This commit is contained in:
107
docs/topaz-adapter-operations.md
Normal file
107
docs/topaz-adapter-operations.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -17,6 +17,10 @@ types:
|
||||
relations:
|
||||
manager: user
|
||||
|
||||
identity:
|
||||
relations:
|
||||
identifier: user
|
||||
|
||||
group:
|
||||
relations:
|
||||
member: user | group#member
|
||||
|
||||
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
|
||||
}
|
||||
303
internal/adapters/topaz/adapter_test.go
Normal file
303
internal/adapters/topaz/adapter_test.go
Normal 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
|
||||
}
|
||||
7
internal/adapters/topaz/doc.go
Normal file
7
internal/adapters/topaz/doc.go
Normal 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
|
||||
159
internal/adapters/topaz/http_client.go
Normal file
159
internal/adapters/topaz/http_client.go
Normal 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
|
||||
}
|
||||
106
internal/adapters/topaz/http_client_test.go
Normal file
106
internal/adapters/topaz/http_client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
27
internal/adapters/topaz/integration_test.go
Normal file
27
internal/adapters/topaz/integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
80
internal/adapters/topaz/policy_bundle.go
Normal file
80
internal/adapters/topaz/policy_bundle.go
Normal 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
|
||||
}
|
||||
325
internal/adapters/topaz/translate.go
Normal file
325
internal/adapters/topaz/translate.go
Normal 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
|
||||
}
|
||||
}
|
||||
183
internal/adapters/topaz/types.go
Normal file
183
internal/adapters/topaz/types.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user