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
|
## What the example proves
|
||||||
|
|
||||||
- Topaz's v3 manifest can express flex-auth's canonical object types
|
- Topaz's v3 manifest can express flex-auth's canonical object types
|
||||||
(`user`, `group`, `tenant`, `knowledge_base`, `document`) and
|
(`user`, `identity`, `group`, `tenant`, `knowledge_base`, `document`)
|
||||||
relations (`member`, `parent`, `owner_team`, `reader`, `steward`).
|
and relations (`identifier`, `member`, `parent`, `owner_team`,
|
||||||
|
`reader`, `steward`).
|
||||||
- The Markitect fixture data
|
- The Markitect fixture data
|
||||||
(`examples/markitect/resource_manifest.yaml`, mirrored here) seeds
|
(`examples/markitect/resource_manifest.yaml`, mirrored here) seeds
|
||||||
the directory without translation.
|
the directory without translation.
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
{"type": "group", "id": "team:platform-architecture", "display_name": "Platform Architecture"},
|
{"type": "group", "id": "team:platform-architecture", "display_name": "Platform Architecture"},
|
||||||
{"type": "group", "id": "reader:platform-architecture", "display_name": "Platform Architecture Readers"},
|
{"type": "group", "id": "reader:platform-architecture", "display_name": "Platform Architecture Readers"},
|
||||||
{"type": "user", "id": "alice@example.test", "display_name": "Alice (steward)"},
|
{"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": "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": "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",
|
"type": "knowledge_base",
|
||||||
"id": "knowledge-base:markitect-example",
|
"id": "knowledge-base:markitect-example",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"relations": [
|
"relations": [
|
||||||
{"object_type": "group", "object_id": "team:platform-architecture", "relation": "member", "subject_type": "user", "subject_id": "alice@example.test"},
|
{"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": "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": "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": "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"},
|
{"object_type": "document", "object_id": "document:internal-note", "relation": "steward", "subject_type": "user", "subject_id": "alice@example.test"},
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ types:
|
|||||||
relations:
|
relations:
|
||||||
manager: user
|
manager: user
|
||||||
|
|
||||||
|
identity:
|
||||||
|
relations:
|
||||||
|
identifier: user
|
||||||
|
|
||||||
group:
|
group:
|
||||||
relations:
|
relations:
|
||||||
member: user | group#member
|
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
|
type: workplan
|
||||||
title: "Delegated PDP and Directory Adapters"
|
title: "Delegated PDP and Directory Adapters"
|
||||||
domain: netkingdom
|
domain: netkingdom
|
||||||
status: todo
|
status: active
|
||||||
owner: flex-auth
|
owner: flex-auth
|
||||||
topic_slug: flex-auth
|
topic_slug: flex-auth
|
||||||
planning_priority: P2
|
planning_priority: P2
|
||||||
@@ -44,7 +44,7 @@ vocabulary are stable.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: FLEX-WP-0004-T001
|
id: FLEX-WP-0004-T001
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "9046418c-2b78-42c6-8bfa-76d6ed0050dd"
|
state_hub_task_id: "9046418c-2b78-42c6-8bfa-76d6ed0050dd"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user