diff --git a/docs/topaz-adapter-operations.md b/docs/topaz-adapter-operations.md new file mode 100644 index 0000000..69de47b --- /dev/null +++ b/docs/topaz-adapter-operations.md @@ -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:` 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/.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. diff --git a/examples/topaz/README.md b/examples/topaz/README.md index fcfef91..b75f65a 100644 --- a/examples/topaz/README.md +++ b/examples/topaz/README.md @@ -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. diff --git a/examples/topaz/data/objects.json b/examples/topaz/data/objects.json index 6e90dd8..c48f596 100644 --- a/examples/topaz/data/objects.json +++ b/examples/topaz/data/objects.json @@ -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", diff --git a/examples/topaz/data/relations.json b/examples/topaz/data/relations.json index aded767..7ef3f43 100644 --- a/examples/topaz/data/relations.json +++ b/examples/topaz/data/relations.json @@ -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"}, diff --git a/examples/topaz/manifest.yaml b/examples/topaz/manifest.yaml index 5205c19..76ad816 100644 --- a/examples/topaz/manifest.yaml +++ b/examples/topaz/manifest.yaml @@ -17,6 +17,10 @@ types: relations: manager: user + identity: + relations: + identifier: user + group: relations: member: user | group#member diff --git a/internal/adapters/topaz/adapter.go b/internal/adapters/topaz/adapter.go new file mode 100644 index 0000000..0b733f2 --- /dev/null +++ b/internal/adapters/topaz/adapter.go @@ -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 +} diff --git a/internal/adapters/topaz/adapter_test.go b/internal/adapters/topaz/adapter_test.go new file mode 100644 index 0000000..dce60a4 --- /dev/null +++ b/internal/adapters/topaz/adapter_test.go @@ -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 +} diff --git a/internal/adapters/topaz/doc.go b/internal/adapters/topaz/doc.go new file mode 100644 index 0000000..b167ed7 --- /dev/null +++ b/internal/adapters/topaz/doc.go @@ -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 diff --git a/internal/adapters/topaz/http_client.go b/internal/adapters/topaz/http_client.go new file mode 100644 index 0000000..08e8b30 --- /dev/null +++ b/internal/adapters/topaz/http_client.go @@ -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 +} diff --git a/internal/adapters/topaz/http_client_test.go b/internal/adapters/topaz/http_client_test.go new file mode 100644 index 0000000..37ca2cc --- /dev/null +++ b/internal/adapters/topaz/http_client_test.go @@ -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) + } +} diff --git a/internal/adapters/topaz/integration_test.go b/internal/adapters/topaz/integration_test.go new file mode 100644 index 0000000..78bc1be --- /dev/null +++ b/internal/adapters/topaz/integration_test.go @@ -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) + } +} diff --git a/internal/adapters/topaz/policy_bundle.go b/internal/adapters/topaz/policy_bundle.go new file mode 100644 index 0000000..2afa2f7 --- /dev/null +++ b/internal/adapters/topaz/policy_bundle.go @@ -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 +} diff --git a/internal/adapters/topaz/translate.go b/internal/adapters/topaz/translate.go new file mode 100644 index 0000000..2b43bdf --- /dev/null +++ b/internal/adapters/topaz/translate.go @@ -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 + } +} diff --git a/internal/adapters/topaz/types.go b/internal/adapters/topaz/types.go new file mode 100644 index 0000000..aa8b87b --- /dev/null +++ b/internal/adapters/topaz/types.go @@ -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 +} diff --git a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md index 5bc9fa6..7b176bd 100644 --- a/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md +++ b/workplans/FLEX-WP-0004-delegated-pdp-and-directory-adapters.md @@ -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" ```