diff --git a/internal/markitect/decision.go b/internal/markitect/decision.go new file mode 100644 index 0000000..4a6f103 --- /dev/null +++ b/internal/markitect/decision.go @@ -0,0 +1,75 @@ +package markitect + +import "github.com/netkingdom/flex-auth/pkg/api" + +const ( + GatewayEffectAllow = "allow" + GatewayEffectDeny = "deny" + GatewayEffectRedact = "redact" + GatewayEffectAuditDenied = "audit_denied" +) + +// GatewayDecision is the Markitect-facing decision contract. +type GatewayDecision struct { + ID string `json:"id"` + Effect string `json:"effect"` + Reason string `json:"reason,omitempty"` + RuleID string `json:"rule_id,omitempty"` + PolicyVersion string `json:"policy_version,omitempty"` + Resource api.ResourceRef `json:"resource"` + ResourceMetadata map[string]any `json:"resource_metadata,omitempty"` + Subject api.SubjectRef `json:"subject"` + Obligations []api.Obligation `json:"obligations,omitempty"` + Diagnostics map[string]any `json:"diagnostics,omitempty"` + CaringDescriptor *api.CaringAccessDescriptor `json:"caring_descriptor,omitempty"` + ConformanceFindings []api.CaringConformanceFinding `json:"conformance_findings,omitempty"` + ExposureModes []api.ExposureMode `json:"exposure_modes,omitempty"` +} + +// ToGatewayDecision projects a flex-auth decision envelope into the shape +// consumed by the Markitect policy gateway. +func ToGatewayDecision(decision api.DecisionEnvelope) GatewayDecision { + out := GatewayDecision{ + ID: decision.ID, + Effect: gatewayEffect(decision), + Reason: decision.Reason, + RuleID: decision.MatchedRule, + PolicyVersion: decision.MatchedPolicyVersion, + Resource: decision.Resource, + ResourceMetadata: copyMap(decision.Resource.Attributes), + Subject: decision.Subject, + Obligations: append([]api.Obligation(nil), decision.Obligations...), + Diagnostics: copyMap(decision.Diagnostics), + } + if out.PolicyVersion == "" { + out.PolicyVersion = decision.Provenance.PolicyVersion + } + if decision.Caring != nil { + if decision.Caring.Descriptor != nil { + descriptor := *decision.Caring.Descriptor + out.CaringDescriptor = &descriptor + } + out.ConformanceFindings = append([]api.CaringConformanceFinding(nil), decision.Caring.ConformanceFindings...) + out.ExposureModes = append([]api.ExposureMode(nil), decision.Caring.ExposureModes...) + } + return out +} + +func gatewayEffect(decision api.DecisionEnvelope) string { + if value, ok := decision.Diagnostics["markitect_effect"].(string); ok && value != "" { + return value + } + if auditDenied, ok := decision.Diagnostics["audit_denied"].(bool); ok && auditDenied { + return GatewayEffectAuditDenied + } + switch decision.Effect { + case api.DecisionEffectAllow: + return GatewayEffectAllow + case api.DecisionEffectRedact: + return GatewayEffectRedact + case api.DecisionEffectAuditOnly: + return GatewayEffectAuditDenied + default: + return GatewayEffectDeny + } +} diff --git a/internal/markitect/decision_test.go b/internal/markitect/decision_test.go new file mode 100644 index 0000000..ec99698 --- /dev/null +++ b/internal/markitect/decision_test.go @@ -0,0 +1,111 @@ +package markitect_test + +import ( + "testing" + + "github.com/netkingdom/flex-auth/internal/markitect" + "github.com/netkingdom/flex-auth/pkg/api" +) + +func TestGatewayDecisionAllowContract(t *testing.T) { + got := markitect.ToGatewayDecision(baseEnvelope(api.DecisionEffectAllow)) + + if got.Effect != markitect.GatewayEffectAllow { + t.Fatalf("Effect = %q; want allow", got.Effect) + } + if got.Reason != "reader_group" || got.RuleID != "reader_group" { + t.Fatalf("reason/rule = %q/%q; want reader_group", got.Reason, got.RuleID) + } + if got.PolicyVersion != "markitect-gateway-v1" { + t.Fatalf("PolicyVersion = %q", got.PolicyVersion) + } + if got.ResourceMetadata["trust_zone"] != "internal" { + t.Fatalf("ResourceMetadata = %+v; want trust_zone", got.ResourceMetadata) + } + if got.CaringDescriptor == nil || got.CaringDescriptor.CanonicalRole != api.CanonicalRoleDoer { + t.Fatalf("CaringDescriptor = %+v; want Doer descriptor", got.CaringDescriptor) + } +} + +func TestGatewayDecisionDenyContract(t *testing.T) { + got := markitect.ToGatewayDecision(baseEnvelope(api.DecisionEffectDeny)) + if got.Effect != markitect.GatewayEffectDeny { + t.Fatalf("Effect = %q; want deny", got.Effect) + } +} + +func TestGatewayDecisionRedactContract(t *testing.T) { + envelope := baseEnvelope(api.DecisionEffectRedact) + envelope.Obligations = []api.Obligation{ + {Type: "mask_fields", Parameters: map[string]any{"fields": []string{"email"}}}, + } + + got := markitect.ToGatewayDecision(envelope) + if got.Effect != markitect.GatewayEffectRedact { + t.Fatalf("Effect = %q; want redact", got.Effect) + } + if len(got.Obligations) != 1 || got.Obligations[0].Type != "mask_fields" { + t.Fatalf("Obligations = %+v; want mask_fields", got.Obligations) + } +} + +func TestGatewayDecisionAuditDeniedContract(t *testing.T) { + envelope := baseEnvelope(api.DecisionEffectDeny) + envelope.Diagnostics["audit_denied"] = true + + got := markitect.ToGatewayDecision(envelope) + if got.Effect != markitect.GatewayEffectAuditDenied { + t.Fatalf("Effect = %q; want audit_denied", got.Effect) + } + + envelope = baseEnvelope(api.DecisionEffectAuditOnly) + got = markitect.ToGatewayDecision(envelope) + if got.Effect != markitect.GatewayEffectAuditDenied { + t.Fatalf("audit_only Effect = %q; want audit_denied", got.Effect) + } +} + +func baseEnvelope(effect api.DecisionEffect) api.DecisionEnvelope { + return api.DecisionEnvelope{ + ID: "decision:markitect", + Effect: effect, + Reason: "reader_group", + MatchedRule: "reader_group", + MatchedPolicyVersion: "markitect-gateway-v1", + Resource: api.ResourceRef{ + ID: "document:internal-note", + Type: "document", + System: markitect.SystemID, + Attributes: map[string]any{ + "trust_zone": "internal", + "labels": []string{"internal"}, + }, + }, + Subject: api.SubjectRef{ID: "user:alice"}, + Diagnostics: map[string]any{ + "policy_package": "markitect.gateway.check-fixtures", + }, + Provenance: api.DecisionProvenance{ + PolicyVersion: "markitect-gateway-v1", + }, + Caring: &api.CaringDecisionMetadata{ + Descriptor: &api.CaringAccessDescriptor{ + ID: "descriptor:internal-document-reader", + Profile: api.CaringProfileCaring040RC2, + SubjectType: api.SubjectTypeHuman, + OrganizationRelation: api.OrganizationRelationCustomer, + CanonicalRole: api.CanonicalRoleDoer, + Scope: api.CaringScope{ + Level: api.ScopeLevelResource, + ID: "document:internal-note", + }, + Planes: []api.Plane{api.PlaneData}, + Capabilities: []api.Capability{api.CapabilityView}, + }, + ExposureModes: []api.ExposureMode{api.ExposureModeMasked}, + ConformanceFindings: []api.CaringConformanceFinding{ + {Code: "MARKITECT-INTERNAL-READER", Severity: "info", Message: "reader group matched"}, + }, + }, + } +} diff --git a/workplans/FLEX-WP-0003-markitect-consumer-integration.md b/workplans/FLEX-WP-0003-markitect-consumer-integration.md index 6626fc1..0a17e24 100644 --- a/workplans/FLEX-WP-0003-markitect-consumer-integration.md +++ b/workplans/FLEX-WP-0003-markitect-consumer-integration.md @@ -123,7 +123,7 @@ finding set, and exposure/audit behavior. ```task id: FLEX-WP-0003-T005 -status: todo +status: done priority: medium state_hub_task_id: "f9297b0d-69dc-495c-a650-ca671f2c59c7" ```